perf-stress-ui

star 364

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.

processone By processone schedule Updated 6/5/2026

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, drive window.__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-batch is 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)+1 after each detectRenderLoop() call (and into un-instrumented components), reset window.__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, sleep in Bash, READ counters in a separate eval (the __rc counter is cumulative so read timing doesn't matter). Read the live store via import('/@fs/<abs>/packages/fluux-sdk/src/index.ts') — same instance; verify roomStore.getState().activeRoomJid matches 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 useCallback and 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), so React.memo no-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 (NOT useCallback).
  • 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).
Install via CLI
npx skills add https://github.com/processone/fluux-messenger --skill perf-stress-ui
Repository Details
star Stars 364
call_split Forks 19
navigation Branch main
article Path SKILL.md
More from Creator