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 ApolloThe create page’s prompt/asset panel surfaces a browsable studio catalog throughfetchMoreonto the sharedusePaginatedNoCacheFetch(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.
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:StudioProvider→useStudios({ pageSize: 30 })→usePublicStudios(public) + 2×useStudiosQuery(my/unpublished,cache-and-network, page 0) +fetchStudiosPage(no-cache, pages 1+) →usePaginatedNoCacheFetch(state) +studiostypePolicy (page 0 only). The Auto card and recommendation fallbacks callusePublicStudiosdirectly (public list only — no my/unpublished queries).StudiosList→useStudiosList→ mergesuseStudio(catalog),useStudioRecommendations(RECOMMENDED),useTabbedStudioSearch(search)useStudioRecommendations({ paginated })→useStudioRecommendationsQuery(studioRecommendations,cache-and-network, page 0) +fetchRecommendationsPage(no-cache) →usePaginatedNoCacheFetchkeyed byrecommendationSetId(viabuildRecommendationPageKey); selection handlers inuseStudioRecommendationSelectionStudioRecommendationPoolsProvider→ cadenced page-0no-cachestudioRecommendationsquery +fetchRecommendationsPage(no-cache, pages 1+) →usePaginatedNoCacheFetchfor DRAFT/WORKSPACE pools (page-0 variables built bybuildPoolVariables)AutoStudiosProvider→ independentstudioRecommendationsquery (GENERAL) →stableStudios(shuffle) +autoSubset(useStableShuffledSubset) → Auto carduseTabbedStudioSearch→ 2×useStudioSearch→useStudioSearchQuery(studioSearch,cache-and-network) + ApollofetchMore→studioSearchtypePolicyStudioTabContent→ renders active branch + trailingLoadMoreSentinel(IntersectionObserver)
| Concern | File |
|---|---|
| Catalog lists (public/my/unpublished) | apps/web-core/src/features/generation/prompt/hooks/useStudios.ts |
| No-cache cursor accumulator | apps/web-core/src/hooks/usePaginatedNoCacheFetch.ts |
| Provider wiring + refetch on mutations | apps/web-core/src/features/generation/prompt/contexts/studio/StudioProvider.tsx |
| Merged studio context | apps/web-core/src/features/generation/prompt/contexts/studio/useStudio.ts |
| Load-more routing + mode gating | apps/web-core/src/features/generation/prompt/components/StudiosList/useStudiosList.ts |
| Active-branch rendering | apps/web-core/src/features/generation/prompt/components/StudiosList/components/StudioTabContent.tsx |
| Scroll sentinel | apps/web-core/src/components/load-more-sentinel/LoadMoreSentinel.tsx |
| Scroll container + auto-scroll + cmd+F | apps/web-core/src/features/generation/prompt/components/StudiosList/hooks/useStudiosListDomEffects.ts |
| Recommendations orchestrator | apps/web-core/src/features/generation/prompt/hooks/useStudioRecommendations.ts |
| Request derivation | apps/web-core/src/features/generation/prompt/hooks/useStudioRecommendationRequest.ts |
| Draft input | apps/web-core/src/features/generation/prompt/hooks/useStudioRecommendationDraft.ts |
| Cadence/debounce gate | apps/web-core/src/features/generation/prompt/hooks/studioRecommendationCadence.ts |
| Shared DRAFT/WORKSPACE pools | apps/web-core/src/features/generation/prompt/providers/StudioRecommendationPoolsProvider.tsx |
| Auto card source | apps/web-core/src/features/generation/prompt/contexts/auto-studios/AutoStudiosProvider.tsx |
| Auto visibility lockstep | apps/web-core/src/features/generation/prompt/hooks/useSettledAutoStudioVisibility.ts |
| Search orchestration | apps/web-core/src/features/generation/prompt/hooks/useTabbedStudioSearch.ts |
| Single search instance | apps/web-core/src/features/generation/prompt/hooks/useStudioSearch.ts |
| Standalone public list | usePublicStudios (exported from hooks/useStudios.ts) |
| Recs page-fetch + slate-key + metadata helpers | apps/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 handlers | apps/web-core/src/features/generation/prompt/hooks/useStudioRecommendationSelection.ts |
| Auto-card stable shuffled subset | apps/web-core/src/features/generation/prompt/contexts/auto-studios/useStableShuffledSubset.ts |
| Auto-card visibility for prompt models | apps/web-core/src/features/generation/prompt/hooks/useShouldShowAutoStudios.ts |
| Apollo cache config | apps/web-core/src/apollo.ts |
| Eviction helpers | apps/web-core/src/helpers/cacheEvictHelpers.ts |
| Cross-tab invalidation | apps/web-core/src/helpers/cacheInvalidation.ts |
| Refetch on tab focus | apps/web-core/src/hooks/useRefetchOnVisibility.ts |
| GraphQL operations | apps/web-core/src/graphql/studio.graphql |
Pagination
The catalog uses a hybrid page-0-watched + pages-1+-accumulated model, oneusePaginatedNoCacheFetch instance per list (public, my, unpublished).
- Page 0 is a
useStudiosQuerywithfetchPolicy: 'cache-and-network'(one per list inuseStudios.ts). It stays live and re-renders whenever a sibling studio/recommendation query writes overlappingStudioentities into the cache.data.studios.studiosis memoized intofirstPageItems;pageInfo.nextCursorbecomesfirstPageNextCursor. - Pages 1+ are fetched through
fetchStudiosPage→client.query({ fetchPolicy: 'no-cache' })and accumulated in componentuseStateinsideusePaginatedNoCacheFetch(extraPages). They never touch the normalized cache.
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'}. AuseEffect(() => reset(), [reset])dropsextraPageswheneverpageKeychanges (tab/sort/workspace/served-set switch).fetched: booleanonextraPages— distinguishes a successful-but-empty page (advance/terminate the cursor) from “never fetched” (fall back tofirstPageNextCursor). 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 synchronousloadMorecalls in one render beforeisFetchingMorestate commits.failedCursorRef— a failedfetchPage(returnsnull) records the cursor;loadMoreearly-returnsif (failedCursorRef.current === nextCursor)so a persistently failing page can’t spin the sentinel. Cleared on the next successful page orreset().resetEpochRef— snapshotted asstartEpochbefore the await;isStale = resetEpochRef.current !== startEpochdiscards an in-flight page whose list was reset mid-fetch, so a refetch’s reset isn’t undone. A secondif (prev.key !== pageKey) return previnsidesetExtraPagescovers 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:
- The sentinel enters view (within 800px of the viewport).
onLoadMore→ the active list’sloadMore(deriveStudioActiveListpicks the active list;loadMoreStateByList[activeList]supplies its load-more state).loadMorechecksnextCursor,isFetchingMoreRef, andfailedCursorRef; setsisFetchingMore; snapshotsstartEpoch.await fetchPage(nextCursor)→client.queryno-cache.- On success (not stale): append
page.itemstoextraPages.items, advanceextraPages.nextCursor, setfetched: true. - The deduped
itemsmemo recomputes ([...firstPageItems, ...extraPages.items]skipping seenid) and the grid re-renders with the new rows. isFetchingMoreclears; if the sentinel is still in view andhasMore, it fires again.
Recommendations
The defaultRECOMMENDED 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 drivesrecommendationParams+ the draft’scustomModelIds. studioRecommendationCadencegates 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 inuseStudioRecommendations.tsused to claim an ApollostudioRecommendationsfield policy merges pages — that was stale and has been corrected. The real unpooled merge isusePaginatedNoCacheFetch(no-cache + in-state accumulation); the only Apollo merge for recommendations is the manualupdateQueryin the pools provider.
Search
Search is an overlay over the tabs, orchestrated byuseTabbedStudioSearch 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 withuseDebounce(searchTerm, 500). Only the debounced value reaches the query. - It instantiates two independent
useStudioSearchinstances — 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 setsskip: !searchQueryso it never fires.
useStudioSearch runs useStudioSearchQuery (studioSearch) with fetchPolicy: 'cache-and-network' and notifyOnNetworkStatusChange: true. Unlike the catalog, search paginates with real Apollo fetchMore — loadMore 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 handleStudioOrderChange → handleTabChange('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_orderedIdsarray preserving the server’s sort order (popular/newest/A-Z).paginationReadresolves_orderedIdsback to ordered, dangling-ref-filtered refs; absent it, falls back tocreatedAt-desc overObject.values.replaceOnRefetch: trueon 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: trueonstudios+studioRecommendations(theusePaginatedNoCacheFetchlists): 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.keyArgsper typePolicy includes page sizenso consumers requesting differentfirstvalues for the same logical list never collide and interleave pages.studioskey:${workspaceId ?? 'discover'}-${type}-pub:${published}-${orderBy}-n:${first}. Each distinctfirstkeys a separate cache entry. Post-fix the scroll panel and the Auto-card fallback both requestfirst:30, so they now share one entry (was30vs60); only the rare full-catalog caller usesfirst:500.studioRecommendationsrouting: the merge field is'studios'only;recommendationItemsfalls into...restand comes from the incoming response each merge. WithpageZeroOnly, a head-revalidating refetch adopts cursor +recommendationSetId+recommendationItemstogether — 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 (fetchRecommendationsPageisno-cache), so no page-2 metadata is ever cached — the accumulated pages carry their own metadata in hook state.- Eviction/broadcast:
evictStudiosremovesStudio:{id}, runscache.gc(), and callsbroadcastCacheEviction('Studio', ids)overBroadcastChannel('kive-cache-invalidation');paginationRead’s null-filter then drops the dangling refs.refreshStudiosListevicts the three root fields (studios,studioRecommendations,studioSearch). Studio entities use the defaultStudio:{id}keyFields (no customkeyFieldsanywhere), so any query/fragment sharingStudioListFieldsupdates the same normalized entity. - Refetch-on-visibility:
useRefetchOnVisibilityrefetchesQUERIES_TO_REFETCH(includesstudios, excludesstudioRecommendations) on tab-visible/refocus, throttled 30s, relying onreplaceOnRefetchto 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-pageZeroOnlycursor/scalar pairing rules.
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).| # | Sev | Area | Issue | Resolution | Status |
|---|---|---|---|---|---|
| 1 | MED | Cross-tab staleness | No-cache accumulated pages never received cross-tab eviction broadcasts | usePaginatedNoCacheFetch 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 |
| 2 | MED | Recommendations race | Paginated-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 id | firstPageNextCursor 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 |
| 3 | MED | Performance | AutoStudiosProvider fetched public studios at page size 60 → a distinct cache key from the panel’s 30, duplicating the network fetch | AutoStudiosProvider now requests STUDIOS_SCROLL_PAGE_SIZE (30) so it shares the panel’s page-0 cache entry (one fetch, not two) | ✅ fixed |
| 4 | MED | Performance | useStudioRecommendations’ override-fallback useStudios used the default 500, then sliced to count | Reverted 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 |
| 5 | LOW | Performance | StudioProvider fires 2–3 parallel studios queries on mount with no enabled, even when the panel is closed | Confirmed 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 |
| 6 | LOW | Performance | STUDIOS_DEFAULT_PAGE_SIZE = 500 unbounded single-shot default | The real waste (recs caller) now passes an explicit pageSize; the 500 default is documented as for full-catalog callers only | ✅ fixed |
| 7 | LOW | Correctness | failedCursorRef could permanently suppress a valid retry if page 0 re-yielded the same cursor | An effect clears failedCursorRef whenever firstPageNextCursor changes (page 0 advanced) | ✅ fixed |
| 8 | LOW | Staleness | Unpooled recs freshPayload effect wrote the result-cache with no loading guard | The setEntry write is now gated on !paginatedLoading, so an in-flight slate can’t be persisted under the surface key | ✅ fixed |
| 9 | LOW | Consistency | orderKey collapses RECOMMENDED→USAGE_COUNT_DESC; the public query still runs in RECOMMENDED mode though recs supply the displayed list | Documented in-code: the public query pre-warms the cache so switching to an explicit sort renders instantly. (Dead gridStudios consumer — see #12 — removed.) | 📝 intentional |
| 10 | LOW | Consistency | sortNewStudiosFirst floats new studios only in myStudios, not the public list | Documented as intentional — curated public feed stays in popularity order; users’ own fresh studios float | 📝 intentional |
| 11 | LOW | Staleness | studioRecommendations policy drops page-2+ recommendationItems metadata, guarded only by a convention | Superseded: 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 |
| 12 | LOW | Correctness | gridStudios seeded shuffle keyed on position index → non-uniform “featured” pick | Deleted — gridStudios had no consumer (dead code); the buggy LCG shuffle is gone | ✅ fixed |
| 13 | LOW | Correctness | items dedup keeps the live page-0 copy over a frozen extra-page copy → rendered length can flicker by one | Benign 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 componentuseState, 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 inStudioProvider. 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.
| Unit | File | Tests |
|---|---|---|
usePaginatedNoCacheFetch (page-0 + no-cache accumulator, firstPageReady, eviction reset, failed-cursor block) | hooks/usePaginatedNoCacheFetch.ts | hooks/__tests__/usePaginatedNoCacheFetch.skip.test.tsx (17) |
sortStudiosByRecent + pickPrimaryModelForRecommendations + buildRecommendationParams | features/.../utils/studioUtils.ts | utils/__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.ts | providers/__tests__/studioRecommendationPoolVariables.skip.test.ts (3) |
useShouldShowAutoStudios | features/.../hooks/useShouldShowAutoStudios.ts | hooks/__tests__/useShouldShowAutoStudios.skip.test.tsx (3) |
useStudioRecommendationSelection | features/.../hooks/useStudioRecommendationSelection.ts | hooks/__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.ts | hooks/__tests__/studioRecommendationMode.skip.test.ts (5) |
resolveActivePoolRequestKey (pool’s cadence-pending request-key gate) | features/.../providers/studioRecommendationPoolVariables.ts | providers/__tests__/studioRecommendationPoolVariables.skip.test.ts (4) |
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 scenario | Spec |
|---|---|
| Scrolling the studios list loads page 2+ and never shrinks; re-sorting keeps it populated | apps/web-core/tests/generate-image/studios-pagination.spec.ts |
| Auto-card activation on model add / shuffle / style-ref | apps/web-core/tests/generate-image/auto-studios.spec.ts (pre-existing) |
#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 resetsusePaginatedNoCacheFetch’sextraPages.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 GraphQLorderByderived from UIstudioOrder;RECOMMENDED/UNPUBLISHED/MY_STUDIOScollapse toUSAGE_COUNT_DESC.isRecommendedBrowseMode—useStudiosListflag derived from the discriminant (activeList === 'recommended') that makes the panel read recommendations and skip the catalog spinner.isSearchActiveMode—isControlsSearchActive && !!searchTerm; overrides tab/order routing for load-more and rendering._orderedIds— parallelstring[]on a cached list preserving server sort order;paginationReadresolves 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_orderedIdsrevalidates a multi-page list instead of collapsing it.pageZeroOnly— typePolicy flag forusePaginatedNoCacheFetchlists (studios,studioRecommendations): the cache entry only ever holds page 0, so a head-revalidating refetch adopts the incoming cursor + scalars as one consistent pair.fetched—extraPagesboolean distinguishing a successful-but-empty page (advance/terminate cursor) from “never fetched” (fall back tofirstPageNextCursor).failedCursorRef/resetEpochRef/isFetchingMoreRef—usePaginatedNoCacheFetchguards against sentinel spin on failure, stale-page append after reset, and double-fire within one render.STUDIOS_SCROLL_PAGE_SIZE—30, the paginated panel page size.STUDIOS_DEFAULT_PAGE_SIZE—500, the non-paginated legacy default.STUDIO_RECOMMENDATION_POOL_PAGE_SIZE—60, the pooled-recommendations page size (no-cache+usePaginatedNoCacheFetch).STUDIO_SEARCH_PAGE_SIZE—30, thestudioSearchpage size.attachRecommendationMetadata— joinsrecommendationItems[].metadata(lane/scores) onto studios bystudio.id.isSettled/useSettledAutoStudioVisibility— hold a previous slate / Auto-card visibility steady untilrecommendationSetIdchanges.buildPoolVariables/fetchRecommendationsPage/buildRecommendationPageKey— shared pool/recs helpers: build thestudioRecommendationspage-0 + cursor variables, run oneno-cachepage fetch + metadata attach, and build the slate-scopedpageKey.firstPageReady—usePaginatedNoCacheFetchflag (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 viauseStudiosand directly by the Auto card / recommendation fallbacks.CACHE_VERSION—'v5'; bumping scopes the persisted IndexedDB key and purges caches written under older merge semantics.