Merge pull request #71925 from NousResearch/bb/desktop-state-diagnostics
feat(desktop): state diagnostics — render + store churn counters
This commit is contained in:
commit
41f2196c53
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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()]
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue