Skip to main content

Studio Loading on the Create Page

Status (2026-06-30): the 13 issues in Issues found & fixes applied were addressed (8 code fixes + 5 documented-as-intentional), followed by a refactor pass — the pool list moved off Apollo fetchMore onto the shared usePaginatedNoCacheFetch (fixed a “page 2 never loads” bug), and several units were extracted (usePublicStudios, useShouldShowAutoStudios, useStudioRecommendationSelection, useStableShuffledSubset, buildPoolVariables, fetchRecommendationsPage). The architecture sections below describe the current state. See Module decomposition & test coverage for the extracted units and their tests.
The create page’s prompt/asset panel surfaces a browsable studio catalog through StudiosList, which composes a tabbed header (discover / my, persisted in the studios URL param), an overlay search box, a sort dropdown, and a scrolling grid of StudioChips plus an “Auto” recommendation card. Underneath, three distinct loaders feed the grid depending on mode: recommendations (useStudioRecommendations, the default RECOMMENDED order), the catalog (useStudios — public / my / unpublished lists), and search (useTabbedStudioSearch). All three are backed by Apollo’s normalized cache (Studio:{id}), but the catalog and recommendation lists deliberately do not use Apollo fetchMore for pages past the first: page 0 is a live cache-and-network watched query and pages 1+ are fetched no-cache and accumulated in React state via usePaginatedNoCacheFetch. useStudiosList routes one scroll sentinel (LoadMoreSentinel) to whichever list is active (deriveStudioActiveList picks the list, loadMoreStateByList maps it to its load-more state), and recommendationSetId threads through everything as the fresh-slate signal.

Architecture at a glance

Component → hook → query → cache map:
  • StudioProvideruseStudios({ pageSize: 30 })usePublicStudios (public) + 2× useStudiosQuery (my/unpublished, cache-and-network, page 0) + fetchStudiosPage (no-cache, pages 1+) → usePaginatedNoCacheFetch (state) + studios typePolicy (page 0 only). The Auto card and recommendation fallbacks call usePublicStudios directly (public list only — no my/unpublished queries).
  • StudiosListuseStudiosList → merges useStudio (catalog), useStudioRecommendations (RECOMMENDED), useTabbedStudioSearch (search)
  • useStudioRecommendations({ paginated })useStudioRecommendationsQuery (studioRecommendations, cache-and-network, page 0) + fetchRecommendationsPage (no-cache) → usePaginatedNoCacheFetch keyed by recommendationSetId (via buildRecommendationPageKey); selection handlers in useStudioRecommendationSelection
  • StudioRecommendationPoolsProvider → cadenced page-0 no-cache studioRecommendations query + fetchRecommendationsPage (no-cache, pages 1+) → usePaginatedNoCacheFetch for DRAFT/WORKSPACE pools (page-0 variables built by buildPoolVariables)
  • AutoStudiosProvider → independent studioRecommendations query (GENERAL) → stableStudios (shuffle) + autoSubset (useStableShuffledSubset) → Auto card
  • useTabbedStudioSearch → 2× useStudioSearchuseStudioSearchQuery (studioSearch, cache-and-network) + Apollo fetchMorestudioSearch typePolicy
  • StudioTabContent → renders active branch + trailing LoadMoreSentinel (IntersectionObserver)
Where things live:
ConcernFile
Catalog lists (public/my/unpublished)apps/web-core/src/features/generation/prompt/hooks/useStudios.ts
No-cache cursor accumulatorapps/web-core/src/hooks/usePaginatedNoCacheFetch.ts
Provider wiring + refetch on mutationsapps/web-core/src/features/generation/prompt/contexts/studio/StudioProvider.tsx
Merged studio contextapps/web-core/src/features/generation/prompt/contexts/studio/useStudio.ts
Load-more routing + mode gatingapps/web-core/src/features/generation/prompt/components/StudiosList/useStudiosList.ts
Active-branch renderingapps/web-core/src/features/generation/prompt/components/StudiosList/components/StudioTabContent.tsx
Scroll sentinelapps/web-core/src/components/load-more-sentinel/LoadMoreSentinel.tsx
Scroll container + auto-scroll + cmd+Fapps/web-core/src/features/generation/prompt/components/StudiosList/hooks/useStudiosListDomEffects.ts
Recommendations orchestratorapps/web-core/src/features/generation/prompt/hooks/useStudioRecommendations.ts
Request derivationapps/web-core/src/features/generation/prompt/hooks/useStudioRecommendationRequest.ts
Draft inputapps/web-core/src/features/generation/prompt/hooks/useStudioRecommendationDraft.ts
Cadence/debounce gateapps/web-core/src/features/generation/prompt/hooks/studioRecommendationCadence.ts
Shared DRAFT/WORKSPACE poolsapps/web-core/src/features/generation/prompt/providers/StudioRecommendationPoolsProvider.tsx
Auto card sourceapps/web-core/src/features/generation/prompt/contexts/auto-studios/AutoStudiosProvider.tsx
Auto visibility lockstepapps/web-core/src/features/generation/prompt/hooks/useSettledAutoStudioVisibility.ts
Search orchestrationapps/web-core/src/features/generation/prompt/hooks/useTabbedStudioSearch.ts
Single search instanceapps/web-core/src/features/generation/prompt/hooks/useStudioSearch.ts
Standalone public listusePublicStudios (exported from hooks/useStudios.ts)
Recs page-fetch + slate-key + metadata helpersapps/web-core/src/features/generation/prompt/hooks/studioRecommendationContext.ts
Pool request variables (pure, testable)apps/web-core/src/features/generation/prompt/providers/studioRecommendationPoolVariables.ts
Recs selection handlersapps/web-core/src/features/generation/prompt/hooks/useStudioRecommendationSelection.ts
Auto-card stable shuffled subsetapps/web-core/src/features/generation/prompt/contexts/auto-studios/useStableShuffledSubset.ts
Auto-card visibility for prompt modelsapps/web-core/src/features/generation/prompt/hooks/useShouldShowAutoStudios.ts
Apollo cache configapps/web-core/src/apollo.ts
Eviction helpersapps/web-core/src/helpers/cacheEvictHelpers.ts
Cross-tab invalidationapps/web-core/src/helpers/cacheInvalidation.ts
Refetch on tab focusapps/web-core/src/hooks/useRefetchOnVisibility.ts
GraphQL operationsapps/web-core/src/graphql/studio.graphql

Pagination

The catalog uses a hybrid page-0-watched + pages-1+-accumulated model, one usePaginatedNoCacheFetch instance per list (public, my, unpublished).
  • Page 0 is a useStudiosQuery with fetchPolicy: 'cache-and-network' (one per list in useStudios.ts). It stays live and re-renders whenever a sibling studio/recommendation query writes overlapping Studio entities into the cache. data.studios.studios is memoized into firstPageItems; pageInfo.nextCursor becomes firstPageNextCursor.
  • Pages 1+ are fetched through fetchStudiosPageclient.query({ fetchPolicy: 'no-cache' }) and accumulated in component useState inside usePaginatedNoCacheFetch (extraPages). They never touch the normalized cache.
Why no-cache + state instead of fetchMore (rationale comment atop usePaginatedNoCacheFetch): a cache-and-network watched query reobserves on every cache broadcast (sibling writes, polling, persistence rehydration). That reobservation both aborts an in-flight fetchMore and — under the replaceOnRefetch merge — clobbers already-merged pages. The scroll sentinel would then re-fire forever and the list would never grow past page 0. A standalone no-cache fetch “sits out that fight”: page 0 stays live via its watched query, extra pages are owned in state and untouched by cache writes. Guards (all in usePaginatedNoCacheFetch.ts):
  • pageKey — list identity: public = public|${orderKey}|${pageSize}, my = my|${activeWorkspaceId ?? ''}|${orderKey}|${pageSize}, unpublished = unpublished|${pageSize}, recommendations = ${cacheKey}::${recommendationSetId ?? 'none'}. A useEffect(() => reset(), [reset]) drops extraPages whenever pageKey changes (tab/sort/workspace/served-set switch).
  • fetched: boolean on extraPages — distinguishes a successful-but-empty page (advance/terminate the cursor) from “never fetched” (fall back to firstPageNextCursor). Without it an empty page would refetch page 0’s cursor forever. hasExtraPages = extraPages.key === pageKey && extraPages.fetched; nextCursor = hasExtraPages ? extraPages.nextCursor : (firstPageNextCursor ?? null); hasMore = Boolean(nextCursor).
  • isFetchingMoreRef — blocks two synchronous loadMore calls in one render before isFetchingMore state commits.
  • failedCursorRef — a failed fetchPage (returns null) records the cursor; loadMore early-returns if (failedCursorRef.current === nextCursor) so a persistently failing page can’t spin the sentinel. Cleared on the next successful page or reset().
  • resetEpochRef — snapshotted as startEpoch before the await; isStale = resetEpochRef.current !== startEpoch discards an in-flight page whose list was reset mid-fetch, so a refetch’s reset isn’t undone. A second if (prev.key !== pageKey) return prev inside setExtraPages covers pageKey identity change.
