name: perf-stress-ui description: Use when investigating a UI render-performance problem or render loop in the Fluux app (sidebar/list re-render storms, "why is X re-rendering", verifying a render-perf fix). Reproduces load deterministically in demo mode, measures with react-scan + renderLoopDetector, and diagnoses the memo-breaking prop.
Perf / Stress UI debugging (Fluux)
When to use
A sidebar/list re-renders too much, a render loop is suspected, or you're
verifying a render-perf change. See memory/project_render_perf_react_compiler.md
and docs/superpowers/specs/2026-06-05-perf-stress-ui-harness-design.md.
1. Reproduce (demo mode — no server)
npm run dev, then open:
http://localhost:5173/demo.html?tutorial=false&stress=rooms:15,messages:150,mode:backfill&perf=1
mode:backfill= historical timestamps, no reorder (real "join N rooms" case).mode:live= reorders on every message (worst case). For custom sequences, drivewindow.__demoClient.emitSDK('room:message', { roomJid, message }).
Test EVERY churn source, not just messages. A component can be decoupled from
one and still storm on another (PR #451 killed the composer's message re-renders
but it still re-renders ~1:1 on occupant churn — addOccupant/removeOccupant
replace the occupants Map each event, so useRoomOccupants consumers + the
unmemoized OccupantPanel storm). Drivers to replay individually:
- messages →
emitSDK('room:message', { roomJid, message }) - presence storm (netsplit rejoin / busy room / show-flapping) →
emitSDK('room:occupant-joined'|'room:occupant-left', …)(one event per stanza;room:occupants-batchis the single-render initial-join path — don't use it to simulate a storm) - typing →
emitSDK('room:typing', { roomJid, nick, isTyping })
Running from a git WORKTREE: the worktree has no node_modules; the explicit-path
alias @xmpp/sasl-scram-sha-1 → ../../node_modules/... in apps/fluux/vite.config.ts
then fails ([UNLOADABLE_DEPENDENCY], blank page). Fix: ln -s <main-checkout>/node_modules <worktree>/node_modules
(remove it when done — .gitignore's node_modules/ has a trailing slash so it does
NOT ignore a symlink, and it shows in git status). @fluux/sdk is aliased to
packages/fluux-sdk/src, so you ARE testing the worktree's source.
2. Measure
- Preferred:
await window.__perf.measure('label', () => window.__demoClient.runStressScenario({ kind:'room-join', rooms:15, messagesPerRoom:150, mode:'live' }))→ per-component render table (react-scan). - If
?perf=1/ react-scan HANGS the renderer on load (seen: react-scan + React-Compiler + StrictMode over the full demo tree — every eval/screenshot times out): skip it and use the always-on detector instead.window.__det = await import('/src/utils/renderLoopDetector.ts')(same singleton Vite serves) →__det.getRenderStats(). Instrumented components: App, ChatLayout, Sidebar, RoomsList, ConversationList, RoomView, MessageList, MessageComposer (NOT RoomMessageInput / OccupantPanel — add a counter for those). - Detector
getRenderStats()count uses a RESETTING 1000ms window — it zeroes when a component renders past the window, so it CANNOT capture cumulative magnitude for floods that span/spill past ~1s (you read a tiny post-reset remnant; saw a 60-event storm report "2"). For reliable magnitude, splice a never-resetting counter;(globalThis).__rc = (globalThis).__rc||{}; (globalThis).__rc.X = ((globalThis).__rc.X||0)+1after eachdetectRenderLoop()call (and into un-instrumented components), resetwindow.__rc = {}before each run.startSyncGracePeriod()raises the throw threshold 200→500 + silences warnings so a legit heavy flood doesn't trip the RenderLoopBoundary mid-measurement. - Live preview evals choke ("Promise was collected" / 30s timeout) on awaits ≳1s
and while the renderer is saturated mid-flood. So: FIRE the flood fire-and-forget in
one eval,
sleepin Bash, READ counters in a separate eval (the__rccounter is cumulative so read timing doesn't matter). Read the live store viaimport('/@fs/<abs>/packages/fluux-sdk/src/index.ts')— same instance; verifyroomStore.getState().activeRoomJidmatches the open room. - CAVEAT: React StrictMode doubles dev renders — divide by 2 for logical counts.
- Sanity baseline: a no-op parent re-render should produce 0 child renders (the per-event diagnostic — fire ONE event, read which counters tick — is the cleanest signal and sidesteps batching/coalescing).
3. Diagnose — find the memo-breaking prop, then its source
react-scan reports React-Compiler-memoized components as forget:true,
changes:[], unnecessary:null (it cannot attribute the cause). To find which
prop breaks memo, temporarily wrap the child:
memo(Component, (prev, next) => {
for (const k of new Set([...Object.keys(prev), ...Object.keys(next)]))
if (!Object.is((prev as any)[k], (next as any)[k]))
((window as any).__memoDiff ??= {})[k] = (((window as any).__memoDiff||{})[k]||0)+1
return /* shallow-equal? */ ...
})
Then trace the offending prop to its SOURCE hook. Two traps that recur here:
- React Compiler strips
useCallbackand only memoizes callbacks used as a hook dependency; JSX-only callbacks are fresh closures each render (PR #450). - A prop's source hook returns an unstable ref (e.g.
useFileUpload), soReact.memono-ops even though the JSX looks fine (PR #451). Also distinguish reorder (activity-sorted list order changed — legitimate list re-render) vs content churn (only one row's data changed).
4. Fix patterns
- Stable callbacks: lazy-init
useRef+ a "latest" ref (NOTuseCallback). - Subscribe to an ordered id/JID list via
useShallow(e.g.roomSidebarJids()), and have each row self-subscribe by id (getRoom(jid)— stable per row). - Use focused hooks over ones that recombine entity/meta/runtime each render.
5. Verify
- No-op parent re-render → 0 child renders (memo bails).
- Worst-case burst → ~1 render per message (not × rows).
- Add a render-count regression guard (see
packages/fluux-sdk/src/stores/RENDER_PERF_TESTS.md).