> ## Documentation Index
> Fetch the complete documentation index at: https://kive.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Studio loading create page

# Studio Loading on the Create Page

> **Status (2026-06-30):** the 13 issues in [Issues found & fixes applied](#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](#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 `StudioChip`s 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) + `studios` typePolicy (page 0 only). The Auto card and recommendation fallbacks call `usePublicStudios` directly (public list only — no my/unpublished queries).
* `StudiosList` → `useStudiosList` → 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× `useStudioSearch` → `useStudioSearchQuery` (`studioSearch`, `cache-and-network`) + Apollo `fetchMore` → `studioSearch` typePolicy
* `StudioTabContent` → renders active branch + trailing `LoadMoreSentinel` (IntersectionObserver)

Where things live:

| 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, 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 `fetchStudiosPage` → `client.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

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 `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 **`_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).

| #  | 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 component `useState`, not the normalized cache, so the app-wide `useCacheInvalidationListener` (which only `evict`s `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.

| 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) |

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 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) |

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`.
* **`isRecommendedBrowseMode`** — `useStudiosList` flag 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`** — 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.
* **`fetched`** — `extraPages` boolean distinguishing a successful-but-empty page (advance/terminate cursor) from "never fetched" (fall back to `firstPageNextCursor`).
* **`failedCursorRef` / `resetEpochRef` / `isFetchingMoreRef`** — `usePaginatedNoCacheFetch` guards 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`, 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`.
* **`firstPageReady`** — `usePaginatedNoCacheFetch` 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.
