Skip to content

Perf WG-3: React shell — hydration mismatch, Pixi bundle split, render cascade #73

@DFearing

Description

@DFearing

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_RENDERERweb/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_IDweb/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

  • CR-8: load ?renderer=pixi in dev mode; no hydration warnings in console
  • IR-9: production build before/after with bundle analyzer; default-renderer first-load JS should drop ~200-400 KB gzipped
  • React DevTools profiler: commits/sec under 4× CPU / 3-session sim should drop from ~40 closer to the ~4 Hz throttle target
  • IR-11: confirm Performance panel shows 0 setInterval timers from useFrameRefSelector (check after fix)
  • No regressions in panel drag/resize behavior, perf popover, workspace filter

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions