Merge pull request #71925 from NousResearch/bb/desktop-state-diagnostics

feat(desktop): state diagnostics — render + store churn counters
This commit is contained in:
brooklyn! 2026-07-26 14:12:16 -05:00 committed by GitHub
commit 41f2196c53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 815 additions and 3 deletions

View File

@ -147,6 +147,7 @@
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"@vitejs/plugin-react": "^6.0.1",
"bippy": "0.5.43",
"concurrently": "^10.0.3",
"cross-env": "^10.1.0",
"electron": "40.10.2",

View File

@ -51,6 +51,7 @@ directly via `window.__PERF_DRIVE__`, so no LLM credits are spent.
| `stream --real` | backend | same, from a real LLM stream | measure-real-stream, profile-real-stream |
| `keystroke` | ci | composer keystroke → paint latency | measure-latency, profile-typing, leak-typing |
| `transcript` | ci | large-transcript mount + paint cost | (new) |
| `render-churn` | ci | per-component render attribution + store churn while N tabs stream | (new) |
| `cold-start` | cold | launch → CDP → driver → first paint (fresh spawn/run) | (new) |
| `first-token` | backend | Enter → first assistant token painted (TTFT) | (new) |
| `submit` | backend | Enter → cleared → user msg painted, scroll jump | measure-submit, measure-jump |

View File

