Skip to content

refactor(app): decompose the composition root along four seams #588

Description

@BorisTyshkevich

Part of the ADR-0004 vanilla-shell investment track (see docs/ADR-0004-ui-shell.md); scoped from the 2026-08-03 architecture review.

Goal

src/ui/app.ts is a god composition root: createApp spans lines 169–3169 (roughly 93% of the file), producing a 113-member App bag (src/ui/app.types.ts:242–677, imported by 27 modules) via 130 scattered app.X = ... assignments, typed only by an as App cast at :236. Decompose it along four seams so adding a stateful feature no longer requires touching four or five unrelated files.

Context

Rendering itself is NOT the problem — app.ts has 0 effect() calls, 9 event listeners, and 8 classList sites across 3,215 lines; the extraction discipline the codebase already follows worked. The cost driver is composition-root sprawl: adding a single stateful toolbar button today touches an AppDom field, the element build in workbench-shell.ts, a setXBtn updater in app.ts, an entry in WorkbenchShellDeps (workbench-shell.ts:98–102) AND the matching line in the 18-line forwarding literal at app.ts:3196–3214, plus an ActionsRegistry entry.

Four extractions

Ordered by risk — do 3 and 4 first (lowest risk); 1 and 2 have subtle ordering invariants (the surface-generation guards at app.ts:309–328 and the write-chain at :2324) and should lean on the existing cross-tab-consistency.test.ts coverage rather than being re-derived from scratch.

  1. Routing + main-surface navigation (app.ts:2629–3046, ~420 lines) → src/application/surface-navigation.ts. Currently drags 12 App members that nothing but the router itself needs.
  2. Workspace persistence + cross-tab sync (app.ts:2324–2628, ~305 lines: write serialization, BroadcastChannel, refresh scheduling, beforeunload guard, provisioning) → src/application/workspace-session.ts. Zero DOM, already env-seamed; removes 9 App members and 4 of the file's 9 addEventListener sites.
  3. renderVarStrip + setRunBtn (app.ts:1008–1224, ~217 lines, the densest DOM in the file) → src/ui/variable-bar.ts, which already exists and is the natural home. Drags 8 imports, the VarStripCombo type, and 4 AppDom bookkeeping fields with it.
  4. Promote anchoredPopover (app.ts:1719–1762 — a generic keyboard-owning overlay primitive currently living in the composition root even though popover.ts exists) into src/ui/popover.ts. Then extract the save cluster (app.ts:1763–2003: flashSaved, commitLinkedQuery, saveVariableTab, saveActiveQuery, reloadSavedVersion, openConflictChooser, openSavePopover) → src/ui/workbench/save-controller.ts. Removes the last 6 classList sites (updateSaveBtn).
  5. Replace the as App cast (app.ts:236: const app = appBase as App;) with typed staged construction — sub-service factories that build and compose the final App object field-by-field, so a forgotten member assignment becomes a compile error instead of a runtime hole. Pairs naturally with extractions 1–4: as members move out to their own modules, the App interface (app.types.ts:242–677) should shrink toward narrow capability ports (modules depending on Pick<App, …>-shaped ports) rather than the current single 113-member bag.

Smaller cleanup (bundle with whichever extraction touches it first)

keyboardOwnerChannel is copy-pasted identically three times: file-menu.ts:86–92, library-assign-menu.ts:22–30, dashboard.ts:285–291. Hoist it to one shared module.

Tests

  • Each extracted module ships with its own tests/unit/<module>.test.js at the repo's coverage floors (100/95/90/100), not merely re-exported test coverage from app.ts's existing suite.
  • surface-navigation.ts: routing/navigation behavior covered independent of app.ts, including the ordering invariant around the surface-generation guards (app.ts:309–328 today).
  • workspace-session.ts: cross-tab write/read, BroadcastChannel, refresh scheduling, and beforeunload guard covered directly; existing cross-tab-consistency.test.ts continues to pass unmodified in behavior (may need import-path updates).
  • variable-bar.ts: renderVarStrip/setRunBtn behavior merges cleanly with the module's existing tests, no coverage regression on the file.
  • popover.ts: anchoredPopover promoted with tests; save-controller.ts covers the save cluster (flash/commit/save/reload/conflict/open) independent of app.ts.
  • keyboardOwnerChannel hoisted module has one test file instead of the behavior being asserted three times incidentally through file-menu/library-assign-menu/dashboard tests.
  • Full npm test coverage gate and tsc --noEmit pass after each extraction (can land as separate PRs/phases).

Acceptance criteria

  1. app.ts shrinks toward ≈1,800 lines.
  2. The App interface (app.types.ts:242–677) shrinks — members that only the router, workspace-session, variable-bar, or save-controller need move to their narrower module's own types, rather than living in the shared 113-member bag.
  3. All four extractions preserve current behavior with no functional change (pure refactor); cross-tab-consistency.test.ts and existing app-level integration tests pass unmodified in intent.
  4. keyboardOwnerChannel exists in exactly one place.
  5. Adding a new stateful toolbar button after this change touches fewer than the current 4–5 files (ideally the button's own module plus one registration point).
  6. npm test (coverage gate) and tsc --noEmit pass at each step.

Non-goals

  • Introducing a component framework, hooks, or a generic DI container — extractions stay in the existing pure-module/injected-seam style (ADR-0001/ADR-0004).
  • Changing the AppDom/WorkbenchShellDeps forwarding pattern wholesale; this issue only removes members that no longer need to flow through it.
  • Any behavior or UX change — this is structural extraction only.
  • Extracting every remaining app.ts responsibility; four seams (plus the small keyboardOwnerChannel hoist) are the scope of this issue. Further decomposition can be filed separately if warranted.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    refactorRestructuring without user-facing behavior changetech-debt

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions