Skip to content

refactor(state): make the committed workspace aggregate reactive and retire dashboardTreeRevision #590

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

Make the committed workspace aggregate (state.dashboard / currentWorkspace) itself reactive — or make a computed projection of it the reactive source subscribers read — and retire dashboardTreeRevision, the hand-maintained repaint counter that stands in for that missing reactivity today.

Context

src/state.ts:391–399 declares the counter and says why in its own comment:

#426 — the Dashboard tree's EXPLICIT repaint invalidation. The tree is a projection of the committed workspace aggregate plus main-surface navigation state, neither of which is a signal, so it cannot depend on incidental unrelated signal changes. Every trigger the issue lists ... bumps this instead.

It's instantiated at src/state.ts:725 (dashboardTreeRevision: signal(0)), and every commit path that should invalidate the tree must remember to bump it by hand through the single funnel at src/ui/app.ts:2206–2207:

const invalidateDashboardTree = (): void => { app.state.dashboardTreeRevision.value += 1; };
app.invalidateDashboardTree = invalidateDashboardTree;

— called manually from five sites today (app.ts:2290, :2806, :2838, :2849, :2901). Three effects in src/ui/app-shell.ts read the counter as their only signal into an otherwise non-reactive aggregate:

  • :240–246 — the upper-role tab-count effect (Databases/Dashboards counts).
  • :252–260 — the Dashboard tree repaint effect. Its own comment: "the ONE reactive input the tree has ... Expansion/search/scroll are deliberately NOT reactive — the tree repaints itself directly for those."
  • :275–279 — the saved/history side-panel repaint effect. The #427 comment immediately above it (:270–274) explains why: Library membership is now a projection of dashboards[] (a query is in the Library exactly while no Dashboard member references it), so a committed Dashboard change can move a query in/out of this list with no savedQueries signal change at all — this same counter is the only thing that tells that effect to re-run.

The bug class this invites: any future commit path that mutates dashboard/currentWorkspace and forgets to call invalidateDashboardTree() leaves the Dashboard tree, tab counts, and Library/History panel stale — silently, with every existing test green, because nothing asserts the counter was bumped for a new mutation path; it only asserts behavior for the mutation paths that already remember.

Deliverables

  1. Make state.dashboard / currentWorkspace reactive at the source (a signal, or wrapped so mutation is observable), or introduce one computed projection derived from it that the three app-shell.ts effects and any future subscriber read directly — no second hand-maintained counter standing in for either.
  2. Delete dashboardTreeRevision and its five manual bump call sites (app.ts:2290, :2806, :2838, :2849, :2901) and the invalidateDashboardTree funnel, once nothing depends on it.
  3. Preserve the deliberate non-reactivity of tree expansion/search/scroll/keyboard state (dashboardTreeUi, src/state.ts:400–407 — a plain Map, explicitly NOT a signal because observing it would lose the search caret and repaint on every scroll frame). This issue changes how the tree learns that the underlying data changed, not how it renders its own transient UI state.
  4. Preserve the #427 Library-projection semantics exactly (Library membership = queries with no dashboards[] member reference) — the new reactive source must fire for that transformation too, not just for direct savedQueries edits.

Tests

  • A test that adds a new mutation path touching dashboard/currentWorkspace and asserts the tree/tab-count/saved-history effects re-run without an explicit manual invalidation call — proving the reactivity is structural, not remembered.
  • Existing coverage for the three app-shell.ts effects (tab counts, tree repaint, saved/history repaint) continues to pass unmodified in intent.
  • Regression test for the #427 Library-membership projection: a Dashboard-only mutation (adding/removing a panel reference to a query, no savedQueries write) still repaints the saved/history panel.
  • dashboardTreeUi (expansion/search/scroll) is asserted to remain un-reactive — no new effect subscribes to it.
  • npm test (coverage gate) and tsc --noEmit pass.

Acceptance criteria

  1. dashboardTreeRevision and its manual bump call sites no longer exist.
  2. The Dashboard tree, upper-role tab counts, and saved/history panel repaint from a structural dependency on the committed aggregate (or its computed projection), not a manually incremented counter.
  3. dashboardTreeUi (tree expansion/search/scroll/keyboard) remains deliberately non-reactive, unchanged.
  4. The #427 Library-projection semantics (membership derived from dashboards[]) are preserved exactly.
  5. npm test (coverage gate) and tsc --noEmit pass.

Non-goals

  • Migrating every remaining non-signal slice of AppState to signals — scope is the committed workspace aggregate and its three known subscribers.
  • Changing mutateWorkspace's commit/validation contract, result shape, or conflict handling.
  • Any visual or behavioral change to the Dashboard tree, tab counts, or saved/history panel beyond how they learn to repaint.

Related

Risk notes

  • batch() discipline matters: converting the counter bump into a reactive read means multiple field writes during one commit must not fire subscribers once per field — verify the new source (or its computed projection) settles within the same batch() the commit already uses.
  • Per the open-time-snapshot-is-not-a-gate lesson, destructive-commit re-validation belongs inside the mutateWorkspace transform itself, not in a UI-layer check that happens to run after a stale snapshot — this refactor must not accidentally move that validation to a point where it can be skipped.

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