From 31d49f0dfc67e5b4d68a5ba6c3422c10864d2c5d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 18:58:30 -0500 Subject: [PATCH 1/4] perf(desktop): share one ResizeObserver instead of one per consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CDP trace of one sash drag settled what the render counters could not. I had assumed the remaining cost was layout/paint; it was not: script 6770ms | style 1866ms | layout 71ms Layout was never the problem. The top attributable callsite in our own code was use-resize-observer.ts at 977ms. Counting the callbacks named the mechanism exactly: 8,620 ResizeObserver instances constructed, and during a 40-move drag 2,600 callbacks each carrying exactly ONE entry — 65 separate callbacks per pointermove. Every consumer owned a private observer, so N elements resizing under a common ancestor meant N trips through the observer machinery instead of one batched delivery. With five mounted tiles that is ~100 user bubbles, each with its own observer, all woken by a width change. One shared observer with a WeakMap of target -> handlers. Callers keep their exact contract: a handler observing several elements is still invoked once with all of its entries, and unobserve happens when the last handler for an element goes away. Verified by trace, before -> after: use-resize-observer 977ms -> 42.5ms (-96%) style recalc 1866ms -> 1145ms (-39%) total script 6770ms -> 3929ms (-42%) Callback count 2,600 -> 43: one delivery per frame instead of 65. Adds the two probes that found it. diag-drag-trace.mjs takes a real timeline trace and prints the style/layout/script split plus the top script callsites — that split is what disproved the layout theory. diag-ro-storm.mjs counts RO callbacks vs entries, which is what distinguished 'a few expensive calls' from 'very many cheap ones'. --- apps/desktop/scripts/diag-drag-trace.mjs | 217 ++++++++++++++++++ apps/desktop/scripts/diag-ro-storm.mjs | 170 ++++++++++++++ apps/desktop/src/hooks/use-resize-observer.ts | 100 +++++++- 3 files changed, 478 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/scripts/diag-drag-trace.mjs create mode 100644 apps/desktop/scripts/diag-ro-storm.mjs diff --git a/apps/desktop/scripts/diag-drag-trace.mjs b/apps/desktop/scripts/diag-drag-trace.mjs new file mode 100644 index 000000000000..01c4527fc067 --- /dev/null +++ b/apps/desktop/scripts/diag-drag-trace.mjs @@ -0,0 +1,217 @@ +// What is the drag actually spending time on? +// +// The render counters proved React is no longer the cost (commits 83 -> 12 +// after the $layoutTree fix) yet drag fps stayed ~3 while p95 halved. That +// pattern says a fixed per-frame floor outside React. This takes a real CDP +// trace of one sash drag and prints the category split — Recalculate Style, +// Layout, Paint, Scripting — so the next fix targets the actual cost instead +// of the next plausible-looking thing. +// +// node scripts/diag-drag-trace.mjs [--port 9222] [--tiles 5] + +import { attach } from './perf/lib/launch.mjs' +import { sleep } from './perf/lib/cdp.mjs' + +const arg = (name, fallback) => { + const i = process.argv.indexOf(`--${name}`) + + return i === -1 ? fallback : process.argv[i + 1] +} + +const port = Number(arg('port', 9222)) +const TILES = Number(arg('tiles', 5)) +const TURNS = Number(arg('turns', 20)) + +const setup = ` + (() => { + const hook = window.__HERMES_SESSION_TILES__ + if (!hook) return 'no-hook' + const turn = (sid, i) => ([ + { id: sid + '-u' + i, role: 'user', timestamp: Date.now(), + parts: [{ type: 'text', text: 'Question ' + i }] }, + { id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false, + parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nProse with **bold** and \`code\`.\\n' }] } + ]) + window.__T__ = { ids: [] } + for (let n = 1; n <= ${TILES}; n++) { + const sid = 'trace-tile-' + n + const rid = 'trace-rt-' + n + const messages = [] + for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i)) + messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true, + parts: [{ type: 'text', text: 'Working.' }] }) + window.__T__.ids.push({ sid, rid }) + hook.open(sid, 'center') + hook.patch(sid, { runtimeId: rid }) + hook.publish(rid, { + 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 + }) + } + return 'ok' + })() +` + +const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})` + +// Drive the sash WITHOUT awaiting rAF: a slow app would stretch a rAF-paced +// loop and make the window itself a function of the slowness. Fixed wall-clock +// pacing keeps the trace window comparable run to run. +const DRAG = ` + (async () => { + const handle = document.querySelector('[role="separator"]') + if (!handle) return 'none' + const box = handle.getBoundingClientRect() + const y = box.top + box.height / 2 + const x0 = box.left + box.width / 2 + let x = x0 + const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 } + handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y })) + for (let i = 0; i < 40; i++) { + x += (i < 20 ? 3 : -3) + window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y })) + await new Promise(r => setTimeout(r, 16)) + } + window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y })) + return 'dragged' + })() +` + +const CLEANUP = ` + (() => { + if (window.__T__) { + for (const { sid, rid } of window.__T__.ids) { + const s = window.__HERMES_SESSION_TILES__.states() + window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null }) + window.__HERMES_SESSION_TILES__.close(sid) + } + window.__T__ = null + } + return 'cleaned' + })() +` + +const { cdp, teardown } = await attach({ port }) + +try { + await cdp.send('Runtime.enable') + + const ok = await cdp.eval(setup) + + if (ok !== 'ok') { + throw new Error(`setup failed: ${ok}`) + } + + for (let n = 1; n <= TILES; n++) { + await cdp.eval(reveal(`trace-tile-${n}`)) + await sleep(300) + } + + await sleep(1500) + + // Collect trace events for the drag window only. `cdp.on` is the client's + // only event API (no `once`), so completion is signalled through a flag. + const events = [] + let complete = false + cdp.on('Tracing.dataCollected', params => events.push(...(params.value ?? []))) + cdp.on('Tracing.tracingComplete', () => { + complete = true + }) + + await cdp.send('Tracing.start', { + transferMode: 'ReportEvents', + traceConfig: { includedCategories: ['devtools.timeline', 'blink.user_timing'] } + }) + + const dragged = await cdp.eval(DRAG) + + await cdp.send('Tracing.end') + + for (let waited = 0; !complete && waited < 10000; waited += 200) { + await sleep(200) + } + + await cdp.eval(CLEANUP) + + // Sum self-time per timeline category. Nested events would double-count, so + // attribute each event's duration minus the duration of its direct children. + const INTERESTING = new Set([ + 'UpdateLayoutTree', // Recalculate Style + 'Layout', + 'Paint', + 'PaintImage', + 'Layerize', + 'UpdateLayer', + 'CompositeLayers', + 'FunctionCall', + 'EvaluateScript', + 'TimerFire', + 'EventDispatch', + 'HitTest', + 'ParseHTML', + 'CommitLoad' + ]) + + const totals = new Map() + let traced = 0 + + for (const e of events) { + if (e.ph !== 'X' || typeof e.dur !== 'number') { + continue + } + + traced += 1 + const name = e.name + + if (!INTERESTING.has(name)) { + continue + } + + totals.set(name, (totals.get(name) ?? 0) + e.dur / 1000) + } + + console.log(`drag=${dragged} trace events=${events.length} (complete=${traced})\n`) + console.log('TIMELINE COST (ms, total duration by event):') + + const rows = [...totals.entries()].sort((a, b) => b[1] - a[1]) + + if (rows.length === 0) { + console.log(' (no timeline events — category filter or tracing domain unavailable)') + } + + for (const [name, ms] of rows) { + console.log(` ${name.padEnd(20)} ${ms.toFixed(1)}ms`) + } + + const style = totals.get('UpdateLayoutTree') ?? 0 + const layout = totals.get('Layout') ?? 0 + const script = (totals.get('FunctionCall') ?? 0) + (totals.get('EvaluateScript') ?? 0) + (totals.get('TimerFire') ?? 0) + + console.log(`\nVERDICT: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms`) + + // Script dominates -> name the functions. Timeline FunctionCall events carry + // the callsite in args.data, so the top offenders can be attributed without + // a separate CPU profile. + const byFn = new Map() + + for (const e of events) { + if (e.ph !== 'X' || e.name !== 'FunctionCall' || typeof e.dur !== 'number') { + continue + } + + const d = e.args?.data ?? {} + const key = `${d.functionName || '(anonymous)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}` + byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000) + } + + console.log('\nTOP SCRIPT CALLSITES (ms):') + + for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15)) { + console.log(` ${ms.toFixed(1).padStart(8)} ${name}`) + } +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-ro-storm.mjs b/apps/desktop/scripts/diag-ro-storm.mjs new file mode 100644 index 000000000000..f405aa65cd12 --- /dev/null +++ b/apps/desktop/scripts/diag-ro-storm.mjs @@ -0,0 +1,170 @@ +// How many ResizeObserver callbacks does one sash drag actually fire, and for +// how many DISTINCT elements? The trace named use-resize-observer.ts at 977ms +// but not whether that's a few expensive calls or a great many cheap ones — +// and the fix differs completely between those. +// +// node scripts/diag-ro-storm.mjs [--port 9222] [--tiles 5] + +import { attach } from './perf/lib/launch.mjs' +import { sleep } from './perf/lib/cdp.mjs' + +const arg = (name, fallback) => { + const i = process.argv.indexOf(`--${name}`) + + return i === -1 ? fallback : process.argv[i + 1] +} + +const port = Number(arg('port', 9222)) +const TILES = Number(arg('tiles', 5)) +const TURNS = Number(arg('turns', 20)) + +const setup = ` + (() => { + const hook = window.__HERMES_SESSION_TILES__ + if (!hook) return 'no-hook' + const turn = (sid, i) => ([ + { id: sid + '-u' + i, role: 'user', timestamp: Date.now(), + parts: [{ type: 'text', text: 'Question ' + i + ' about the diff and its error path.' }] }, + { id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false, + parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nProse with **bold** and \`code\`.\\n' }] } + ]) + window.__R__ = { ids: [] } + for (let n = 1; n <= ${TILES}; n++) { + const sid = 'ro-tile-' + n + const rid = 'ro-rt-' + n + const messages = [] + for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i)) + messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true, + parts: [{ type: 'text', text: 'Working.' }] }) + window.__R__.ids.push({ sid, rid }) + hook.open(sid, 'center') + hook.patch(sid, { runtimeId: rid }) + hook.publish(rid, { + 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 + }) + } + return 'ok' + })() +` + +const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})` + +// Patch ResizeObserver to count callbacks + distinct observed targets, then +// drag and report. Counting happens in the page so nothing crosses CDP per call. +// +// NOTE: the app's shared observer (hooks/use-resize-observer.ts) is created +// lazily on first use, so this patch must be installed BEFORE any surface +// mounts — otherwise the shared instance is a native one this wrapper never +// sees and every counter reads zero. `constructed` is the tell: a run showing +// a handful of constructions and zero callbacks means the patch landed late, +// not that the app stopped observing. +const INSTRUMENT = ` + (() => { + if (window.__ROSTATS__) return 'already' + const Native = window.ResizeObserver + const stats = { constructed: 0, observed: 0, callbacks: 0, entries: 0, targets: new Set(), on: false } + window.__ROSTATS__ = stats + window.ResizeObserver = class extends Native { + constructor(cb) { + super((entries, obs) => { + if (stats.on) { + stats.callbacks += 1 + stats.entries += entries.length + for (const e of entries) stats.targets.add(e.target) + } + return cb(entries, obs) + }) + stats.constructed += 1 + } + observe(...args) { + stats.observed += 1 + return super.observe(...args) + } + } + return 'patched' + })() +` + +const DRAG = ` + (async () => { + const s = window.__ROSTATS__ + s.callbacks = 0; s.entries = 0; s.targets = new Set(); s.on = true + const handle = document.querySelector('[role="separator"]') + if (!handle) { s.on = false; return JSON.stringify({ error: 'no sash' }) } + const box = handle.getBoundingClientRect() + const y = box.top + box.height / 2 + const x0 = box.left + box.width / 2 + let x = x0 + const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 } + const t0 = performance.now() + handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y })) + for (let i = 0; i < 40; i++) { + x += (i < 20 ? 3 : -3) + window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y })) + await new Promise(r => setTimeout(r, 16)) + } + window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y })) + await new Promise(r => setTimeout(r, 300)) + s.on = false + return JSON.stringify({ + ms: Math.round(performance.now() - t0), + moves: 40, + constructed: s.constructed, + observed: s.observed, + callbacks: s.callbacks, + entries: s.entries, + distinctTargets: s.targets.size, + userBubbles: document.querySelectorAll('[data-slot="aui_user-message-root"]').length + }) + })() +` + +const CLEANUP = ` + (() => { + if (window.__R__) { + for (const { sid, rid } of window.__R__.ids) { + const s = window.__HERMES_SESSION_TILES__.states() + window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null }) + window.__HERMES_SESSION_TILES__.close(sid) + } + window.__R__ = null + } + return 'cleaned' + })() +` + +const { cdp, teardown } = await attach({ port }) + +try { + await cdp.send('Runtime.enable') + await cdp.eval(INSTRUMENT) + + const ok = await cdp.eval(setup) + + if (ok !== 'ok') { + throw new Error(`setup failed: ${ok}`) + } + + for (let n = 1; n <= TILES; n++) { + await cdp.eval(reveal(`ro-tile-${n}`)) + await sleep(300) + } + + await sleep(1500) + + const r = JSON.parse(await cdp.eval(DRAG)) + await cdp.eval(CLEANUP) + + console.log(JSON.stringify(r, null, 2)) + + if (r.moves) { + console.log(`\nper pointermove: ${(r.entries / r.moves).toFixed(1)} RO entries`) + console.log(`distinct elements resized: ${r.distinctTargets} (user bubbles in DOM: ${r.userBubbles})`) + } +} finally { + teardown?.() +} diff --git a/apps/desktop/src/hooks/use-resize-observer.ts b/apps/desktop/src/hooks/use-resize-observer.ts index 6ff08387ffcf..61cd950a3d7f 100644 --- a/apps/desktop/src/hooks/use-resize-observer.ts +++ b/apps/desktop/src/hooks/use-resize-observer.ts @@ -12,7 +12,65 @@ import { type RefObject, useLayoutEffect, useRef } from 'react' * instances mounting at once (every user bubble on a session switch), the * interleaved read→write→read pattern cascades into seconds of layout thrash. * Inside RO timing, layout is already clean and the same reads are ~free. + * + * ONE observer is shared by every caller. A private `new ResizeObserver` per + * hook instance means the browser delivers one callback PER CONSUMER when a + * common ancestor resizes, and each of those is a separate trip through the + * observer machinery. A single shared observer batches the same work into one + * delivery carrying many entries. + * + * Measured on a sash drag with five mounted session tiles (~100 user bubbles): + * 2,600 callbacks across 40 pointermoves — 65 separate callbacks per frame, + * each carrying exactly one entry — and 977ms of script time attributed to + * this file. Batching collapses that to one callback per frame. */ + +type Handler = (entries: readonly ResizeObserverEntry[]) => void + +/** Live target → handler routing for the shared observer. */ +const handlers = new WeakMap>() + +let shared: null | ResizeObserver = null + +function sharedObserver(): null | ResizeObserver { + if (typeof ResizeObserver === 'undefined') { + return null + } + + if (!shared) { + shared = new ResizeObserver(entries => { + // Group this delivery's entries by handler so a caller observing several + // elements is still invoked once, with all of its entries — the same + // contract a private observer gave it. + const byHandler = new Map() + + for (const entry of entries) { + const targets = handlers.get(entry.target) + + if (!targets) { + continue + } + + for (const handler of targets) { + const list = byHandler.get(handler) + + if (list) { + list.push(entry) + } else { + byHandler.set(handler, [entry]) + } + } + } + + for (const [handler, group] of byHandler) { + handler(group) + } + }) + } + + return shared +} + export function useResizeObserver( onResize: (entries: readonly ResizeObserverEntry[]) => void, ...refs: readonly RefObject[] @@ -21,14 +79,15 @@ export function useResizeObserver( refsRef.current = refs useLayoutEffect(() => { - if (typeof ResizeObserver === 'undefined') { + const observer = sharedObserver() + + if (!observer) { onResize([]) return } - const observer = new ResizeObserver(entries => onResize(entries)) - let observed = false + const observed: Element[] = [] for (const ref of refsRef.current) { const element = ref.current @@ -37,16 +96,39 @@ export function useResizeObserver( continue } - observer.observe(element) - observed = true + const existing = handlers.get(element) + + if (existing) { + existing.add(onResize) + } else { + handlers.set(element, new Set([onResize])) + // Only the first handler for an element needs to register it; the + // observer fires once per element regardless of how many care. + observer.observe(element) + } + + observed.push(element) } - if (!observed) { - observer.disconnect() - + if (observed.length === 0) { return } - return () => observer.disconnect() + return () => { + for (const element of observed) { + const set = handlers.get(element) + + if (!set) { + continue + } + + set.delete(onResize) + + if (set.size === 0) { + handlers.delete(element) + observer.unobserve(element) + } + } + } }, [onResize]) } From bbfc4df3577e115b46b3d4ebf3f87d50330db8a5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 19:05:21 -0500 Subject: [PATCH 2/4] perf(desktop): one app-level TooltipProvider, not one per Tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `Tip` carried its own `TooltipProvider`, and there are ~107 call sites. Each is a subtree that re-renders when anything above it does, so they dominated unrelated interactions: 52,784 TooltipProvider renders and 18.3s of component time in a single sash drag. Radix's provider holds only refs and stable callbacks (no reactive state) — hoisting one to the app root is what it is designed for. `Tooltip` still reads delayDuration/disableHoverableContent from context, and the per-Tip overrides are preserved. `Tip` keeps a local provider as a FALLBACK, chosen by context: a component rendered in isolation has no root provider and Radix throws "`Tooltip` must be used within `TooltipProvider`". Without this, 20 unit tests that render a single control fail. Inside the app the flag is always true, so the common path is a bare Tooltip. This is the shape the earlier lazy-mount attempt should have taken. That one deferred the Radix subtree until hover, which moved data-slot="tooltip-trigger" off the mounted DOM and broke 18 tests encoding that contract. Hoisting keeps the contract intact — every one of those tests passes unchanged. Measured on the same drag: TooltipProvider 52,784 renders / 18.3s -> gone from the table Primitive.div 40.5s -> 13.4s Popper 10.5s -> 2.7s Tooltip 15.7s -> 4.4s --- apps/desktop/src/components/ui/tooltip.tsx | 67 ++++++++++++++++++---- apps/desktop/src/main.tsx | 8 +++ 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/components/ui/tooltip.tsx b/apps/desktop/src/components/ui/tooltip.tsx index fe16e3b97cfc..226bd9472b3f 100644 --- a/apps/desktop/src/components/ui/tooltip.tsx +++ b/apps/desktop/src/components/ui/tooltip.tsx @@ -5,6 +5,10 @@ import { useI18n } from '@/i18n' import { useKeybindHint } from '@/lib/keybinds/use-keybind-hint' import { cn } from '@/lib/utils' +/** True inside `RootTooltipProvider`. `Tip` uses this to decide whether it + * needs to supply its own provider — see the note on `Tip`. */ +const HasTooltipProvider = React.createContext(false) + function TooltipProvider({ delayDuration = 0, // Tips are labels, not interactive surfaces. Hoverable content + Radix's @@ -105,21 +109,55 @@ interface TipProps extends Omit{children} } + const tip = ( + + {children} + {label} + + ) + + return provided ? tip : {tip} +} + +/** The app's single tooltip provider. Mounted once at the root so no `Tip` + * needs its own. Defaults match what `Tip` used to pass per instance. */ +function RootTooltipProvider({ children }: { children: React.ReactNode }) { return ( - - - {children} - {label} - - + + + {children} + + ) } @@ -163,4 +201,13 @@ function TipKeybindLabel({ actionId, text }: TipKeybindLabelProps) { return } -export { Tip, TipHintLabel, TipKeybindLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } +export { + RootTooltipProvider, + Tip, + TipHintLabel, + TipKeybindLabel, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 969b5c510d93..3d1c164b6dea 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -17,6 +17,7 @@ import { HashRouter } from 'react-router-dom' import App from './app' import { ErrorBoundary } from './components/error-boundary' import { HapticsProvider } from './components/haptics-provider' +import { RootTooltipProvider } from './components/ui/tooltip' import { I18nProvider } from './i18n' import { installClipboardShim } from './lib/clipboard' import { queryClient } from './lib/query-client' @@ -46,6 +47,12 @@ if (winParam === 'overlay') { + {/* ONE tooltip provider for the whole app. Every `Tip` used to + carry its own, and with ~107 call sites those subtrees + dominated unrelated interactions (52,784 TooltipProvider + renders in a single sash drag). Radix's provider holds only + refs and stable callbacks, so hoisting is what it's for. */} + {/* useTransitions={false}: react-router v7's HashRouter wraps every route state update in React.startTransition() by default. In React 19's concurrent renderer, transitions are non-urgent — React @@ -58,6 +65,7 @@ if (winParam === 'overlay') { + From 45d4cf634dc3b12629b62ccf591624e0c549b751 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 19:16:28 -0500 Subject: [PATCH 3/4] fix(desktop): time interaction frames on the clock that drives them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit withFrames ran its own requestAnimationFrame ticker while the gesture body independently awaited rAF per step. Two rAF consumers, so the observer's deltas counted the driver's frames as well as the app's — it reported ~3fps for a drag that a single-clock probe measures at ~23fps, and it never moved no matter what got fixed underneath. Timing now comes from the same callbacks the body drives (__MARK__). This also fixes a silent false-negative on the typing pass: it paced on setTimeout, so the independent ticker was mostly sampling idle waits between keystrokes and reported a flat 61fps. On the driving clock the same interaction reports ~30fps with 27 of 40 frames over 33ms — which matches the 'typing feels slow' symptom I previously could not reproduce. TYPE now records __TYPE_TARGET__ and the runner throws when no composer is found, so a pass that measures nothing fails loudly instead of scoring a perfect 0 deficit — same guard DRAG already had. --- .../scripts/perf/scenarios/idle-cost.mjs | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/apps/desktop/scripts/perf/scenarios/idle-cost.mjs b/apps/desktop/scripts/perf/scenarios/idle-cost.mjs index d26fb7661c5e..a7b3313a40a4 100644 --- a/apps/desktop/scripts/perf/scenarios/idle-cost.mjs +++ b/apps/desktop/scripts/perf/scenarios/idle-cost.mjs @@ -79,29 +79,31 @@ const idleCost = seconds => ` ` /** Record frame pacing across an interaction driven from the page. + * + * The gesture body drives itself on requestAnimationFrame, so it IS the frame + * clock — timing is taken from those same callbacks rather than a second, + * independent rAF ticker. Running two rAF consumers made the observer's + * deltas count the driver's frames as well as the app's and reported ~3fps + * where the interaction actually ran at ~23fps. `frames` is filled by the + * body via `__MARK__`. * * `record` MUST be false for any fps number you intend to believe: the render * counter walks the whole fiber tree on every commit, so recording during a * gesture measures the instrumentation as much as the app. Attribution and - * timing therefore run as two separate passes — the observer effect here is - * large enough to hide a 15x render reduction behind an unchanged fps. */ + * timing therefore run as two separate passes. */ const withFrames = (body, record = false) => ` (async () => { const rc = window.__RENDER_COUNTS__ ${record ? 'rc.start()' : ''} const frames = [] let last = performance.now() - let stop = false - const tick = () => { - if (stop) return + // The body calls this once per frame it drives. + const __MARK__ = () => { const now = performance.now() frames.push(now - last) last = now - requestAnimationFrame(tick) } - requestAnimationFrame(tick) ${body} - stop = true ${record ? 'rc.stop()' : ''} const total = frames.reduce((a, b) => a + b, 0) const sorted = [...frames].sort((a, b) => a - b) @@ -138,12 +140,14 @@ const DRAG = withFrames(` x += 2 window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y })) await new Promise(r => requestAnimationFrame(r)) + __MARK__() } window.__DRAG_MOVED__ = Math.round(x - x0) for (let i = 0; i < 30; i++) { x -= 2 window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y })) await new Promise(r => requestAnimationFrame(r)) + __MARK__() } window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y })) } else { @@ -154,6 +158,7 @@ const DRAG = withFrames(` /** Type into the composer — the keystroke symptom. */ const TYPE = withFrames(` const el = document.querySelector('[contenteditable="true"], textarea') + window.__TYPE_TARGET__ = el ? (el.tagName.toLowerCase()) : 'none' if (el) { el.focus() for (let i = 0; i < 40; i++) { @@ -167,6 +172,10 @@ const TYPE = withFrames(` el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' })) } el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch })) + // Wait for the frame this keystroke produces, then mark it — same clock + // discipline as DRAG, so typing fps is comparable to drag fps. + await new Promise(r => requestAnimationFrame(r)) + __MARK__() await new Promise(r => setTimeout(r, 25)) } } else { @@ -220,6 +229,7 @@ export default { const dragTarget = await cdp.eval('window.__DRAG_TARGET__ || "unknown"') const dragMoved = await cdp.eval('window.__DRAG_MOVED__ ?? 0') const type = JSON.parse(await cdp.eval(TYPE)) + const typeTarget = await cdp.eval('window.__TYPE_TARGET__ || "unknown"') await cdp.eval(CLEANUP) @@ -227,6 +237,10 @@ export default { throw new Error('idle-cost: no [role="separator"] sash found — the drag measured nothing.') } + if (typeTarget === 'none') { + throw new Error('idle-cost: no composer found — the typing pass measured nothing.') + } + return { metrics: { // Commits per second with a turn open and nothing arriving. Should be 0. From a894879d28d9b4577ea6b44fff96ee647720f1e1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 19:55:55 -0500 Subject: [PATCH 4/4] perf(desktop): baseline multitab + render-churn, leave idle-cost report-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures medians of 5 runs for multitab and render-churn so tonight's wins can't silently regress. idle-cost is deliberately NOT gated. Its render attribution and idle commit rate are trustworthy and are what the scenario exists for, but the drag fps it reports (~0.6fps, p95 814ms) contradicts a direct single-clock probe of the same gesture on the same build (57fps). I ruled out sash selection, tile setup, render-counter residue, and a 20s soak, and could not explain the gap — so the metric ships as a report, not a gate. Gating CI on a number I can't defend would either fire on a phantom or mask a real stall. tier: 'report' is outside GATED ('ci','cold'), so the scenario still runs and prints but neither compares nor writes a baseline. --- apps/desktop/scripts/perf/README.md | 1 + apps/desktop/scripts/perf/baseline.json | 23 +++++++++++++++++-- .../scripts/perf/scenarios/idle-cost.mjs | 8 ++++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/desktop/scripts/perf/README.md b/apps/desktop/scripts/perf/README.md index 4c74de4983dc..ec28a9b0d9b3 100644 --- a/apps/desktop/scripts/perf/README.md +++ b/apps/desktop/scripts/perf/README.md @@ -52,6 +52,7 @@ directly via `window.__PERF_DRIVE__`, so no LLM credits are spent. | `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) | +| `idle-cost` | report | busy-but-silent tiles: idle commit rate, + fps while resizing / typing | (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/baseline.json b/apps/desktop/scripts/perf/baseline.json index 392993f618c6..6750b8616731 100644 --- a/apps/desktop/scripts/perf/baseline.json +++ b/apps/desktop/scripts/perf/baseline.json @@ -1,9 +1,9 @@ { "_meta": { - "note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot — no fake-boot). Representative shipped numbers, not dev-inflated. cold-start reuses one profile so the V8 code cache is WARM (what users get after first launch, ~1.0s); a fresh-profile first launch is ~+400ms (measure with `--cold-fresh`). Marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances loose for cross-machine/disk variance.", + "note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot \u2014 no fake-boot). Representative shipped numbers, not dev-inflated. cold-start reuses one profile so the V8 code cache is WARM (what users get after first launch, ~1.0s); a fresh-profile first launch is ~+400ms (measure with `--cold-fresh`). Marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances loose for cross-machine/disk variance.", "platform": "darwin-arm64", "node": "v24.11.0", - "updated": "2026-07-19T23:16:01.227Z" + "updated": "2026-07-27T00:30:11.290Z" }, "scenarios": { "stream": { @@ -55,6 +55,25 @@ "dom_content_loaded_ms": 574, "nav_to_read_ms": 721 } + }, + "multitab": { + "metrics": { + "longtasks_n": 0, + "longtask_max_ms": 0, + "frame_p95_ms": 29.1, + "frame_p99_ms": 36.2, + "slow_frames_33": 9 + } + }, + "render-churn": { + "metrics": { + "sidebar_renders": 0, + "sidebar_wasted": 0, + "wasted_renders": 1704, + "total_renders": 8221, + "commits": 1352, + "wasted_notifies": 0 + } } } } diff --git a/apps/desktop/scripts/perf/scenarios/idle-cost.mjs b/apps/desktop/scripts/perf/scenarios/idle-cost.mjs index a7b3313a40a4..c378f96ad975 100644 --- a/apps/desktop/scripts/perf/scenarios/idle-cost.mjs +++ b/apps/desktop/scripts/perf/scenarios/idle-cost.mjs @@ -202,7 +202,13 @@ const round = (n, places = 1) => Math.round(n * 10 ** places) / 10 ** places export default { name: 'idle-cost', - tier: 'ci', + // NOT 'ci': the drag fps this reports (~0.6fps, p95 814ms) contradicts a + // direct single-clock probe of the same gesture on the same build (57fps), + // and I could not reconcile the two — ruled out sash selection, tile setup, + // counter residue, and a 20s soak. Its RENDER attribution and idle commit + // rate are trustworthy and are what this scenario is for; the interaction + // fps is reported for investigation, not gated on, until that is explained. + tier: 'report', description: 'Busy-but-silent tiles: idle commit rate, and fps while resizing / typing.', async run(cdp, opts = {}) { const tiles = Number(opts.tiles ?? 5)