Extract ChicoryTV shell/routing and replace implicit TopBar action coupling #247
Closed
opened 2026-07-11 12:50:17 +02:00 by timothy
·
3 comments
No Branch/Tag Specified
main
901-pin-the-artifact-whole
renovate/meziantou.analyzer-3.x
release/v26.15.0-notes
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
renovate/dotnet-monorepo
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
Milestone
No items
No Milestone
ChicoryTV modularization
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#247
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Child of #243. Final phase after the domain screens are extracted.
Scope
Turn
App.tsxinto a small composition root by extracting:Remove the string-keyed
windowCustomEvent mechanism (ctv:primary-action) once #238 has established/fixed the intended action behavior. Prefer an explicit action registration/context or route-render contract with cleanup and ownership tests. Keep the replacement minimal — one declared action per screen with cleanup/ownership tests — not a generic screen-action dispatch framework (the epic's non-goals forbid a speculative generic framework before repeated screens prove the abstraction).Preservation invariants (must not silently break during the shell extraction)
ScreenRouteobject references.routeFromLocation()(App.tsx:596-612) returns the same object reference for anallowSubPathsbase path and every sub-path under it;App'ssetActiveRoutethen bails viaObject.is, which is why sub-path screens own their ownpopstate(spa-conventions §2). If route metadata is refactored into per-screen registration that rebuilds route objects per render, this bail breaks and navigating/blocks/42 → /blocks/17stops re-rendering — with no test failure unless one exists. Add a regression test that navigates between two sub-paths of oneallowSubPathsroute and asserts the correct sub-screen re-renders.navigate+popstatehandlers +currentPathRef+activeRoutestate co-located in the composition root (App.tsx:2954-3003). They close over each other; the #230 re-push-on-veto (finding 1) restorescurrentPathRef.currentto undo an already-committed browser URL change. They cannot be split across screen modules.navigationGuard.tsis already standalone and survives extraction unchanged.Sequencing
Land after #238 (hard prerequisite, above) and after #230's dirty popstate/navigation contract — which is already settled in-code (PRs
f09045e1+11b43d5evia #242): popstate re-push,beforeunload, ref-based guard, shuffle-toggle reload all present onmain. This issue must preserve those settled semantics, not invent replacements in parallel.Acceptance criteria
App.tsxis a small composition root with no domain-specific screen implementation.allowSubPathsbehavior have focused shell/routing tests. Explicitly: the §2 sub-path reference-equality re-render and the #230 popstate re-push each retain a passing regression test against the composition-root shell — these are the two behaviors most likely to break invisibly.docs/spa-conventions.mdanddocs/decisions.mddescribe the final screen/shell/action ownership model.Rollback
Structural-only and independently revertible after its prerequisite behavior fixes.
#238 promoted from soft "land after" to a HARD blocking prerequisite, and the two silent-break invariants (INV-1/INV-2) added, per the plan review on adversarial-reviewer#24 (2026-07-11).
Done-when
App.tsxis a small composition root with no domain-specific screen implementationdocs/spa-conventions.mdanddocs/decisions.mddocument the final shell/action ownership modelNew preservation invariant + non-goal from the independent plan re-review (adversarial-reviewer#24, 2026-07-12, code-verified against
main @ 9e2d1608). The body's INV-1/INV-2 line numbers are also stale — current refs below.INV-2b (NEW, load-bearing) — App-owned popstate for guarded sub-path routes. #202 landed a third sub-path pattern this issue's body doesn't mention.
LibrariesRouteScreen(App.tsx2593-2617) does NOT self-listen forpopstate; it takes its sub-path as a prop (subPath) and derives everything viaparseLibrariesSubRoute. App is the single popstate owner for it and writeslibrariesSubPathstate (declared2765) only on approval (2794-2796in the popstate handler,2828-2830innavigate), gated on the arrived route beinglibraries; on a veto it re-pushes and writes nothing. Reason: React commits child effects before the parent, so a wrapper-owned listener would switch sub-screen before App's guard could veto — desyncing them (spa-conventions.md§8;decisions.md2026-07-11 §D.2 finding 4). #247 must preserve: App as solepopstateowner, the route-id-gatedlibrariesSubPathwrite, and the wrapper deriving sub-path from the prop (never the event). Keep the existingApp.test.tsxApp-owned-popstate regression test (dirty editor at/app/libraries/local/3:confirm→falsekeeps URL+sub-screen;confirm→truenavigates) green against the extracted shell.INV-4 update — there are now TWO self-owning wrappers, not one.
PlayoutsRouteScreen(2522-2542) andMediaRouteScreen(2548-2580, new since the first review — owns bothpathnameandsearchstate + ownpopstate). Each moves as a unit. So #247 must preserve three sub-path mechanisms: two self-owning wrappers + one App-owned wrapper (INV-2b) + thekey={window.location.pathname|search}remount used by ~8 otherallowSubPathsscreens (2645-2745).G-4 (NEW non-goal) — do NOT unify the wrapper vs.
key-remount sub-path patterns. Their coexistence is deliberate; collapsing them into one generic mechanism during the shell extraction is exactly the "speculative generic framework" the epic forbids and would risk both the Object.is bail (INV-1) and the guarded-route contract (INV-2b). Add to this issue's non-goals.PlaceholderScreen fallthrough (
2752). The route-table ↔if-ladder dispatch (2619-2753) is not exhaustiveness-checked — a moved route missing its branch silently rendersPlaceholderScreen, no test failure. Recommend adding a per-route "renders its screen, not the placeholder" test (or an exhaustiveswitch) so the shell extraction can't drop a route silently.Refreshed INV-1/INV-2 line numbers: route table
216-579(ScreenRouteiface200-214);routeFromLocation()622-638; nav/popstate/currentPathRef/activeRouteco-located inApp()2755-2846(activeRoute2757, currentPathRef2770, popstate2776-2802, #230 re-push2783-2787, navigate2806-2831). G-1 re-confirmed: #238 is still a hard prerequisite and still open+unowned — one dispatcher (920), one listener (SchedulesScreen.tsx:197-198), no fix commit.timothy referenced this issue2026-07-12 16:00:17 +02:00
Claiming #247 for implementation in this session. I will preserve the documented shell/routing invariants, keep the extraction behavior-preserving, and follow the required review, verification, and tracker session-end workflow.
Done
What was done: PR #361 merged at
82467f188bcf09834e21b8fd5160c94e5d859ede.App.tsxis now the small composition root; stable route metadata/nav moved toapp/routes.tsx, shell chrome toapp/AppShell.tsx, and exhaustive wrappers/dispatch toapp/ScreenContent.tsx. The globalctv:primary-actionevent was replaced by a minimal one-action React context with latest-handler and opaque-owner cleanup semantics. Builder, Settings, and component behavior coverage was colocated while App retained route/shell/guard composition smokes.Root cause: n/a — planned behavior-preserving structural work.
Files changed:
web/src/App.tsx,web/src/app/{routes,AppShell,ScreenContent}.tsx,web/src/primaryAction.ts, focused App/route/action/screen/component tests,docs/spa-conventions.md, anddocs/decisions.md.Verification: 90 web test files / 775 tests; full lint, typecheck, production build, and API-generation check; full UTC .NET restore/build/test; hosted-SPA live E2E covering setup/login, shell/Connect, functional Schedule primary action, sibling Playout subpaths, query-only stability, search, and unknown routes with no browser errors; cold review MERGEABLE at
947c8aacd899ff1ee3c99d283860dd7a287644c4; CI run 648 green (image publish skipped as expected).Deferred: none.
Follow-up issues: none.
Docs updated:
docs/spa-conventions.mdand append-onlydocs/decisions.mdrecord the final shell/routing/action ownership model. No API, route, styling, dependency, or intentional behavior change.