From 44211f36082a5005cbf909f9312b5c9c5372775b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 06:10:54 -0500 Subject: [PATCH 1/2] feat(desktop): dev-only render + store churn counters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. 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. --- apps/desktop/package.json | 1 + apps/desktop/src/debug/README.md | 111 ++++++++++++++ apps/desktop/src/debug/atom-churn.ts | 132 +++++++++++++++++ apps/desktop/src/debug/dev-only.noop.ts | 8 + apps/desktop/src/debug/dev-only.ts | 19 +++ apps/desktop/src/debug/index.ts | 31 ++++ apps/desktop/src/debug/render-counter.ts | 179 +++++++++++++++++++++++ apps/desktop/src/debug/watched-atoms.ts | 81 ++++++++++ apps/desktop/src/main.tsx | 7 + apps/desktop/vite.config.ts | 16 +- package-lock.json | 13 +- 11 files changed, 595 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/debug/README.md create mode 100644 apps/desktop/src/debug/atom-churn.ts create mode 100644 apps/desktop/src/debug/dev-only.noop.ts create mode 100644 apps/desktop/src/debug/dev-only.ts create mode 100644 apps/desktop/src/debug/index.ts create mode 100644 apps/desktop/src/debug/render-counter.ts create mode 100644 apps/desktop/src/debug/watched-atoms.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0d2c07756af8e..1e3a6e10ceeea 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/src/debug/README.md b/apps/desktop/src/debug/README.md new file mode 100644 index 0000000000000..5cae200efd1ff --- /dev/null +++ b/apps/desktop/src/debug/README.md @@ -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. + +**`` 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. diff --git a/apps/desktop/src/debug/atom-churn.ts b/apps/desktop/src/debug/atom-churn.ts new file mode 100644 index 0000000000000..4c78a012e2863 --- /dev/null +++ b/apps/desktop/src/debug/atom-churn.ts @@ -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() +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)[k], (b as Record)[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 + clear: () => void + start: () => void + stop: () => void + recording: () => boolean + report: (limit?: number) => Array + 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()] + } +} diff --git a/apps/desktop/src/debug/dev-only.noop.ts b/apps/desktop/src/debug/dev-only.noop.ts new file mode 100644 index 0000000000000..9c4595ee149fa --- /dev/null +++ b/apps/desktop/src/debug/dev-only.noop.ts @@ -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 {} diff --git a/apps/desktop/src/debug/dev-only.ts b/apps/desktop/src/debug/dev-only.ts new file mode 100644 index 0000000000000..e052c2f14ed82 --- /dev/null +++ b/apps/desktop/src/debug/dev-only.ts @@ -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 {} diff --git a/apps/desktop/src/debug/index.ts b/apps/desktop/src/debug/index.ts new file mode 100644 index 0000000000000..365cc23fdbfa9 --- /dev/null +++ b/apps/desktop/src/debug/index.ts @@ -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() diff --git a/apps/desktop/src/debug/render-counter.ts b/apps/desktop/src/debug/render-counter.ts new file mode 100644 index 0000000000000..30dcae9146d9d --- /dev/null +++ b/apps/desktop/src/debug/render-counter.ts @@ -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 ``: 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() +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 | null | undefined + const next = fiber.memoizedProps as Record | 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 + /** 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 + /** 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 + } + } +} diff --git a/apps/desktop/src/debug/watched-atoms.ts b/apps/desktop/src/debug/watched-atoms.ts new file mode 100644 index 0000000000000..ebcb2724b7803 --- /dev/null +++ b/apps/desktop/src/debug/watched-atoms.ts @@ -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) + } + } +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index b1dd657655ba2..02cd6dfca36ec 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -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' diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 2b0685c9a0b00..4a58133b8bfb5 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -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) => + 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), '@': 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 } -}) +})) diff --git a/package-lock.json b/package-lock.json index 0ee09bbcb2943..2848a8c89af31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } From 37ac8ae76a165a4e5a27582b917a9b91a6b91ea7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 06:11:00 -0500 Subject: [PATCH 2/2] feat(desktop): render-churn perf scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives the same synthetic multi-tab streaming workload as `multitab` (publishSessionState per session per flush, no backend, no credits) but reports render attribution instead of frame pacing — sidebar_renders, wasted_renders, and wasted_notifies. Answers 'does the sidebar re-render while an agent is typing' directly. --- apps/desktop/scripts/perf/README.md | 1 + apps/desktop/scripts/perf/scenarios/index.mjs | 2 + .../scripts/perf/scenarios/render-churn.mjs | 217 ++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 apps/desktop/scripts/perf/scenarios/render-churn.mjs diff --git a/apps/desktop/scripts/perf/README.md b/apps/desktop/scripts/perf/README.md index e8cfc57523125..4c74de4983dc8 100644 --- a/apps/desktop/scripts/perf/README.md +++ b/apps/desktop/scripts/perf/README.md @@ -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 | diff --git a/apps/desktop/scripts/perf/scenarios/index.mjs b/apps/desktop/scripts/perf/scenarios/index.mjs index d69ecce2fbcfc..d92526b0fcfab 100644 --- a/apps/desktop/scripts/perf/scenarios/index.mjs +++ b/apps/desktop/scripts/perf/scenarios/index.mjs @@ -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, diff --git a/apps/desktop/scripts/perf/scenarios/render-churn.mjs b/apps/desktop/scripts/perf/scenarios/render-churn.mjs new file mode 100644 index 0000000000000..d1e940cbed7ed --- /dev/null +++ b/apps/desktop/scripts/perf/scenarios/render-churn.mjs @@ -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) + } + } + } +}