hermes-agent/apps/desktop/src/debug
Brooklyn Nicholson 2c867b05ce perf(desktop): 60fps sash drag on real sessions — height-gate the RO pins
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.
2026-07-27 01:07:48 -05:00
..
atom-churn.ts feat(desktop): dev-only render + store churn counters 2026-07-26 06:10:54 -05:00
dev-only.noop.ts feat(desktop): dev-only render + store churn counters 2026-07-26 06:10:54 -05:00
dev-only.ts feat(desktop): dev-only render + store churn counters 2026-07-26 06:10:54 -05:00
index.ts perf(desktop): 60fps sash drag on real sessions — height-gate the RO pins 2026-07-27 01:07:48 -05:00
perf-live.ts perf(desktop): 60fps sash drag on real sessions — height-gate the RO pins 2026-07-27 01:07:48 -05:00
README.md feat(desktop): dev-only render + store churn counters 2026-07-26 06:10:54 -05:00
render-counter.ts perf(desktop): 60fps sash drag on real sessions — height-gate the RO pins 2026-07-27 01:07:48 -05:00
watched-atoms.ts feat(desktop): dev-only render + store churn counters 2026-07-26 06:10:54 -05:00

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. A memo() or a narrower subscription removes it.
  • atom wasted — published a value deep-equal to the previous one. @nanostores/react bails out on reference equality only, so this re-renders every subscriber for nothing. This is the "preserve reference identity on no-ops" rule in apps/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.js0. 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.