orderKey mapping (toStudiosOrderBy in useStudios.ts): UNPUBLISHED | RECOMMENDED | MY_STUDIOS all collapse to 'USAGE_COUNT_DESC'; any other order passes through. The unpublished list hardcodes orderBy: 'CREATED_AT_DESC'. sortNewStudiosFirst (useStudios.ts) floats studios created within ONE_WEEK_MS to the top (newest first), leaving the rest in server order. Applied only to myStudiosRaw; studios = [...publicStudios, ...myStudios]. This workspace-only scope is intentional (documented in-code): a user expects their own fresh studios to surface immediately, while the public catalog stays in its curated popularity ranking. Page sizes: STUDIOS_SCROLL_PAGE_SIZE = 30 (the paginated panel, passed by StudioProvider and now AutoStudiosProvider so they share one cache entry); STUDIOS_DEFAULT_PAGE_SIZE = 500 only for the few non-paginated full-catalog callers (paginated/derive-a-subset callers pass an explicit small pageSize). Scroll sentinel (LoadMoreSentinel.tsx): an IntersectionObserver rooted on the nearest scrollable ancestor with rootMargin: '800px 0px'. It calls onLoadMore when isInView && hasMore && !isFetchingMore. The box stays mounted (only py toggles with hasMore) so the observer keeps observing if hasMore later flips true. (Note: the observer lives here, not in useStudiosListDomEffects, which only owns the scroll container ref + auto-scroll + cmd/ctrl+F shortcut.) What happens when you scroll:
  1. The sentinel enters view (within 800px of the viewport).
  2. onLoadMore → the active list’s loadMore (deriveStudioActiveList picks the active list; loadMoreStateByList[activeList] supplies its load-more state).
  3. loadMore checks nextCursor, isFetchingMoreRef, and failedCursorRef; sets isFetchingMore; snapshots startEpoch.
  4. await fetchPage(nextCursor)client.query no-cache.
  5. On success (not stale): append page.items to extraPages.items, advance extraPages.nextCursor, set fetched: true.
  6. The deduped items memo recomputes ([...firstPageItems, ...extraPages.items] skipping seen id) and the grid re-renders with the new rows.
  7. isFetchingMore clears; if the sentinel is still in view and hasMore, it fires again.

Recommendations

The default RECOMMENDED order renders studios from useStudioRecommendations, not from the catalog query. It composes three layers — request derivation, cadence/debounce, and a surface-dependent delivery layer. Trigger / cadence / debounce:
  • Free prompt text is mention-stripped and debounced 300ms (STUDIO_RECOMMENDATION_PROMPT_DEBOUNCE_MS); model add/remove is live (non-debounced) and drives recommendationParams + the draft’s customModelIds.
  • studioRecommendationCadence gates releases two ways: a text edit-distance threshold of 20 (isEditDistanceAtLeast, suppressed once both prompts hit the 2000-char max), and a global trailing rate limit of 1000ms (lastGlobalReleaseAt, shared across all cadence instances so multiple surfaces share one 1/sec window). Non-text changes (nonPromptKey: model/style/settings/context) bypass the text threshold but still pay the rate limit.
