Files
hermes-agent/apps/desktop/src/debug/README.md
T
Brooklyn Nicholson 44211f3608 feat(desktop): dev-only render + store churn counters
Adds two dev-only counters that attribute re-renders and store
notifications during an interaction, so a perf claim can be answered with
a number instead of a hunch:

  window.__RENDER_COUNTS__  what re-rendered, and why (props/state/parent)
  window.__ATOM_CHURN__     which store published it, and whether it mattered

The `wasted` column in each is the fix list — components that re-rendered
with no changed input, and stores that published a value equal to the
last one. Both are inert until start(), so idle cost is one branch per
commit and per notify.

React 19.2 removed injectProfilingHooks from react-dom, so the mark*
profiling family is unavailable and onCommitFiberRoot is the only channel
left. <Profiler> can't answer the question either: React invokes onRender
for every Profiler in a committed tree including subtrees that bailed
out, and a bailed-out subtree still reports nonzero actualDuration. This
uses bippy's didFiberRender instead. bippy over react-scan because
react-scan/lite is a thin wrapper over it while the package pulls ~217
transitive deps and floats two on latest.

main.tsx imports the entry statically above react-dom because react-dom
captures the devtools hook at module init — a late install reports
renderers=0 and observes zero commits. Production exclusion is handled by
a build-time alias to a no-op module rather than tree-shaking, since a
static side-effect import can't be eliminated.
2026-07-26 06:10:54 -05:00

4.7 KiB
Raw Blame History

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.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.