Skip to content

perf: structural sharing in setAppState + React.memo on the pane tree#150

Merged
Zeus-Deus merged 3 commits into
mainfrom
perf/structural-sharing
Jul 10, 2026
Merged

perf: structural sharing in setAppState + React.memo on the pane tree#150
Zeus-Deus merged 3 commits into
mainfrom
perf/structural-sharing

Conversation

@Zeus-Deus

Copy link
Copy Markdown
Owner

Fixes #127.

Problem

The whole active pane tree re-rendered on every backend tick. The Rust backend rebuilds the entire AppStateSnapshot on every emit_app_state (coalesced to 16ms windows, so up to ~60 full snapshots/sec under agent streaming), the frontend debounces another 16ms, and then setAppState swapped the snapshot in with no structural sharing — fresh refs for every workspace/surface/pane meant useActiveWorkspace() churned every tick and the un-memoized WorkspaceMain → PaneContainer → PaneNode → TerminalPane chain reconciled top to bottom.

Part 1 — structural sharing in setAppState

New src/lib/structural-share.ts: shareStructural(prev, next) reconciles the incoming snapshot against the one already held, reusing the previous refs for every deep-equal subtree (arrays element-wise, plain objects per key, recursive through the pane tree). A no-op tick returns the previous top-level ref → zero zustand fan-out; a change to one workspace's pane_statuses leaves every other workspace object and every unchanged surface/pane subtree identity intact. useActiveWorkspace()/useActiveSurface() become stable across no-op ticks with no call-site changes, and the WeakMap-cached buildSessionWorkspaceIndex stays correct (reused ref ⇒ unchanged content).

Deliberately unlike the removed full-snapshot JSON.stringify dedup (reverted for causing freezes — see the comment in use-app-state.ts): the walk allocates no strings, runs in a single O(n) pass with per-branch early exits, and executes at most once per 16ms debounce window. Worst case it's one walk of a snapshot that was just JSON-deserialized over IPC anyway.

Part 2 — memoize the pane tree

React.memo on PaneContainer, PaneNode (the split branch's recursion resolves to the memoized component), TerminalPane + BrowserPane (export-only wrappers — the xterm lifecycle, write pump, and PTY path are untouched, per the perf-regression history), and WorkspaceMain's heavy children TabBar, PresetBar, RightPanel. Part 1 is what makes these effective.

Also fixes a latent rules-of-hooks bug in PaneNode: useAppStore/useFeatureFlags were called after the kind === "split" early return, so a split↔leaf flip at the same fiber position would change hook order and throw. Both hooks are hoisted above the branch.

Verification

  • npm run verify green: cargo check + cargo test, tsc, 165 test files / 2383 frontend tests.
  • New src/lib/structural-share.test.ts (18 cases): identity fast paths, deep-equal → prev ref, array grow/shrink/reorder, Record key add/remove/swap, optional-field presence, recursive split-tree branch sharing, input immutability.
  • New store-level tests: setAppState(deepClone(A)) keeps the previous snapshot ref; a one-workspace change keeps every other workspace's identity.
  • New src/components/layout/pane-tree-rerender.test.tsx (5 cases) drives the real store → useActiveWorkspace() → memo chain under a <Profiler>: zero commits on a deep-equal tick and on an inactive-workspace change; a real pane-title change and a split→single structure change still render correctly (guards against over-memoization).
  • Live pass in the dev-mock UI (npm run dev + browser pane): workspace switch, workspace close, and chat/terminal surfaces all update immediately. Pane split/close/tab mutations have no dev-mock handlers (real-IPC only) — those paths are covered by the integration suite, and their backend emits are direct (emit_app_state), not coalesced, so no added latency.
  • No Rust changes; nothing on the keystroke echo path.

Zeus-Deus added 3 commits July 9, 2026 23:14
Fixes #127. The whole active pane tree re-rendered on every backend
tick (up to ~60 full AppStateSnapshot emits/sec during agent
streaming) because setAppState swapped the snapshot in with fresh
refs everywhere and nothing in the pane tree was memoized.

Part 1 — structural sharing. setAppState now runs
shareStructural(prev, next) (src/lib/structural-share.ts): a single
O(n) reconciliation walk that reuses the previous snapshot's object
refs for every deep-equal subtree, early-exiting per branch. A no-op
tick returns the previous top-level ref (zero zustand fan-out); a
change to one workspace leaves every other workspace/surface/pane
subtree ref untouched. useActiveWorkspace()/useActiveSurface() become
stable across no-op ticks with no call-site changes, and the
WeakMap-cached buildSessionWorkspaceIndex stays correct (reused ref
means unchanged content). Unlike the removed full-snapshot
JSON.stringify dedup (reverted for causing freezes), the walk
allocates no strings and runs at most once per 16ms debounce window.

Part 2 — memoize the pane tree. PaneContainer, PaneNode (recursion
resolves to the memoized component), TerminalPane, BrowserPane
(export-only wrappers, xterm lifecycle untouched), and
WorkspaceMain's heavy children (TabBar, PresetBar, RightPanel) are
wrapped in React.memo. Also fixes a latent rules-of-hooks bug in
PaneNode: useAppStore/useFeatureFlags were called after the split
early-return, so a split<->leaf flip at the same fiber position would
change hook order; both hooks are hoisted above the branch.

Verified: npm run verify green (cargo check + cargo test, tsc, 165
test files / 2383 frontend tests). New coverage: 18 unit tests for
shareStructural, 2 store-level identity tests, and a 5-case
pane-tree-rerender integration suite driving the real store ->
useActiveWorkspace -> memo chain, proving zero Profiler commits on
deep-equal ticks and other-workspace changes while real changes
still render. Live UI pass in the dev mock: workspace switch, close,
and chat surfaces all update immediately (user-action emits are
direct, not coalesced). No Rust changes; the keystroke echo path,
write pump, and PTY channel are untouched.
@Zeus-Deus Zeus-Deus merged commit 28817bc into main Jul 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: structural sharing in setAppState + React.memo on the pane tree (whole tree re-renders every backend tick)

1 participant