Summary
Workflow 3 of 5 from the 2026-05-03 performance review of main. Parent: #46. Sibling workflows: #71 (Pixi internals), #72 (Canvas2D internals).
The React shell wrapping the imperative renderers is well-architected (shared SimulationManager, useSyncExternalStore throttle, ref-based draw-prop sync), but two SSR/client hydration mismatches, a static ~780 KB Pixi import on every page load, and a render-cascade that fires on every session-activity event are all leaving real wins on the table. Issue #46 notes ~3585 React commits in 90 s under 4× CPU / 3 sessions (~40 commits/sec) which is well above the draw cadence — most of the items here are candidates for that excess.
Critical
CR-8 · SSR/client hydration mismatch
Two distinct mismatches, same fix family.
(a) IS_PIXI_RENDERER — web/lib/renderer-mode.ts:11
export const IS_PIXI_RENDERER =
typeof window !== 'undefined' &&
new URLSearchParams(window.location.search).get('renderer') === 'pixi'
Module-scope evaluation: server emits false, client emits true when ?renderer=pixi. session-canvas-panel.tsx:270 branches the entire renderer subtree on this. Server renders Canvas2D subtree → client hydrates Pixi → React 19 throws away SSR work and re-renders.
(b) INSTANCE_ID — web/hooks/use-panel-layout.ts:95-105 displayed at top-bar.tsx:220, 224
Server returns 'ssr'; client returns URL-or-random string. The text appears directly in JSX → mismatch warning.
Fix: useState(false) flipped in useEffect; both initial SSR and first client render produce Canvas2D / placeholder, swap on the second pass.
Important
IR-9 · Pixi.js statically imported on every page load (~780 KB minified)
- Import chain:
app/page.tsx → AgentVisualizer ('use client') → SessionCanvasPanel → pixi/pixi-canvas.tsx → pixi.js
node_modules/pixi.js/dist/pixi.min.mjs is 780 KB un-gzipped; default-renderer (Canvas2D) users pay full parse + transfer for code they never run
grep -rn "dynamic\|React.lazy\|lazy(" web/components web/hooks returns nothing — no code-splitting today
- Fix:
next/dynamic({ ssr: false }) for the Next path + React.lazy for the Vite webview path, gated on the deferred IS_PIXI_RENDERER from CR-8. Pair with experimental.optimizePackageImports: ['pixi.js'] in next.config.mjs.
IR-10 · bridge identity churns on every session activity update
web/hooks/use-vscode-bridge.ts:379-404
- Memo deps include
sessions, selectedSessionId, sessionsWithActivity (a Set rebuilt at line 186-192 on every event). New bridge identity → recomputed handleCloseSession / handleCanvasAgentClick / openFile (index.tsx:277, 290, 296) → memoized <TopBar> re-renders → Workspaces button re-runs O(N) useMemo over sessions
- Fix: split bridge into stable "actions" memo and churning "state" memo, or memoize action callbacks individually (they depend on internal refs only). Comment at line 378-379 acknowledges the concern but the deps still include the churning fields.
IR-11 · Four setInterval(250 ms) polling timers per SessionCanvasPanel
web/hooks/use-frame-ref-selector.ts:31-40 mounts a setInterval per call
session-canvas-panel.tsx:203-206 calls it 4× (selectCurrentTime, selectIsPlaying, selectSpeed, selectMaxTime) — 4 timers per session canvas, all running on idle and offscreen
- The same fields are already exposed by
useSessionSimulation via useSyncExternalStore (which throttles to ~4 Hz). Polling is redundant.
- Fix: read
sim.currentTime etc. directly from useSessionSimulation's return; eliminate the polling layer.
IR-12 · SessionNamesProvider context value is a fresh object every render
web/hooks/use-session-names.ts:66
value: { getName, setName, names } allocated inline → every consumer re-renders whenever the provider parent re-renders
- Fix: wrap in
useMemo. (Same pattern that panel-layout got right at line 590 — apply consistently.)
IR-13 · usePanelLayout exposes the entire panels map in context value
web/hooks/use-panel-layout.ts:590-610
- Every drag/resize triggers re-renders for all 7+ FloatingPanel consumers (TopBar, every panel, AgentVisualizerInner, SessionCanvasPanel)
floating-panel.tsx:103-108 writes setPanelRect on mount of every panel including visible={false} ones → O(panels²) startup cascade
- Fix: split into a stable "actions" context and a per-panel "rect" context (or selector hook mirroring
session-stats-store.ts); skip the mount setPanelRect when !isRendered.
IR-14 · Always-mounted hidden panels still run their useMemos
web/components/agent-visualizer/index.tsx:488-515 mounts FileAttentionPanel, SessionTranscriptPanel, TimelinePanel, AgentChatPanel, CostSummaryPanel unconditionally; they internally check visible and return null
file-attention-panel.tsx:23-31's O(N log N) sort and maxTokens reduce run every fileAttention update even when hidden
MessageFeedPanel is correctly gated at parent ({showMessageFeed && <MessageFeedPanel ... />}) — apply the same pattern.
Minor
| ID |
Where |
Fix |
| MR-4 |
web/next.config.mjs |
Add experimental.optimizePackageImports: ['pixi.js'] |
| MR-5 |
web/app/globals.css:118-125 |
Universal selector * { @apply border-border outline-ring/50 } (shadcn boilerplate) — scope or remove |
| MR-7 |
session-transcript-panel.tsx:45-53 |
Memoize filteredConversation — currently re-filters at 4 Hz under active search |
| MR-8 |
web/hooks/use-perf-settings.ts:54 |
safeEffects: { ...DEFAULT_EFFECTS, ...effects } allocated every render → defeats memoized <TopBar>. Wrap in useMemo. |
| MR-13 |
top-bar.tsx:368-433, 518-636 |
PerfButton and WorkspaceFilterButton build full popover JSX every render even when closed. Defer construction until open === true. |
Open question (do this first)
Before fixing IR-10/IR-11/IR-12/IR-13: profile with React DevTools to identify which hook is actually driving the ~40 commits/sec under steady state. The root cause may collapse several of these. Worth one investigative session before splitting fixes across multiple PRs.
Parallelism
Independent of WG-1 (#71), WG-2 (#72), WG-4 (relay), WG-5 (extension). React shell files don't overlap with renderer internals — pixi-canvas.tsx belongs to WG-1, canvas.tsx belongs to WG-2.
Test plan
Summary
Workflow 3 of 5 from the 2026-05-03 performance review of
main. Parent: #46. Sibling workflows: #71 (Pixi internals), #72 (Canvas2D internals).The React shell wrapping the imperative renderers is well-architected (shared
SimulationManager,useSyncExternalStorethrottle, ref-based draw-prop sync), but two SSR/client hydration mismatches, a static ~780 KB Pixi import on every page load, and a render-cascade that fires on every session-activity event are all leaving real wins on the table. Issue #46 notes ~3585 React commits in 90 s under 4× CPU / 3 sessions (~40 commits/sec) which is well above the draw cadence — most of the items here are candidates for that excess.Critical
CR-8 · SSR/client hydration mismatch
Two distinct mismatches, same fix family.
(a)
IS_PIXI_RENDERER—web/lib/renderer-mode.ts:11Module-scope evaluation: server emits
false, client emitstruewhen?renderer=pixi.session-canvas-panel.tsx:270branches the entire renderer subtree on this. Server renders Canvas2D subtree → client hydrates Pixi → React 19 throws away SSR work and re-renders.(b)
INSTANCE_ID—web/hooks/use-panel-layout.ts:95-105displayed attop-bar.tsx:220, 224Server returns
'ssr'; client returns URL-or-random string. The text appears directly in JSX → mismatch warning.Fix:
useState(false)flipped inuseEffect; both initial SSR and first client render produce Canvas2D / placeholder, swap on the second pass.Important
IR-9 · Pixi.js statically imported on every page load (~780 KB minified)
app/page.tsx → AgentVisualizer ('use client') → SessionCanvasPanel → pixi/pixi-canvas.tsx → pixi.jsnode_modules/pixi.js/dist/pixi.min.mjsis 780 KB un-gzipped; default-renderer (Canvas2D) users pay full parse + transfer for code they never rungrep -rn "dynamic\|React.lazy\|lazy(" web/components web/hooksreturns nothing — no code-splitting todaynext/dynamic({ ssr: false })for the Next path +React.lazyfor the Vite webview path, gated on the deferredIS_PIXI_RENDERERfrom CR-8. Pair withexperimental.optimizePackageImports: ['pixi.js']innext.config.mjs.IR-10 ·
bridgeidentity churns on every session activity updateweb/hooks/use-vscode-bridge.ts:379-404sessions,selectedSessionId,sessionsWithActivity(a Set rebuilt at line 186-192 on every event). Newbridgeidentity → recomputedhandleCloseSession/handleCanvasAgentClick/openFile(index.tsx:277, 290, 296) → memoized<TopBar>re-renders →Workspacesbutton re-runs O(N)useMemoover sessionsIR-11 · Four
setInterval(250 ms)polling timers perSessionCanvasPanelweb/hooks/use-frame-ref-selector.ts:31-40mounts a setInterval per callsession-canvas-panel.tsx:203-206calls it 4× (selectCurrentTime,selectIsPlaying,selectSpeed,selectMaxTime) — 4 timers per session canvas, all running on idle and offscreenuseSessionSimulationviauseSyncExternalStore(which throttles to ~4 Hz). Polling is redundant.sim.currentTimeetc. directly fromuseSessionSimulation's return; eliminate the polling layer.IR-12 ·
SessionNamesProvidercontext value is a fresh object every renderweb/hooks/use-session-names.ts:66value: { getName, setName, names }allocated inline → every consumer re-renders whenever the provider parent re-rendersuseMemo. (Same pattern thatpanel-layoutgot right at line 590 — apply consistently.)IR-13 ·
usePanelLayoutexposes the entirepanelsmap in context valueweb/hooks/use-panel-layout.ts:590-610floating-panel.tsx:103-108writessetPanelRecton mount of every panel includingvisible={false}ones → O(panels²) startup cascadesession-stats-store.ts); skip the mount setPanelRect when!isRendered.IR-14 · Always-mounted hidden panels still run their
useMemosweb/components/agent-visualizer/index.tsx:488-515mountsFileAttentionPanel,SessionTranscriptPanel,TimelinePanel,AgentChatPanel,CostSummaryPanelunconditionally; they internally checkvisibleand returnnullfile-attention-panel.tsx:23-31's O(N log N) sort andmaxTokensreduce run every fileAttention update even when hiddenMessageFeedPanelis correctly gated at parent ({showMessageFeed && <MessageFeedPanel ... />}) — apply the same pattern.Minor
web/next.config.mjsexperimental.optimizePackageImports: ['pixi.js']web/app/globals.css:118-125* { @apply border-border outline-ring/50 }(shadcn boilerplate) — scope or removesession-transcript-panel.tsx:45-53filteredConversation— currently re-filters at 4 Hz under active searchweb/hooks/use-perf-settings.ts:54safeEffects: { ...DEFAULT_EFFECTS, ...effects }allocated every render → defeats memoized<TopBar>. Wrap inuseMemo.top-bar.tsx:368-433, 518-636PerfButtonandWorkspaceFilterButtonbuild full popover JSX every render even when closed. Defer construction untilopen === true.Open question (do this first)
Before fixing IR-10/IR-11/IR-12/IR-13: profile with React DevTools to identify which hook is actually driving the ~40 commits/sec under steady state. The root cause may collapse several of these. Worth one investigative session before splitting fixes across multiple PRs.
Parallelism
Independent of WG-1 (#71), WG-2 (#72), WG-4 (relay), WG-5 (extension). React shell files don't overlap with renderer internals —
pixi-canvas.tsxbelongs to WG-1,canvas.tsxbelongs to WG-2.Test plan
?renderer=pixiin dev mode; no hydration warnings in consoleuseFrameRefSelector(check after fix)