---
key: spa.app-shell-extraction
title: 2026-07-15 — App shell/routing extraction + explicit primary-action ownership (#247)
status: active
since: '2026-07-15'
supersedes: none
superseded-by: none
rule: '`App.tsx` is only the composition root over `web/src/app/routes.tsx` (stable route-object identity), `app/AppShell.tsx` (shell chrome), and `app/ScreenContent.tsx` (exhaustive screen dispatch); primary actions are one explicit `PrimaryActionProvider` registration per screen, replacing the old global `ctv:primary-action` window event.'
signals: 'app shell extraction, route identity, primary-action ownership · paths: `web/src/app/routes.tsx`, `web/src/app/AppShell.tsx`, `web/src/app/ScreenContent.tsx` · issues: #247, #243, #238, #230'
mechanics: '`docs/spa-conventions.md` §10; `App.test.tsx`'
---

Final phase of the App.tsx modularization epic (#243). `web/src/App.tsx` is now only the composition
root: it owns `activeRoute`, `currentPathRef`, the `navigate`/`popstate` pair that consults the dirty
guard, the approved `librariesSubPath`, and the global theme/health inputs, then composes the shell with
the matched screen. Route metadata/matching/sidebar groups moved to `web/src/app/routes.tsx`; shell chrome
(Sidebar, TopBar, Connect menu, version and theme controls) moved to `app/AppShell.tsx`; exhaustive screen
dispatch plus the Media/Libraries wrappers moved to `app/ScreenContent.tsx`. No route, styling, API, or
dependency changed.

**Route identity is deliberate infrastructure.** `routes.tsx` contains the ONE stable module-level array
of shared `ScreenRoute` objects. `routeFromLocation()` returns those same references for an
`allowSubPaths` base path and every owned sub-path, so React's `Object.is` state bailout remains part of
the contract rather than an accidental implementation detail. The three existing sub-path mechanisms stay
distinct: Playouts and Media self-own `pathname`/`popstate`; guarded Libraries receives only the path App
approved after its dirty guard; the remaining route screens retain their established self-owned or keyed
remount behavior. The extraction does not unify them into a speculative router/framework. `ScreenContent`
uses an exhaustive `ScreenId` switch, so a future route added without a dispatch branch fails typecheck
instead of silently falling through to a placeholder.

**Primary actions are one explicit screen-owned registration, not a global event.** The old
`ctv:primary-action` window `CustomEvent` and string-keyed dispatcher are removed. `PrimaryActionProvider`
holds exactly one `{ routeId, handler, owner }` registration shared by the active screen and TopBar;
`usePrimaryAction` registers it, keeps the latest handler behind a ref without ownership churn, and clears
it only when the same opaque owner unmounts. A stale cleanup therefore cannot erase a newer screen's
action. TopBar renders the Plus action only when the route has a non-empty label AND that active route owns
a matching registration, so metadata alone can no longer create a dead button. This is intentionally not a
generic action bus: one declared action per screen is the whole contract.

Tests pin stable route-object identity (including sibling sub-paths and query-only URLs), composition-level
sub-path rendering and dirty-popstate restoration, and primary-action matching/latest-handler/route-change/
cleanup/stale-owner behavior. Detailed Builder and Settings behavior is colocated with those screens while
`App.test.tsx` remains shell/routing/composition coverage. Refs #247 #243 #238 #230.
