Driving HER real instance (real profile, real transcripts, streams live)
via CDP instead of synthetic tiles finally exposed the remaining stall.
The timeline on a real 60-frame sash drag:
style recalc 2736ms | script 1027ms | layout 89ms
top callsite: pin @ fallback.tsx — 927ms
Two pin-to-bottom ResizeObservers (the bounded tool window's and the
reasoning preview's) pinned on EVERY resize delivery. A sash drag changes
every message's WIDTH once per frame, so each frame ran scrollTop write ->
scrollHeight read across every tool group: a forced write-read reflow
cascade that the render counters could never see (zero React involvement).
Both pins are now height-gated off the RO entry (reflow-free): only
content GROWTH pins. Width-only deliveries return immediately.
Measured on the live app, same drag, before -> after:
fps 11.5 -> 59-60
p95 101ms -> 18ms
slow>33 60/60 -> 1/60
Also in this batch (each was verified live before the next was attempted):
- thread/list: split messageSignature into STRUCTURAL (ids/roles — keys
boundaries + row identity) and WEIGHT (part counts — budget only), and
memoize groups + row JSX. A streamed part-append re-rendered every
turn's boundary via its resetKey prop; explain() measured 540-865
wasted Block renders per drag/stream sample, now {}.
- message-render-boundary: document the structural-only resetKey contract.
- tool/fallback: memoize ToolFallback's part object + ToolEntry/ToolTitle/
ToolGlyph (151 renders each, 100% wasted, on real transcripts).
- use-message-stream: ADAPTIVE flush floor — next flush waits 3x the
measured cost of the last one (33ms floor, 250ms cap), so multi-stream
load degrades text update rate instead of input latency.
- tree-split: preview sash drags with inline flex on the two seam
wrappers, committing the store ONCE on release (fixed-zone sides get
flexBasis only, so a hidden sidebar can't leave a phantom gap).
- debug/: perf-live LoAF long-frame attribution, explain() cascade walker
with changed-hook indices, diag-real-loop/key-latency/switch-trace
probes that drive the real app over CDP.
Typing during 2 live streams: keystroke->paint p50 3.3ms, p95 18.4ms,
zero frames over 33ms. Session switch p50 ~35ms settled; the remaining
~1.3s outlier tail is streaming-session switches (React work-loop, not
style/layout) — next target.
|
||
|---|---|---|
| .. | ||
| atom-churn.ts | ||
| dev-only.noop.ts | ||
| dev-only.ts | ||
| index.ts | ||
| perf-live.ts | ||
| README.md | ||
| render-counter.ts | ||
| watched-atoms.ts | ||
Dev-only state diagnostics
Two counters that answer, for any interaction: what re-rendered, why, and which store pushed it?
window.__RENDER_COUNTS__ what re-rendered, attributed to props / state / parent
window.__ATOM_CHURN__ which store published it, and whether it mattered
Both are inert until start(), so the idle cost is one no-op branch per commit
and per notify. Neither ships: vite.config.ts aliases @/debug/dev-only to a
no-op module for any build that isn't the dev server (or VITE_PERF_PROBE=1).
Using it
From the devtools console, while an agent streams:
__RENDER_COUNTS__.start(); __ATOM_CHURN__.start()
// ...let it run for a few seconds...
__RENDER_COUNTS__.stop(); __ATOM_CHURN__.stop()
console.table(__RENDER_COUNTS__.report())
console.table(__ATOM_CHURN__.report())
The wasted column in each is the fix list:
- render
wasted— re-rendered with neither changed props nor changed hook state, i.e. purely because a parent did. Amemo()or a narrower subscription removes it. - atom
wasted— published a value deep-equal to the previous one.@nanostores/reactbails out on reference equality only, so this re-renders every subscriber for nothing. This is the "preserve reference identity on no-ops" rule inapps/desktop/AGENTS.md.
Or as a gated measurement, driving 5 concurrent streaming tabs synthetically (no backend, no credits):
node scripts/perf/run.mjs render-churn --spawn --tiles 5 --tokens 240
Why bippy, and not the obvious choices
React 19.2 removed injectProfilingHooks from react-dom. Verified:
grep -c injectProfilingHooks node_modules/react-dom/cjs/react-dom-client.development.js
→ 0. Only onCommitFiberRoot / onPostCommitFiberRoot remain. The entire
mark* profiling family (component-render-start, state-update,
render-scheduled) is dead on this stack — anything built on it is out.
<Profiler> cannot answer "did the sidebar re-render?" React invokes
onRender for every Profiler in a committed tree, including subtrees that
bailed out. Counting those callbacks "proves" a re-render that never happened.
actualDuration is not a discriminator either: a bailed-out subtree still
reports a small nonzero duration, so there's no safe threshold. didFiberRender
is the honest signal. (Note app/chat/perf-probe.tsx exports a PerfProbe
Profiler wrapper that is used nowhere — that's why.)
react-scan ships the right idea in its undocumented react-scan/lite
subpath, but lite is a thin wrapper whose only imports are bippy and
bippy/source. The package pulls ~217 transitive deps (babel, preact) and
floats react-grab / react-doctor on latest, so installs aren't
reproducible, and its main entry currently breaks Vite with a JSON
import-attribute error (upstream issues #448, #467, both open). We take bippy
directly: MIT, zero dependencies.
The import-order constraint
main.tsx imports @/debug/dev-only statically, above react-dom. This is
load-bearing, not stylistic.
react-dom captures the devtools hook at module init, not at createRoot.
Installing afterwards leaves a hook object in place but bippy._renderers
empty, and every commit goes unseen. Verified both directions:
| order | _renderers |
commits |
|---|---|---|
| bippy installs first | 1 | 1 |
| react-dom evaluates first | 0 | 0 |
A dynamic import() behind an import.meta.env.DEV guard would resolve a
microtask after main.tsx's static graph (react-dom included) had evaluated —
too late. Hence a static import, with production exclusion handled by the
build-time alias instead of tree-shaking.
If you add a test that imports these counters, note the ui vitest project's
setupFiles pulls in @testing-library/react (and thus react-dom) before any
test body runs, so the hook can never install there. Use a config without
setupFiles.
Baseline (5 tabs × 240 tokens, dev renderer, darwin-arm64)
sidebar_renders 6
sidebar_wasted 0
wasted_renders 10,432
total_renders 78,385
commits 1,566
wasted_notifies 0
The sidebar hypothesis is refuted: 6 renders across the whole run, all
attributable to hook state on genuine busy/needsInput edges, none wasted. The
stableArray guards on $workingSessionIds / $attentionSessionIds
(store/session-states.ts:236-259) are doing their job.
The real cost is elsewhere — see topRenders in the scenario's detail.
$sessionStates notified 1,200 times with 10 listeners (fan-out 12,000)
and zero wasted notifies, so the publishing side is honest; the waste is in
components that re-render on a parent commit without their own inputs changing.