hermes-agent/apps/desktop/src/debug
Brooklyn Nicholson f7aee9dc8c fix(desktop): make render-churn measure streaming, not boot churn
Two problems, both found by distrusting the harness's own numbers.

1. The scenario slept a fixed 1s after mounting tabs, then recorded. Boot
   and session hydration are not reliably done by then, so a variable
   amount of unrelated work landed inside the measurement window. Three
   back-to-back runs on identical code spread 2.2x on total_renders and
   3.8x on wasted_renders — wide enough that a single-run before/after
   delta could be mostly noise. Replaced with a quiesce gate that waits
   for commits to hold still before recording, and reports 'quiet:N' or
   'timeout:...' so a contaminated run is visible instead of silent.

2. The counter attributed a context-driven re-render as 'wasted', which
   pointed at memo() as the fix when memo cannot block context at all.
   Adds contextChanged via the fiber's context dependency list, and
   excludes it from wasted.

The gate also turned up a finding worth more than the fix: with five busy
tiles and NO driver running, the renderer still commits ~18x/sec. The
report now names the cascade roots (own state changed, props did not)
rather than leaving them to be guessed at — Streamdown re-renders itself
105 times while idle, which is what drives Block/Ct.
2026-07-26 15:54:18 -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 feat(desktop): dev-only render + store churn counters 2026-07-26 06:10:54 -05:00
README.md feat(desktop): dev-only render + store churn counters 2026-07-26 06:10:54 -05:00
render-counter.ts fix(desktop): make render-churn measure streaming, not boot churn 2026-07-26 15:54:18 -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.