perf: structural sharing in setAppState + React.memo on the pane tree#150
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #127.
Problem
The whole active pane tree re-rendered on every backend tick. The Rust backend rebuilds the entire
AppStateSnapshoton everyemit_app_state(coalesced to 16ms windows, so up to ~60 full snapshots/sec under agent streaming), the frontend debounces another 16ms, and thensetAppStateswapped the snapshot in with no structural sharing — fresh refs for every workspace/surface/pane meantuseActiveWorkspace()churned every tick and the un-memoizedWorkspaceMain → PaneContainer → PaneNode → TerminalPanechain reconciled top to bottom.Part 1 — structural sharing in
setAppStateNew
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'spane_statusesleaves 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 theWeakMap-cachedbuildSessionWorkspaceIndexstays correct (reused ref ⇒ unchanged content).Deliberately unlike the removed full-snapshot
JSON.stringifydedup (reverted for causing freezes — see the comment inuse-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.memoonPaneContainer,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), andWorkspaceMain's heavy childrenTabBar,PresetBar,RightPanel. Part 1 is what makes these effective.Also fixes a latent rules-of-hooks bug in
PaneNode:useAppStore/useFeatureFlagswere called after thekind === "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 verifygreen: cargo check + cargo test, tsc, 165 test files / 2383 frontend tests.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.setAppState(deepClone(A))keeps the previous snapshot ref; a one-workspace change keeps every other workspace's identity.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).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.