@ -6,6 +6,7 @@ import firstToken from './first-token.mjs'
import keystroke from './keystroke.mjs'
import multitab from './multitab.mjs'
import profileSwitch from './profile-switch.mjs'
import renderChurn from './render-churn.mjs'
import sessionSwitch from './session-switch.mjs'
import stream from './stream.mjs'
import streamHistory from './stream-history.mjs'
@ -18,6 +19,7 @@ export const SCENARIOS = {
[keystroke.name]: keystroke,
[transcript.name]: transcript,
[multitab.name]: multitab,
[renderChurn.name]: renderChurn,
[coldStart.name]: coldStart,
[firstToken.name]: firstToken,
[submit.name]: submit,

View File

@ -0,0 +1,217 @@
// Render churn during multi-tab streaming: WHAT re-rendered and WHY, and which
// store published the update. Frame pacing (see `multitab`) tells you the cost;
// this tells you the cause.
//
// Drives the same synthetic pipeline as `multitab` — publishSessionState per
// session per flush via `__HERMES_SESSION_TILES__`, no backend, no credits —
// then reads the dev-only counters installed by `src/debug/`:
//
// window.__RENDER_COUNTS__ — per-component renders, attributed to
// props / hook state / parent-only ("wasted")
// window.__ATOM_CHURN__ — per-store notifications, listener fan-out, and
// notifications whose value was deep-equal to the
// previous one ("wasted")
//
// The headline metric is `sidebar_renders`: how many times the sidebar tree
// re-rendered while agents were typing in other tabs. It should be 0.
//
// node scripts/perf/run.mjs render-churn --spawn [--tiles 5] [--tokens 240]
import { sleep } from '../lib/cdp.mjs'
/** Components that make up the sidebar tree. A render of any of these while
* a background tab streams is work the user cannot see. */
const SIDEBAR_COMPONENTS = [
'ChatSidebar',
'SidebarSurface',
'SessionRow',
'SessionsSection',
'CronJobsSection',
'ProfileSwitcher',
'VirtualSessionList',
'WorkspaceGroup',
'OverviewRow',
'SessionStatusDot'
]
/** Page-side setup: open `tiles` session tiles, seed each with a transcript.
* Mirrors `multitab.mjs` so the two scenarios measure the same workload. */
const setup = (tiles, seedTurns) => `
(() => {
const hook = window.__HERMES_SESSION_TILES__
if (!hook) return 'no-hook'
if (!window.__RENDER_COUNTS__) return 'no-render-counter'
if (!window.__ATOM_CHURN__) return 'no-atom-churn'
const turn = (sid, i) => ([
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
parts: [{ type: 'text', text: 'Review question ' + i + ': does the diff handle the error path?' }] },
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- The catch block drops the error.\\n- Retries are unbounded.\\n' }] }
])
const state = (sid) => {
const messages = []
for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
parts: [{ type: 'text', text: '' }] })
return {
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
needsInput: false, turnStartedAt: Date.now(), usage: null
}
}
window.__RC__ = { ids: [], timer: null }
for (let n = 1; n <= ${tiles}; n++) {
const sid = 'churn-tile-' + n
const rid = 'churn-rt-' + n
window.__RC__.ids.push({ sid, rid })
hook.open(sid, 'center')
hook.patch(sid, { runtimeId: rid })
hook.publish(rid, state(sid))
}
return 'ok'
})()
`
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
/** Grow every tile's streaming tail by `chunk` each `intervalMs`, through the
* same publish path the gateway's delta flush uses. */
const drive = (chunk, intervalMs, totalTokens) => `
(() => {
const hook = window.__HERMES_SESSION_TILES__
let pushed = 0
const tick = () => {
const states = hook.states()
for (const { rid } of window.__RC__.ids) {
const prev = states[rid]
if (!prev) continue
const messages = prev.messages.map(m => {
if (m.id !== prev.streamId) return m
const head = m.parts.slice(0, -1)
const last = m.parts[m.parts.length - 1]
return { ...m, parts: [...head, { type: 'text', text: last.text + ${JSON.stringify(chunk)} }] }
})
hook.publish(rid, { ...prev, messages })
}
pushed += 1
if (pushed < ${totalTokens}) window.__RC__.timer = setTimeout(tick, ${intervalMs})
else window.__RC__.done = true
}
window.__RC__.timer = setTimeout(tick, ${intervalMs})
return 'driving'
})()
`
const START = `
(() => {
window.__RENDER_COUNTS__.start()
window.__ATOM_CHURN__.start()
return 'recording'
})()
`
const COLLECT = `
(() => {
window.__RENDER_COUNTS__.stop()
window.__ATOM_CHURN__.stop()
return JSON.stringify({
commits: window.__RENDER_COUNTS__.commits(),
renders: window.__RENDER_COUNTS__.report(200),
atoms: window.__ATOM_CHURN__.report(200)
})
})()
`
const CLEANUP = `
(() => {
if (window.__RC__) {
clearTimeout(window.__RC__.timer)
for (const { sid, rid } of window.__RC__.ids) {
const states = window.__HERMES_SESSION_TILES__.states()
window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null })
window.__HERMES_SESSION_TILES__.close(sid)
}
window.__RC__ = null
}
window.__RENDER_COUNTS__.clear()
window.__ATOM_CHURN__.clear()
return 'cleaned'
})()
`
export default {
name: 'render-churn',
tier: 'ci',
description: 'N streaming tabs: per-component render attribution + store churn.',
async run(cdp, opts = {}) {
const tiles = Number(opts.tiles ?? 5)
const seedTurns = Number(opts.turns ?? 20)
const tokens = Number(opts.tokens ?? 240)
// Matches STREAM_DELTA_FLUSH_MS — one publish per session per real flush.
const intervalMs = Number(opts.intervalMs ?? 33)
const chunk = opts.chunk ?? 'A streamed review sentence with **bold** and `code`.\n\n'
await cdp.send('Runtime.enable')
const ok = await cdp.eval(setup(tiles, seedTurns))
if (ok !== 'ok') {
throw new Error(
`render-churn setup failed (${ok}) — needs a dev renderer with src/debug installed ` +
'(the counters are aliased out of production builds unless VITE_PERF_PROBE=1).'
)
}
// Mount every tab (keep-alive mounts on first activation), then settle.
for (let n = 1; n <= tiles; n++) {
await cdp.eval(reveal(`churn-tile-${n}`))
await sleep(350)
}
await sleep(1000)
// Start recording AFTER mount so the numbers are steady-state streaming
// cost, not one-off mount cost.
await cdp.eval(START)
await cdp.eval(drive(chunk, intervalMs, tokens))
await sleep(tokens * intervalMs + 1500)
const data = JSON.parse(await cdp.eval(COLLECT))
await cdp.eval(CLEANUP)
const byName = new Map(data.renders.map(r => [r.name, r]))
const sidebarRows = SIDEBAR_COMPONENTS.map(n => byName.get(n)).filter(Boolean)
const sidebarRenders = sidebarRows.reduce((a, r) => a + r.renders, 0)
const sidebarWasted = sidebarRows.reduce((a, r) => a + r.wasted, 0)
const totalRenders = data.renders.reduce((a, r) => a + r.renders, 0)
const totalWasted = data.renders.reduce((a, r) => a + r.wasted, 0)
const atomWasted = data.atoms.reduce((a, r) => a + r.wasted, 0)
return {
metrics: {
// The hypothesis, as a number: sidebar renders while background tabs
// stream. Should be 0.
sidebar_renders: sidebarRenders,
sidebar_wasted: sidebarWasted,
// Renders with no changed props and no changed hook state — pure
// parent-driven work, across the whole tree.
wasted_renders: totalWasted,
total_renders: totalRenders,
commits: data.commits,
// Store notifications that published a value equal to the last one.
wasted_notifies: atomWasted
},
detail: {
tiles,
tokens,
sidebar: sidebarRows,
topRenders: data.renders.slice(0, 15),
topAtoms: data.atoms.slice(0, 15)
}
}
}
}

View File

@ -0,0 +1,111 @@
# 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:
```js
__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):
```bash
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.

View File

@ -0,0 +1,132 @@
// Dev-only nanostores churn counter — the state-side companion to
// `render-counter.ts`. Render counts tell you WHAT re-rendered; this tells you
// WHICH ATOM pushed the update, and whether the push was worth making.
//
// The headline metric is `wasted`: notifications whose new value is deep-equal
// to the old one. `@nanostores/react`'s `useStore` bails out on REFERENCE
// equality only (`snapshotRef.current === value`), so publishing a fresh array
// or object with identical contents re-renders every subscriber for nothing.
// That is the exact failure `apps/desktop/AGENTS.md` names: "Preserve reference
// identity on no-ops."
//
// `listeners` is read from the store's own `lc` (listener count) at notify
// time, because `onNotify` fires even when a store has zero subscribers — a
// raw notify count over-reports. `notifies x listeners` is the real fan-out.
import { onNotify, type Store } from 'nanostores'
export interface AtomChurn {
/** Times the store notified its listeners. */
notifies: number
/**
* ...of those, how many pushed a value deep-equal to the previous one.
* Pure waste: every subscriber re-rendered and nothing actually changed.
*/
wasted: number
/** Peak listener count seen at notify time (`store.lc`). */
peakListeners: number
/** Sum of listeners across notifications — the true re-render fan-out. */
fanout: number
}
const churn = new Map<string, AtomChurn>()
const unsubscribes: Array<() => void> = []
let recording = false
const blank = (): AtomChurn => ({ fanout: 0, notifies: 0, peakListeners: 0, wasted: 0 })
/** Structural equality, depth-capped so a long transcript array doesn't make
* the instrumentation itself the bottleneck. Beyond the cap we compare by
* reference, which under-reports waste rather than inventing it. */
function equal(a: unknown, b: unknown, depth = 0): boolean {
if (Object.is(a, b)) {
return true
}
if (depth > 3 || typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
return false
}
if (Array.isArray(a) !== Array.isArray(b)) {
return false
}
const ka = Object.keys(a as object)
const kb = Object.keys(b as object)
if (ka.length !== kb.length) {
return false
}
return ka.every(k => equal((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k], depth + 1))
}
/** Watch one store. Call at module scope for each atom you want attributed. */
export function watchAtom(name: string, store: Store) {
const off = onNotify(store, ({ oldValue }) => {
if (!recording) {
return
}
const entry = churn.get(name) ?? blank()
// `lc` is nanostores' listener count — see node_modules/nanostores/atom/index.js.
const listeners = (store as unknown as { lc?: number }).lc ?? 0
const next = (store as unknown as { value?: unknown }).value
entry.notifies += 1
entry.fanout += listeners
entry.peakListeners = Math.max(entry.peakListeners, listeners)
if (equal(oldValue, next)) {
entry.wasted += 1
}
churn.set(name, entry)
})
unsubscribes.push(off)
return off
}
/** Rows sorted by wasted notifications, then fan-out — the fix list, in order. */
function report(limit = 40) {
return [...churn.entries()]
.map(([name, c]) => ({ name, ...c }))
.sort((a, b) => b.wasted - a.wasted || b.fanout - a.fanout)
.slice(0, limit)
}
declare global {
interface Window {
__ATOM_CHURN__?: {
churn: Map<string, AtomChurn>
clear: () => void
start: () => void
stop: () => void
recording: () => boolean
report: (limit?: number) => Array<AtomChurn & { name: string }>
get: (name: string) => AtomChurn | undefined
/** Names of every watched store, whether or not it has notified. */
watched: () => string[]
}
}
}
if (typeof window !== 'undefined' && !window.__ATOM_CHURN__) {
window.__ATOM_CHURN__ = {
churn,
clear: () => churn.clear(),
get: name => churn.get(name),
recording: () => recording,
report,
start: () => {
churn.clear()
recording = true
},
stop: () => {
recording = false
},
watched: () => [...churn.keys()]
}
}

View File

@ -0,0 +1,8 @@
// Production stand-in for `dev-only.ts`. `vite.config.ts` aliases the dev
// entry to this module for any build that isn't the dev server, so the
// diagnostics graph — and bippy with it — never reaches a shipped renderer.
//
// Keep this file free of imports. It exists precisely so the production
// bundle contains nothing from `debug/`.
export {}

View File

@ -0,0 +1,19 @@
// Dev-only diagnostics entry. Statically imported from `main.tsx` ABOVE the
// `react-dom` import — that ordering is load-bearing and non-negotiable.
//
// react-dom captures the devtools hook at MODULE INIT, not at `createRoot`.
// Verified: installing bippy after react-dom has evaluated yields
// `renderers=0` and `commits=0` — the hook object exists but react-dom never
// registered with it. A dynamic `import()` here would resolve a microtask
// after main.tsx's static graph (react-dom included) had already evaluated,
// so it must be a plain static import chain, whose evaluation order ESM
// guarantees.
//
// Production builds don't tree-shake this away (a static side-effect import
// can't be eliminated) — instead `vite.config.ts` aliases this module to
// `dev-only.noop.ts` whenever the build isn't serving dev, so neither bippy
// nor the counters reach a shipped renderer.
import './index'
export {}

View File

@ -0,0 +1,31 @@
// Dev-only state diagnostics: one import, two counters.
//
// window.__RENDER_COUNTS__ — what re-rendered, and why (props/state/parent)
// window.__ATOM_CHURN__ — which store published it, and whether it mattered
//
// Imported FIRST in `main.tsx`, before `react-dom`. That ordering is
// load-bearing: react-dom decides at module-init whether a devtools hook
// exists, so bippy must install during THIS module's evaluation. A dynamic
// `import()` from main.tsx would resolve a microtask too late and every commit
// would go unseen — hence a plain static import chain, whose evaluation order
// ESM guarantees.
//
// Both counters are inert until `start()` is called, so the idle cost in dev is
// a single no-op branch per commit / per notify.
//
// Typical session, from the devtools console:
//
// __RENDER_COUNTS__.start(); __ATOM_CHURN__.start()
// // ...let an agent stream 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: components that re-rendered
// with no changed input, and stores that published a value equal to the last.
import './render-counter'
import { watchSessionAtoms } from './watched-atoms'
watchSessionAtoms()

View File

@ -0,0 +1,179 @@
// Dev-only render counter — answers "what actually re-rendered, and why?".
//
// Loaded from `main.tsx` BEFORE `react-dom` (see the import-order note there).
// That ordering is load-bearing: react-dom decides at module-init whether a
// devtools hook exists, so installing after it has already initialised leaves
// `bippy._renderers` empty and every commit goes unseen.
//
// Why not `<Profiler>`: React invokes `onRender` for EVERY Profiler in a
// committed tree, including subtrees that bailed out. Counting those callbacks
// "proves" the sidebar re-rendered when it did not. `actualDuration` is not a
// discriminator either — a bailed-out subtree still reports a small nonzero
// duration. `didFiberRender` is the honest signal.
//
// Why not react-scan: its `lite` subpath is a thin wrapper over bippy, while
// the package pulls ~217 transitive deps (babel, preact) and floats
// `react-grab`/`react-doctor` on `latest`, which makes installs
// non-reproducible and breaks Vite with a JSON import-attribute error.
import { didFiberRender, type Fiber, getDisplayName, instrument, isCompositeFiber, traverseRenderedFibers } from 'bippy'
/** Why a component re-rendered, attributed per commit. */
export interface RenderRecord {
/** Commits in which this component actually re-rendered (mount excluded). */
renders: number
/** ...of those, how many had at least one changed prop reference. */
propsChanged: number
/** ...of those, how many had changed hook state (useState/useMemo/store). */
stateChanged: number
/**
* ...of those, how many had NEITHER changed props nor changed state. These
* re-rendered purely because a parent did — the wasted work a `memo()` or a
* narrower store subscription would eliminate.
*/
wasted: number
/** Sum of `actualDuration` across counted renders, in ms. */
totalMs: number
}
const counts = new Map<string, RenderRecord>()
let commits = 0
let recording = false
const blank = (): RenderRecord => ({
propsChanged: 0,
renders: 0,
stateChanged: 0,
totalMs: 0,
wasted: 0
})
/** Did any prop's reference identity change between the two fiber versions? */
function propsChanged(fiber: Fiber): boolean {
const prev = fiber.alternate?.memoizedProps as Record<string, unknown> | null | undefined
const next = fiber.memoizedProps as Record<string, unknown> | null | undefined
if (!prev || !next) {
return false
}
for (const key of Object.keys(next)) {
if (!Object.is(prev[key], next[key])) {
return true
}
}
return Object.keys(prev).length !== Object.keys(next).length
}
/** Did any hook's memoizedState change? Covers useState, useSyncExternalStore
* (so nanostores `useStore`), useMemo, and useReducer alike. */
function stateChanged(fiber: Fiber): boolean {
let next: Fiber['memoizedState'] | null | undefined = fiber.memoizedState
let prev: Fiber['memoizedState'] | null | undefined = fiber.alternate?.memoizedState
while (next && prev) {
if (!Object.is(next.memoizedState, prev.memoizedState)) {
return true
}
next = next.next
prev = prev.next
}
return false
}
function record(fiber: Fiber) {
const name = getDisplayName(fiber)
if (!name) {
return
}
const entry = counts.get(name) ?? blank()
const props = propsChanged(fiber)
const state = stateChanged(fiber)
entry.renders += 1
entry.totalMs += fiber.actualDuration ?? 0
if (props) {
entry.propsChanged += 1
}
if (state) {
entry.stateChanged += 1
}
if (!props && !state) {
entry.wasted += 1
}
counts.set(name, entry)
}
/** Rows sorted by wasted renders, then total renders — the fix list, in order. */
function report(limit = 40) {
return [...counts.entries()]
.map(([name, r]) => ({ name, ...r, totalMs: Math.round(r.totalMs * 100) / 100 }))
.sort((a, b) => b.wasted - a.wasted || b.renders - a.renders)
.slice(0, limit)
}
declare global {
interface Window {
__RENDER_COUNTS__?: {
/** Per-component render attribution since the last `clear()`. */
counts: Map<string, RenderRecord>
/** Commits observed since the last `clear()`. */
commits: () => number
clear: () => void
/** Start counting. Cheap no-op until called — zero cost while idle. */
start: () => void
stop: () => void
recording: () => boolean
/** Sorted worst-offenders table; `console.table`-friendly. */
report: (limit?: number) => Array<RenderRecord & { name: string }>
/** Attribution for one component by display name. */
get: (name: string) => RenderRecord | undefined
}
}
}
if (typeof window !== 'undefined' && !window.__RENDER_COUNTS__) {
instrument({
onCommitFiberRoot(_id, root) {
if (!recording) {
return
}
commits += 1
traverseRenderedFibers(root, fiber => {
if (isCompositeFiber(fiber) && didFiberRender(fiber)) {
record(fiber)
}
})
}
})
window.__RENDER_COUNTS__ = {
clear: () => {
counts.clear()
commits = 0
},
commits: () => commits,
counts,
get: name => counts.get(name),
recording: () => recording,
report,
start: () => {
counts.clear()
commits = 0
recording = true
},
stop: () => {
recording = false
}
}
}

View File

@ -0,0 +1,81 @@
// Dev-only: registers the stores worth attributing during a streaming turn.
//
// Deliberately NOT every atom in the app — a churn counter that reports 200
// rows is as useless as none. These are the stores on or adjacent to the
// streaming hot path, plus the sidebar's inputs, so a recording answers one
// question directly: while an agent is typing, what is being published, and
// who re-renders because of it?
import { $projects, $projectTree } from '@/store/projects'
import {
$activeSessionId,
$awaitingResponse,
$busy,
$cronSessions,
$currentCwd,
$currentUsage,
$gatewayState,
$messages,
$messagingSessions,
$selectedStoredSessionId,
$sessions,
$sessionsLoading
} from '@/store/session'
import {
$attentionSessionIds,
$focusedRuntimeId,
$focusedSessionState,
$focusedStoredSessionId,
$sessionStates,
$sessionTiles,
$stalledSessionIds,
$workingSessionIds
} from '@/store/session-states'
import { watchAtom } from './atom-churn'
/** Streaming hot path — written per token / per delta flush. */
const HOT = {
$awaitingResponse,
$busy,
// The global mirror the workspace pane paints from.
$messages,
// Republished on EVERY delta: the per-session source of truth.
$sessionStates
}
/** Derived from the hot path. These SHOULD stay quiet during a turn — any
* notification here is a candidate for the "wasted" column. */
const DERIVED = {
$attentionSessionIds,
$currentUsage,
$focusedRuntimeId,
$focusedSessionState,
$focusedStoredSessionId,
$stalledSessionIds,
$workingSessionIds
}
/** Sidebar inputs. Expected to be cold during a turn — if any of these notify
* while an agent is typing, that is the bug. */
const SIDEBAR = {
$activeSessionId,
$cronSessions,
$currentCwd,
$gatewayState,
$messagingSessions,
$projects,
$projectTree,
$selectedStoredSessionId,
$sessions,
$sessionsLoading,
$sessionTiles
}
export function watchSessionAtoms() {
for (const group of [HOT, DERIVED, SIDEBAR]) {
for (const [name, store] of Object.entries(group)) {
watchAtom(name, store)
}
}
}

View File

@ -1,6 +1,13 @@
import './styles.css'
// Side-effect: applies the persisted window translucency on load.
import './store/translucency'
// Dev-only render/state churn counters. MUST precede the `react-dom` import
// below: react-dom captures the devtools hook at module init, so bippy has to
// install during THIS import's evaluation or every commit goes unseen
// (verified — a late install reports renderers=0, commits=0). `vite.config.ts`
// aliases this specifier to a no-op module for non-dev builds, so neither the
// counters nor bippy reach a shipped renderer.
import '@/debug/dev-only'
import { QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'

View File

@ -25,7 +25,18 @@ const fsAllow = [
)
]
export default defineConfig({
// The dev-only render/state churn counters (src/debug) must be imported
// STATICALLY above react-dom — react-dom captures the devtools hook at module
// init, so a dynamic import lands too late and observes zero commits. A static
// side-effect import can't be tree-shaken, so instead the whole graph is
// aliased out of any non-dev build. `command === 'serve'` covers `vite dev`;
// the perf harness opts a production build back in with VITE_PERF_PROBE=1.
const debugEntry = (command: string, env: Record<string, string>) =>
command === 'serve' || env.VITE_PERF_PROBE === '1'
? path.resolve(__dirname, './src/debug/dev-only.ts')
: path.resolve(__dirname, './src/debug/dev-only.noop.ts')
export default defineConfig(({ command }) => ({
base: './',
plugins: [react(), tailwindcss()],
css: {
@ -57,6 +68,7 @@ export default defineConfig({
},
resolve: {
alias: {
'@/debug/dev-only': debugEntry(command, process.env as Record<string, string>),
'@': path.resolve(__dirname, './src'),
'@hermes/plugin-sdk': path.resolve(__dirname, './src/sdk/index.ts'),
'@hermes/shared/billing': path.resolve(__dirname, '../shared/src/billing-types.ts'),
@ -80,4 +92,4 @@ export default defineConfig({
host: '127.0.0.1',
port: 4174
}
})
}))

13
package-lock.json generated
View File

@ -184,7 +184,8 @@
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5",
"wait-on": "^9.0.5"
"wait-on": "^9.0.5",
"bippy": "0.5.43"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@ -19748,6 +19749,16 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/bippy": {
"version": "0.5.43",
"resolved": "https://registry.npmjs.org/bippy/-/bippy-0.5.43.tgz",
"integrity": "sha512-Tvu7b1M7+d8b9/YHaCeODEsi2CgbuoBql+dWSBrNnCuqJ1gMUeY3i0r+319hvjjl5GVBP6FFWxrKnq3fhZER0w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"react": ">=17.0.0"
}
}
}
}