surfaceContext + draft: surfaceContext ('DRAFT' | 'GENERAL' | 'WORKSPACE') tells the server which ranking surface to serve — PROMPT_STRIP pool = DRAFT, asset panel = WORKSPACE, AutoStudiosProvider + unpooled paginated query = GENERAL. draft (StudioRecommendationDraftInput) carries in-progress intent: stripped prompt, assetType, aspectRatio, mode, requestedOutputCount, styleRefImageUrl, customModelIds. recommendationSetId as the fresh-slate signal: the server returns a recommendationSetId per response; a new id means a fresh slate, and every downstream consumer keys on it. displayedRecommendationSetId must come from the same query that supplied studios — a null id means fallback catalog and therefore no selection receipt. The pool only commits a payload once the query resolves for the current requestKey (gated on !loading), so a stale slate is never written under a new key; isSettled = poolState.requestKey === requestKey. recommendationItems vs studios: attachRecommendationMetadata joins recommendationItems[].metadata (lane, scores.totalScore/usedScore/likedScore) onto each studio by studio.id. The visible chips come from studios; recommendationItems is metadata only. Pools provider pagination: StudioRecommendationPoolsProvider exposes shared DRAFT/WORKSPACE pools. It previously paginated via Apollo fetchMore on a no-cache query, but for a no-cache observable the updateQuery merge isn’t reliably reflected back into data, so the merged page 2 was dropped (the load-more skeleton flashed but the list never grew past page 1). It now uses the same standard as the catalog/unpooled lists: page 0 is the watched no-cache query (variables from buildPoolVariables), pages 1+ go through usePaginatedNoCacheFetch + fetchRecommendationsPage. pageKey (built by buildRecommendationPageKey) embeds the served recommendationSetId so a fresh set drops accumulated pages, and the cursor is exposed only once the page-0 result isSettled for the active request (firstPageReady: isSettled). Page size 60 (STUDIO_RECOMMENDATION_POOL_PAGE_SIZE). Auto-studios subset/shuffle vs deterministic top-N: AutoStudiosProvider runs a separate independent studioRecommendations query (GENERAL) backing only the Auto card. stableStudios = shuffle([...recommendedStudios]) (memoized on params + studio signature, excluding shuffleTrigger) is exposed as recommendedStudioIds (deterministic pin-at-top top-N for the #-menu); autoSubset = getLimitedStudios keeps still-valid previous picks then shuffles the remainder, sized by imageNumberOfSamples and re-rolled on shuffleTrigger. useSettledAutoStudioVisibility lockstep: holds the Auto card’s shouldShow steady mid-request and snaps to the live value only when recommendationSetId changes (adjust-state-on-prop-change), so the card doesn’t flicker while a new slate is in flight. Pooled surfaces instead snapshot pool.shouldShowAutoStudios at commit time. RECOMMENDED browse mode in the catalog: useStudiosList feeds raw flags (search/tab/order) into deriveStudioActiveList (listMode.ts), the single precedence ladder; isRecommendedBrowseMode = activeList === 'recommended' is derived FROM the discriminant (not encoded in parallel) and drives isCatalogLoading = isRecommendedBrowseMode ? false : loadingStudios — so the recommendations path never shows the catalog spinner. The activeList discriminant (‘search’|‘my’|‘unpublished’|‘recommended’|‘public’) indexes a loadMoreStateByList record, routing the sentinel to the recommended list’s load-more in this mode (replacing the old getLoadMoreState ladder).
Note (fixed): a comment in useStudioRecommendations.ts used to claim an Apollo studioRecommendations field policy merges pages — that was stale and has been corrected. The real unpooled merge is usePaginatedNoCacheFetch (no-cache + in-state accumulation); the only Apollo merge for recommendations is the manual updateQuery in the pools provider.
Search is an overlay over the tabs, orchestrated by useTabbedStudioSearch and gated behind the GrowthBook flag studio-search-sort (also gates cmd+F and the sort dropdown).
  • It owns the raw searchTerm (useState) and debounces it with useDebounce(searchTerm, 500). Only the debounced value reaches the query.
  • It instantiates two independent useStudioSearch instances — discover (workspaceId: undefined, filters { published: true, type: 'ADMIN' }) and my (workspaceId: activeWorkspaceId, filters { type: 'WORKSPACE' }). The debounced term is fed only to the active-tab instance (searchQuery: isActive ? debouncedSearchTerm : ''); the inactive instance gets '', which sets skip: !searchQuery so it never fires.
useStudioSearch runs useStudioSearchQuery (studioSearch) with fetchPolicy: 'cache-and-network' and notifyOnNetworkStatusChange: true. Unlike the catalog, search paginates with real Apollo fetchMoreloadMore calls fetchMore({ variables: { cursor: nextCursor } }) and Apollo merges the page into the cache, so searchData.studioSearch.studios already holds all loaded pages. hasMore = Boolean(nextCursor); first = STUDIO_SEARCH_PAGE_SIZE = 30. isFetchingMore is a local useState toggled around the promise (not networkStatus), so existing results stay rendered instead of flashing skeletons. Initial-load gate: isLoading = !!searchQuery && apolloSearchLoading && !searchData?.studioSearch. Resubmit (submitManualSearch) calls refetchSearch({ query, cursor: null }), resetting to page 1. Analytics dedup via reportedQueryRef fires Studios Search Submitted once per distinct query despite onCompleted firing after each fetchMore. How search mode overlays tabs/order: useStudiosList computes isSearchActiveMode = isControlsSearchActive && !!searchTerm. When true, deriveStudioActiveList resolves to 'search' before considering tab or order (search has top precedence), so loadMoreStateByList hands the sentinel the search hook’s load-more, and StudioTabContent renders searchedStudios. Entering search does not change studioOrder — on the discover tab the order can still be RECOMMENDED. To avoid mis-attributing a pick from search results to the served recommendation set, useStudioSelectionFlow forces recommendationSetId = null when isControlsSearchActive && searchTerm. The my tab is coupled to search through the same debounced term but a workspace-scoped instance; RECOMMENDED is invalid on the my tab (shouldShowStudioOrderOption hides it; useDiscoverStudioSortSync swaps it to USAGE_COUNT_DESC/MY_STUDIOS), and selecting the synthetic MY_STUDIOS option routes through handleStudioOrderChangehandleTabChange('my').

Apollo cache

The studio cache config (apollo.ts) is a generic cursor-pagination machine.
  • paginationMerge / paginationRead (apollo.ts) store each list as an ID-keyed map plus a parallel _orderedIds array preserving the server’s sort order (popular/newest/A-Z). paginationRead resolves _orderedIds back to ordered, dangling-ref-filtered refs; absent it, falls back to createdAt-desc over Object.values.
  • replaceOnRefetch: true on all four studio typePolicies (studios, studioSearch, studioSearchAdmin, studioRecommendations): a cursorless network response replaces the cached list so cross-tab/cross-device deletions drop out — unless the user paginated in-session (sessionFetchMoreKeys) or the incoming page is exactly the head of cached _orderedIds (incomingRevalidatesHead).
  • pageZeroOnly: true on studios + studioRecommendations (the usePaginatedNoCacheFetch lists): a head-revalidating refetch adopts the incoming cursor and scalars as one pair — a stale persisted cursor must not stick when the entry only ever holds page 0. fetchMore lists (boards, videoBlocks, items…) do NOT set it: their deep accumulated cursor must survive a head-match refetch, else the sentinel re-walks every cached page after reload.
  • keyArgs per typePolicy includes page size n so consumers requesting different first values for the same logical list never collide and interleave pages. studios key: ${workspaceId ?? 'discover'}-${type}-pub:${published}-${orderBy}-n:${first}. Each distinct first keys a separate cache entry. Post-fix the scroll panel and the Auto-card fallback both request first:30, so they now share one entry (was 30 vs 60); only the rare full-catalog caller uses first:500.
  • studioRecommendations routing: the merge field is 'studios' only; recommendationItems falls into ...rest and comes from the incoming response each merge. With pageZeroOnly, a head-revalidating refetch adopts cursor + recommendationSetId + recommendationItems together — one response, one set — so ranker surfaces (WORKSPACE/DRAFT, e.g. the paginated bulk-wizard query) read consistent lane metadata through the cache. Pages 1+ never hit this policy (fetchRecommendationsPage is no-cache), so no page-2 metadata is ever cached — the accumulated pages carry their own metadata in hook state.
  • Eviction/broadcast: evictStudios removes Studio:{id}, runs cache.gc(), and calls broadcastCacheEviction('Studio', ids) over BroadcastChannel('kive-cache-invalidation'); paginationRead’s null-filter then drops the dangling refs. refreshStudiosList evicts the three root fields (studios, studioRecommendations, studioSearch). Studio entities use the default Studio:{id} keyFields (no custom keyFields anywhere), so any query/fragment sharing StudioListFields updates the same normalized entity.
  • Refetch-on-visibility: useRefetchOnVisibility refetches QUERIES_TO_REFETCH (includes studios, excludes studioRecommendations) on tab-visible/refocus, throttled 30s, relying on replaceOnRefetch to prune cross-device deletions.
  • CACHE_VERSION = 'v5' (apollo.ts) — v4 purged stale multi-page studio entries from before the no-cache redesign; v5 purges entries written under the pre-pageZeroOnly cursor/scalar pairing rules.
Is the studios merge policy actually exercised? For the infinite-scroll panel, the multi-page logic is effectively dead (now noted in an in-code comment on the policy). useStudios writes page 0 through the watched cache-and-network query (which does hit paginationMerge/paginationRead for replace/revalidate-head), but every page past the first goes through fetchStudiosPage no-cache — never Apollo fetchMore. isFetchMore (Boolean(args?.cursor)) is therefore never true for studios, no second page is ever written, _orderedIds never grows past page 0, and the append / in-session-pagination branches cannot run for the panel. The same is true of studioRecommendations’ unpooled path; studioSearch is the one studio policy whose fetchMore append branch is genuinely exercised (search uses real fetchMore).

Issues found & fixes applied

All 13 verified issues are resolved: ✅ fixed = behavior/code change; 📝 intentional = no behavior change, the design choice is now documented in-code (and the “suspicious” framing was the gap).
#SevAreaIssueResolutionStatus
1MEDCross-tab stalenessNo-cache accumulated pages never received cross-tab eviction broadcastsusePaginatedNoCacheFetch takes an evictTypename and listens on the kive-cache-invalidation BroadcastChannel, filtering evicted ids out of extraPages.items; evictTypename: 'Studio' wired at all 4 call sites✅ fixed
2MEDRecommendations racePaginated-recs firstPageNextCursor derived from the held previous payload, so load-more during a refetch could fetch a new slate at the old cursor and mix slates under one set idfirstPageNextCursor now comes from freshPayload only — while a refetch is in flight hasMore falls to false instead of exposing the stale cursor; items still render from the held payload so nothing flickers✅ fixed
3MEDPerformanceAutoStudiosProvider fetched public studios at page size 60 → a distinct cache key from the panel’s 30, duplicating the network fetchAutoStudiosProvider now requests STUDIOS_SCROLL_PAGE_SIZE (30) so it shares the panel’s page-0 cache entry (one fetch, not two)✅ fixed
4MEDPerformanceuseStudioRecommendations’ override-fallback useStudios used the default 500, then sliced to countReverted after review: a count-sized window broke the recency float (a fresh studio has ~zero usage → bottom of USAGE_COUNT_DESC, so it never entered the window). Full 500 restored on this rare path (override product without category), tradeoff documented in-code📝 intentional
5LOWPerformanceStudioProvider fires 2–3 parallel studios queries on mount with no enabled, even when the panel is closedConfirmed intentional prefetch (instant panel open) and documented; public query now shares one cache key with AutoStudios, so the cost is one fetch. The enabled escape hatch is called out for surfaces that want to defer📝 intentional
6LOWPerformanceSTUDIOS_DEFAULT_PAGE_SIZE = 500 unbounded single-shot defaultThe real waste (recs caller) now passes an explicit pageSize; the 500 default is documented as for full-catalog callers only✅ fixed
7LOWCorrectnessfailedCursorRef could permanently suppress a valid retry if page 0 re-yielded the same cursorAn effect clears failedCursorRef whenever firstPageNextCursor changes (page 0 advanced)✅ fixed
8LOWStalenessUnpooled recs freshPayload effect wrote the result-cache with no loading guardThe setEntry write is now gated on !paginatedLoading, so an in-flight slate can’t be persisted under the surface key✅ fixed
9LOWConsistencyorderKey collapses RECOMMENDEDUSAGE_COUNT_DESC; the public query still runs in RECOMMENDED mode though recs supply the displayed listDocumented in-code: the public query pre-warms the cache so switching to an explicit sort renders instantly. (Dead gridStudios consumer — see #12 — removed.)📝 intentional
10LOWConsistencysortNewStudiosFirst floats new studios only in myStudios, not the public listDocumented as intentional — curated public feed stays in popularity order; users’ own fresh studios float📝 intentional
11LOWStalenessstudioRecommendations policy drops page-2+ recommendationItems metadata, guarded only by a conventionSuperseded: no fetchMore exists on this field anymore (pages 1+ are no-cache), so the dev-warn wrapper was unreachable and is deleted; the merge now adopts cursor + set id + items as one pair (pageZeroOnly), making ranker surfaces safe through the cache✅ fixed
12LOWCorrectnessgridStudios seeded shuffle keyed on position index → non-uniform “featured” pickDeletedgridStudios had no consumer (dead code); the buggy LCG shuffle is gone✅ fixed
13LOWCorrectnessitems dedup keeps the live page-0 copy over a frozen extra-page copy → rendered length can flicker by oneBenign by design; documented in-code with the page-0-wins rationale📝 intentional

✅ Code fixes (8)

#1 — cross-tab eviction reaches no-cache pages. Pages past page 0 live in component useState, not the normalized cache, so the app-wide useCacheInvalidationListener (which only evicts Studio:{id}) couldn’t prune them — a studio deleted/moved-to-public in another tab stayed visible in scrolled pages until the next pageKey change. usePaginatedNoCacheFetch now subscribes via subscribeToCacheEvictions (helpers/cacheInvalidation.ts) when given evictTypename and resets when an accumulated page holds an affected id. One shared BroadcastChannel per tab fans out to all subscribers — the app-wide listener uses the same helper (the create page mounts ~10 paginated lists; per-hook channels would each deserialize every broadcast). All three catalog lists and the recs lists pass evictTypename: 'Studio'. (Same-tab deletes were already covered for the catalog by refetchAllStudios(); this also closes the residue on the separate recs instance.) #2 — no slate-mix during a recs refetch. usePaginatedNoCacheFetch’s firstPageNextCursor now reads freshPayload?.pageInfo?.nextCursor (the just-resolved page-0 query) instead of the held currentRecommendationPayload. During an in-flight refetch with changed variables freshPayload is undefined, so an un-paginated list reports hasMore: false rather than handing recommendationsFetchPage the previous slate’s cursor (which it would have fetched against the new variables). firstPageItems still comes from the held payload, so the visible slate doesn’t shrink. Once the request settles, the fresh cursor takes over and — if the slate changed — the new recommendationSetId flips pageKey and resets the accumulator. #3 / #4 / #6 — duplicate public-studio fetches. On the create page up to three useStudios instances ran with different page sizes; because keyArgs includes -n:${first}, each keyed a distinct cache entry and re-fetched the same published: true catalog. AutoStudiosProvider now uses STUDIOS_SCROLL_PAGE_SIZE (shares the panel’s entry), so the always-on duplication is gone. The recs override-fallback deliberately keeps the 500 default (#4 above): its recency float needs the whole catalog window, and the path is rare. #7 — failed-cursor block clears when page 0 advances. failedCursorRef blocks the scroll sentinel from re-firing a failed cursor, but it could outlive a transient failure. A useEffect keyed on firstPageNextCursor now resets the block whenever page 0’s cursor moves. #8 — recs result-cache write waits for settle. The cross-instance handoff setEntry(cacheKey, …) is gated on !paginatedLoading, so a one-render stale-variables payload can’t be persisted under the surface key. #11 — ranker metadata consistent through the cache. The old guard was a dev-only studioRecommendationsMerge warn on fetchMore pages — unreachable once pages 1+ went no-cache (no fetchMore exists on the field), and its “cache-backed caller is always GENERAL” premise stopped holding when the paginated bulk-wizard query switched to WORKSPACE. Both are gone: the merge (pageZeroOnly) now takes cursor + recommendationSetId + recommendationItems from the same incoming response on every write, so whatever surface reads through the cache gets one consistent set. #12 — dead gridStudios removed. gridStudios (a 3-item seeded shuffle of publicStudios) had no consumer anywhere in the app; the position-indexed LCG that made the pick non-uniform is deleted with it.

📝 Documented as intentional (5)

#5 — eager prefetch in StudioProvider. Fetching on mount (no enabled) is a deliberate prefetch so opening the panel renders from cache instantly; post-#3 the public query is one shared fetch, not two. The enabled flag (e.g. tied to visibleBlock === 'studio') is documented for callers that want to defer. #9 — public query in RECOMMENDED mode. orderKey collapses RECOMMENDED to USAGE_COUNT_DESC and the displayed list comes from useStudioRecommendations, but the public query still runs to pre-warm the cache so switching to an explicit sort doesn’t flash a spinner. Now stated in-code. #10 — workspace-only new-studio float. sortNewStudiosFirst applies to myStudios only by design: the public catalog stays in its curated popularity ranking; a user’s own fresh studios surface immediately. #13 — one-row dedup flicker. items dedups page-0-first, so a live page 0 over frozen tail pages can change the rendered count by one until the next reset — benign and self-healing; documented at the dedup site.

Module decomposition & test coverage

The god hooks were decomposed into focused, independently-testable units. Tests are *.skip.test.* — they run locally and on pre-push but are excluded from CI (repo convention). Pure helpers are tested directly; hooks use a lightweight createRoot/act render harness.
UnitFileTests
usePaginatedNoCacheFetch (page-0 + no-cache accumulator, firstPageReady, eviction reset, failed-cursor block)hooks/usePaginatedNoCacheFetch.tshooks/__tests__/usePaginatedNoCacheFetch.skip.test.tsx (17)
sortStudiosByRecent + pickPrimaryModelForRecommendations + buildRecommendationParamsfeatures/.../utils/studioUtils.tsutils/__tests__/studioUtils.skip.test.ts (14)
useStableShuffledSubset (Auto-card carry-over + re-roll)features/.../contexts/auto-studios/useStableShuffledSubset.ts__tests__/useStableShuffledSubset.skip.test.tsx (8)
buildPoolVariables (pure, dependency-free)features/.../providers/studioRecommendationPoolVariables.tsproviders/__tests__/studioRecommendationPoolVariables.skip.test.ts (3)
useShouldShowAutoStudiosfeatures/.../hooks/useShouldShowAutoStudios.tshooks/__tests__/useShouldShowAutoStudios.skip.test.tsx (3)
useStudioRecommendationSelectionfeatures/.../hooks/useStudioRecommendationSelection.tshooks/__tests__/useStudioRecommendationSelection.skip.test.tsx (3)
deriveStudioActiveList (useStudiosList’s list discriminant + precedence)features/.../components/StudiosList/listMode.ts__tests__/listMode.skip.test.ts (8)
resolveRecommendationMode (useStudioRecommendations’ pooled/unpooled/paginated matrix)features/.../hooks/studioRecommendationMode.tshooks/__tests__/studioRecommendationMode.skip.test.ts (5)
resolveActivePoolRequestKey (pool’s cadence-pending request-key gate)features/.../providers/studioRecommendationPoolVariables.tsproviders/__tests__/studioRecommendationPoolVariables.skip.test.ts (4)
The god hooks (useStudioRecommendations, useStudiosList) and the pool provider’s stateful core are not unit-tested as whole hooks — they coordinate many contexts/effects where a hook test would mostly mock the world. Instead their decision logic is extracted into the pure functions above and tested directly (deriveStudioActiveList, resolveRecommendationMode, resolveActivePoolRequestKey), and their pagination behavior runs through the tested usePaginatedNoCacheFetch. The remaining React plumbing (effect ordering, context wiring) is covered at the e2e layer — the place where “does scrolling actually load page 2 in a real browser” can be asserted:
e2e scenarioSpec
Scrolling the studios list loads page 2+ and never shrinks; re-sorting keeps it populatedapps/web-core/tests/generate-image/studios-pagination.spec.ts
Auto-card activation on model add / shuffle / style-refapps/web-core/tests/generate-image/auto-studios.spec.ts (pre-existing)
These run in the preview e2e pipeline (the PR carries the #web-core preview e2e trigger), not in unit CI (test.skip(!!process.env.CI)), matching the sibling generate-image specs. Deliberately deferred (documented but not done — they change UX/state shape or interleave incident-hardened gating and warrant their own e2e-verified PR): the full ListSource/StudiosOrderBy orthogonal-axes split (B3), per-list symmetry for my/unpublished (A2), and the usePooledStudioRecommendations + useUnpooledStudioRecommendations facade split (A1).

Glossary

  • pageKey — string identity of a paginated list (filters/sort/workspace/page-size, plus served set id for recs); a change resets usePaginatedNoCacheFetch’s extraPages.
  • recommendationSetId — server-issued id per recommendation response; a new value signals a fresh slate that all downstream consumers key on; null = fallback catalog = no selection receipt.
  • orderKey — the GraphQL orderBy derived from UI studioOrder; RECOMMENDED/UNPUBLISHED/MY_STUDIOS collapse to USAGE_COUNT_DESC.
  • isRecommendedBrowseModeuseStudiosList flag derived from the discriminant (activeList === 'recommended') that makes the panel read recommendations and skip the catalog spinner.
  • isSearchActiveModeisControlsSearchActive && !!searchTerm; overrides tab/order routing for load-more and rendering.
  • _orderedIds — parallel string[] on a cached list preserving server sort order; paginationRead resolves and dangling-filters it.
  • replaceOnRefetch — typePolicy flag: a cursorless response replaces the cached list (prunes deletions), unless in-session paginated or revalidating the head.
  • incomingRevalidatesHead — guard: an incoming page equal to the head of existing _orderedIds revalidates a multi-page list instead of collapsing it.
  • pageZeroOnly — typePolicy flag for usePaginatedNoCacheFetch lists (studios, studioRecommendations): the cache entry only ever holds page 0, so a head-revalidating refetch adopts the incoming cursor + scalars as one consistent pair.
  • fetchedextraPages boolean distinguishing a successful-but-empty page (advance/terminate cursor) from “never fetched” (fall back to firstPageNextCursor).
  • failedCursorRef / resetEpochRef / isFetchingMoreRefusePaginatedNoCacheFetch guards against sentinel spin on failure, stale-page append after reset, and double-fire within one render.
  • STUDIOS_SCROLL_PAGE_SIZE30, the paginated panel page size.
  • STUDIOS_DEFAULT_PAGE_SIZE500, the non-paginated legacy default.
  • STUDIO_RECOMMENDATION_POOL_PAGE_SIZE60, the pooled-recommendations page size (no-cache + usePaginatedNoCacheFetch).
  • STUDIO_SEARCH_PAGE_SIZE30, the studioSearch page size.
  • attachRecommendationMetadata — joins recommendationItems[].metadata (lane/scores) onto studios by studio.id.
  • isSettled / useSettledAutoStudioVisibility — hold a previous slate / Auto-card visibility steady until recommendationSetId changes.
  • buildPoolVariables / fetchRecommendationsPage / buildRecommendationPageKey — shared pool/recs helpers: build the studioRecommendations page-0 + cursor variables, run one no-cache page fetch + metadata attach, and build the slate-scoped pageKey.
  • firstPageReadyusePaginatedNoCacheFetch flag (default true); when false (page 0 mid-refetch) it suppresses the whole load-more cursor without dropping accumulated pages.
  • usePublicStudios — standalone published-list hook (query + no-cache paginator); used by the panel via useStudios and directly by the Auto card / recommendation fallbacks.
  • CACHE_VERSION'v5'; bumping scopes the persisted IndexedDB key and purges caches written under older merge semantics.