# SPA conventions — "Add a screen" playbook Purpose: a precise playbook for adding a new screen (or sub-path editor) to the ChicoryTV React SPA (`web/`), for an agent with no prior context in this repo. **Update this doc in the same PR that changes any convention below.** Companion to `api-conventions.md` (the API surface the SPA talks to) and `docs/contributing.md` (general repo conventions). ## 1. Stack & layout Vite + React + TypeScript, builds to `ErsatzTV/wwwroot/app` (see `web/vite.config.ts`: `base: '/app/'`, `build.outDir: '../ErsatzTV/wwwroot/app'`), served by the ASP.NET host at `/app`. - **Composition root**: `web/src/App.tsx` — owns the active route, approved Libraries sub-path, navigation guard integration, and theme; it composes the shell with the matched screen. - **Routes + nav**: `web/src/app/routes.tsx` — the ONE stable module-level array of `ScreenRoute` objects (`path`, `label`, `title`, `kicker`, `icon`, etc.) plus `allowSubPaths?: boolean`, the matcher, and sidebar group definitions. Route objects must never be rebuilt per render. - **Shell + screen dispatch**: `web/src/app/AppShell.tsx` owns Sidebar/TopBar/Connect/version/theme chrome; `web/src/app/ScreenContent.tsx` exhaustively maps a matched route to its screen and owns the Media/Libraries route wrappers. - **Screens**: `web/src/screens/*.tsx`, one file per top-level screen, generally with a colocated `*.test.tsx`. - **API clients**: `web/src/api/.ts` (see §4). - **Styling**: `web/src/shell.css` (+ `web/src/components/components.css`) — utility classes with a `ctv-` prefix (~690 occurrences across those two files). Reuse an existing `ctv-*` class before inventing a new one. `shell.css` carries the only base reset — `html, body { margin: 0 }` plus `body { background: var(--surface-app) }` (the 8px default body margin otherwise frames every full-viewport layout with a light border, #373). **There is no global `box-sizing` reset** (the SPA is authored under the default `content-box`), so any element that combines `width: 100%` with padding/border must set `box-sizing: border-box` locally or it overflows its container — e.g. `.ctv-nav-item` (#377). Prefer `width: auto` (shrink-to-fit) over `width: 100%` + padding where you can. ## 2. CRITICAL: sub-path screens must own their own pathname state If a route sets `allowSubPaths: true` (e.g. so `/app/blocks/{id}` works under the `/app/blocks` nav entry), **the screen component itself must track `window.location.pathname` and listen for `popstate`** — do not rely on the composition root re-rendering `ScreenContent` when the sub-path changes. **Why**: `app/routes.tsx`'s `routeFromLocation()` matches an `allowSubPaths` route by prefix (`pathname.startsWith(\`${route.path}/\`)`) and returns the **same `ScreenRoute` object reference** for the base path and every sub-path under it. `App`'s state update is `setActiveRoute(routeFromLocation())`; React's `useState` setter bails via `Object.is` when the new value is reference-equal to the old one — so navigating from `/app/blocks` to `/app/blocks/42` (or between `/app/blocks/42` and `/app/blocks/17`) **never re-invokes `ScreenContent`** at the `App` level. See the comment block directly above `PlayoutsRouteScreen` in `screens/PlayoutsScreen.tsx` for the canonical explanation, and its implementation (`useState(() => window.location.pathname)` + `useEffect` with a `popstate` listener local to the wrapper component) for the fix. Exemplars of screens that already do this correctly: `BlocksScreen.tsx`, `TemplatesScreen.tsx`, `DecosScreen.tsx`, `DecoTemplatesScreen.tsx`, and the `PlayoutsRouteScreen` wrapper colocated in `PlayoutsScreen.tsx` (which owns two sibling sub-paths, `/playouts/{id}/alternate-schedules` and `/playouts/{id}/templates`, dispatching internally via `parsePlayoutSubRoute`). **Exception — a guarded sub-path route defers pathname ownership to App.** When a sub-path route's screens ALSO register a dirty guard (§8), the wrapper must **not** self-listen for `popstate`; App owns the pathname and passes the approved sub-path down as a prop. The `LibrariesRouteScreen` wrapper in `app/ScreenContent.tsx` is the exemplar (sub-path parsed by `parseLibrariesSubRoute`, dispatched via a flat `switch`, sub-path supplied by App's `librariesSubPath` state). See §8 for why child-before-parent effect order makes the self-listening pattern unsafe here. ## 3. Data loading pattern Reference implementation: `web/src/screens/LogsScreen.tsx`. Structure to copy for any screen that fetches from the API: - A **discriminated-union state type** covering loading/success/error, e.g. `type LogsState = { status: 'loading'; ... } | { status: 'success'; ... } | { status: 'error'; ... }`. - A `seqRef` (monotonically incremented request counter) + `activeRef` (mount-tracking boolean, flipped in a mount/unmount `useEffect`) pair — guards against a stale, slower request overwriting a newer one's result, and against setting state after unmount. - The actual fetch lives in a `useCallback` (`load`), called from a **separate** `useEffect(() => { load(); }, [load])`. - **Lint rule — `react-hooks` "no set-state-in-effect"**: never call `setState` **synchronously in the body** of a `useEffect`. State transitions happen only inside event handlers or promise `.then()`/`.catch()` callbacks (as in `LogsScreen`'s `load`). This is enforced by `eslint-plugin-react-hooks` in `web/eslint.config.js` — a synchronous `setState` in an effect body will fail `npm run lint`. ## 3a. "Keep results visible during refetch" ⇒ gate mutations + show a refreshing cue Some grid screens deliberately keep the **previous** successful result set rendered while a refetch is in flight (no full-screen loading state on a query/kind/page change), so the grid doesn't flash empty. `SearchScreen.tsx` and `MediaBrowseScreen.tsx` do this. If such a screen also carries **mutation surfaces** (per-card Add-to menu, Select/select-mode, a selection action bar, "Add all", "Save as smart collection"), those surfaces would otherwise stay live over a **stale** result set — an add/select action then targets the about-to-be-replaced items, or (worse) a query-wide "Add all" bulk request resolves against the previous query. This was issue #221 (adversarial-reviewer#18). Convention — when a screen keeps stale results visible during a refetch: - **Key the success state to the request params that produced it.** Store the identifying params on the `status: 'success'` variant (`SearchScreen`: the `query`; `MediaBrowseScreen`: a `kind|query|page` `key`), set in the seq-guarded `.then`. Derive `const refreshing = state.status === 'success' && state. !== ;` in render. Prefer this over a synchronously-set `refreshing` flag: setting state synchronously from the load path trips the `react-hooks` "no set-state-in-effect" rule (§3). **Invariant, not a guarantee**: the derivation is only self-correcting when *every* value the current params can take will actually trigger a fetch. If `load()` early-returns for some param value (e.g. `SearchScreen`'s blank-query guard), `state` never updates for that value and a stale `status: 'success'` variant lingers — so the comparison must exclude params that suppress fetching, or gate the whole flag on the same condition that gates the fetch (`SearchScreen`: `const refreshing = hasQuery && state.status === 'success' && state.query !== query.trim();` — fixed post-review in #222 after the naive derivation got stuck `true` once the query was cleared to empty, see PR discussion for #221). - **While `refreshing`:** show a visible cue (a `role="status"` "Refreshing…" row with `` plus the `.ctv-media-grid-dim` opacity class on the grid) and **disable every mutation surface** — per-card Add-to menu (withhold the `actions` node), select toggle + in-grid selection (`const canSelect = selectMode && !refreshing;` gates `onToggleSelect`), the selection action bar, "Add all", "Save as smart collection". Card navigation (`onOpen`) **may** stay live — but only outside select mode: while `selectMode && refreshing`, `MediaPosterCard` falls back to `onOpen` whenever `onToggleSelect` is undefined, so both props must be withheld together or a mid-select click navigates away instead of no-op'ing. The select-mode toggle itself should only be disabled while refreshing when *entering* select mode (`refreshing && !selectMode`) — exiting only clears selection, not a mutation, so it must stay available. - **Bind async bulk completions to their request params, not just mount.** A whole-query/whole-set request (e.g. `getSearchAllItems`) must, on resolve, check that its snapshotted params are still current (compare against a ref that always holds the committed value — `SearchScreen` reuses `lastQueryRef`) and **discard** otherwise. Checking only `activeRef` (mounted) is insufficient. ## 3b. Paged list endpoints clamp server-side — page to completeness ONLY for bounded lists, never a media-library picker Every paged `/api/v1` list endpoint (rerun-collections, multi-collections, library/browse, search, trakt-lists, …) clamps `pageSize` to its own controller's `MaxPageSize` (100, as of #644) regardless of what the client requests. A screen that asks for `pageSize: 1000` to "get everything in one call" gets only the first `MaxPageSize` rows back, silently — no error, no truncation indicator, no paging UI to notice the gap. This was issue #644 (following on from #634, which fixed the first instance — `SchedulesScreen`'s rerun-collections picker load). **Two classes of call site, treated differently** (decision record: `docs/decisions/records/spa/list-completeness-vs-bounded-pickers.md`, `spa.list-completeness-vs-bounded-pickers` — a #644 follow-up review found the original blanket "use `loadAllPages` for any picker" guidance below was itself the defect for one class of caller): - **Bounded-by-construction lists** (rerun collections, multi-collections, playlists — admin-created, hundreds of rows at most): genuinely need the complete list, and completeness is cheap. Use the shared `loadAllPages` helper (`web/src/api/paging.ts`, re-exported via `web/src/api/index.ts`) instead of an inflated `pageSize`: ```ts const { items, complete } = await loadAllPages(getMultiCollections); // pages against totalCount, cap defaults to 100 ``` It pages `pageNum` from 0 (per §"paging-zero-based" in `api-conventions.md`) against the response's `totalCount`, stopping — and reporting `complete: false` — on an empty page (defensive guard against a `totalCount` that never converges) or on an aborted `signal`. **Always check `complete`**: a caller that needs the full list must not treat a resolved promise as proof the list is whole (a partial result is otherwise silently indistinguishable from a complete one — the same defect class as #644 itself, since `GetLibraryBrowseItemsHandler.HydrateMediaItems` can legitimately drop stale Lucene hits and produce a short/empty page in normal operation). Pass an `AbortSignal` (4th arg) from the caller's effect cleanup so a superseded load stops issuing further page requests instead of hammering the server for a result nobody will see. **Do not raise the server-side cap to work around this** — the `api.search-allitems-paging` precedent is that the client pages and the server stays bounded; that's a backend decision, out of scope for a screen fix. - **Media-library pickers** (`getLibraryBrowseItems` backing a `` with thousands of `