From 2c867b05ce2a3803c919d1f6784d75a10899b5a9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 01:07:48 -0500 Subject: [PATCH] =?UTF-8?q?perf(desktop):=2060fps=20sash=20drag=20on=20rea?= =?UTF-8?q?l=20sessions=20=E2=80=94=20height-gate=20the=20RO=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driving HER real instance (real profile, real transcripts, streams live) via CDP instead of synthetic tiles finally exposed the remaining stall. The timeline on a real 60-frame sash drag: style recalc 2736ms | script 1027ms | layout 89ms top callsite: pin @ fallback.tsx — 927ms Two pin-to-bottom ResizeObservers (the bounded tool window's and the reasoning preview's) pinned on EVERY resize delivery. A sash drag changes every message's WIDTH once per frame, so each frame ran scrollTop write -> scrollHeight read across every tool group: a forced write-read reflow cascade that the render counters could never see (zero React involvement). Both pins are now height-gated off the RO entry (reflow-free): only content GROWTH pins. Width-only deliveries return immediately. Measured on the live app, same drag, before -> after: fps 11.5 -> 59-60 p95 101ms -> 18ms slow>33 60/60 -> 1/60 Also in this batch (each was verified live before the next was attempted): - thread/list: split messageSignature into STRUCTURAL (ids/roles — keys boundaries + row identity) and WEIGHT (part counts — budget only), and memoize groups + row JSX. A streamed part-append re-rendered every turn's boundary via its resetKey prop; explain() measured 540-865 wasted Block renders per drag/stream sample, now {}. - message-render-boundary: document the structural-only resetKey contract. - tool/fallback: memoize ToolFallback's part object + ToolEntry/ToolTitle/ ToolGlyph (151 renders each, 100% wasted, on real transcripts). - use-message-stream: ADAPTIVE flush floor — next flush waits 3x the measured cost of the last one (33ms floor, 250ms cap), so multi-stream load degrades text update rate instead of input latency. - tree-split: preview sash drags with inline flex on the two seam wrappers, committing the store ONCE on release (fixed-zone sides get flexBasis only, so a hidden sidebar can't leave a phantom gap). - debug/: perf-live LoAF long-frame attribution, explain() cascade walker with changed-hook indices, diag-real-loop/key-latency/switch-trace probes that drive the real app over CDP. Typing during 2 live streams: keystroke->paint p50 3.3ms, p95 18.4ms, zero frames over 33ms. Session switch p50 ~35ms settled; the remaining ~1.3s outlier tail is streaming-session switches (React work-loop, not style/layout) — next target. --- apps/desktop/scripts/diag-code-live.mjs | 25 ++ apps/desktop/scripts/diag-key-latency.mjs | 47 +++ apps/desktop/scripts/diag-live-state.mjs | 21 ++ apps/desktop/scripts/diag-real-loop.mjs | 149 +++++++++ apps/desktop/scripts/diag-sidebar-dom.mjs | 27 ++ apps/desktop/scripts/diag-switch-trace.mjs | 74 +++++ .../session/hooks/use-message-stream/index.ts | 25 +- .../session/hooks/use-message-stream/utils.ts | 6 + .../assistant-ui/message-render-boundary.tsx | 9 +- .../components/assistant-ui/thread/list.tsx | 133 +++++--- .../assistant-ui/thread/message-parts.tsx | 16 +- .../components/assistant-ui/tool/fallback.tsx | 20 +- .../src/components/pane-shell/geometry.ts | 34 ++ .../pane-shell/tree/renderer/tree-split.tsx | 73 ++++- apps/desktop/src/debug/index.ts | 4 + apps/desktop/src/debug/perf-live.ts | 306 ++++++++++++++++++ apps/desktop/src/debug/render-counter.ts | 102 +++++- 17 files changed, 1013 insertions(+), 58 deletions(-) create mode 100644 apps/desktop/scripts/diag-code-live.mjs create mode 100644 apps/desktop/scripts/diag-key-latency.mjs create mode 100644 apps/desktop/scripts/diag-live-state.mjs create mode 100644 apps/desktop/scripts/diag-real-loop.mjs create mode 100644 apps/desktop/scripts/diag-sidebar-dom.mjs create mode 100644 apps/desktop/scripts/diag-switch-trace.mjs create mode 100644 apps/desktop/src/debug/perf-live.ts diff --git a/apps/desktop/scripts/diag-code-live.mjs b/apps/desktop/scripts/diag-code-live.mjs new file mode 100644 index 0000000000000..98ec39973d9a8 --- /dev/null +++ b/apps/desktop/scripts/diag-code-live.mjs @@ -0,0 +1,25 @@ +// Is the tree-split preview path actually active in the running renderer? +// Checks the served source (what vite compiled) rather than guessing. +import { attach } from './perf/lib/launch.mjs' + +const { cdp, teardown } = await attach({ port: 9222 }) + +try { + await cdp.send('Runtime.enable') + + const out = await cdp.eval(`(async () => { + const res = await fetch('/src/components/pane-shell/tree/renderer/tree-split.tsx') + const src = await res.text() + return JSON.stringify({ + previewShift: src.includes('previewShift'), + adaptiveFloor: (await (await fetch('/src/app/session/hooks/use-message-stream/index.ts')).text()).includes('adaptiveFloor'), + structuralSignature: (await (await fetch('/src/components/assistant-ui/thread/list.tsx')).text()).includes('structuralSignature'), + sharedRO: (await (await fetch('/src/hooks/use-resize-observer.ts')).text()).includes('sharedObserver'), + rootTipProvider: (await (await fetch('/src/main.tsx')).text()).includes('RootTooltipProvider') + }) + })()`) + + console.log(out) +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-key-latency.mjs b/apps/desktop/scripts/diag-key-latency.mjs new file mode 100644 index 0000000000000..7a29bd46cad02 --- /dev/null +++ b/apps/desktop/scripts/diag-key-latency.mjs @@ -0,0 +1,47 @@ +// Typing latency, isolated: keystroke -> next paint, with and without an +// active stream. Distinguishes "input is slow" from "the frame budget is +// consumed by streaming flushes" — the fix differs completely. +import { attach } from './perf/lib/launch.mjs' + +const { cdp, teardown } = await attach({ port: 9222 }) + +const TYPE = ` + (async () => { + const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent) + if (!el) return JSON.stringify({ error: 'no composer' }) + el.focus() + const perKey = [] + for (let i = 0; i < 30; i++) { + const ch = 'abcdefghij'[i % 10] + const t0 = performance.now() + el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch })) + el.textContent += ch + el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' })) + el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch })) + await new Promise(r => requestAnimationFrame(r)) + perKey.push(performance.now() - t0) + // Human-ish 80ms cadence so streaming flushes interleave realistically. + await new Promise(r => setTimeout(r, 80)) + } + el.textContent = '' + el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' })) + const sorted = [...perKey].sort((a, b) => a - b) + const pct = p => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] + const busy = (() => { try { return document.querySelectorAll('[data-status="running"]').length } catch { return -1 } })() + return JSON.stringify({ + keyToPaint_p50: Math.round(pct(0.5) * 10) / 10, + keyToPaint_p95: Math.round(pct(0.95) * 10) / 10, + worst: Math.round(sorted[sorted.length - 1] * 10) / 10, + over16: perKey.filter(f => f > 16.7).length, + over33: perKey.filter(f => f > 33).length, + streamingParts: busy + }) + })() +` + +try { + await cdp.send('Runtime.enable') + console.log(await cdp.eval(TYPE)) +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-live-state.mjs b/apps/desktop/scripts/diag-live-state.mjs new file mode 100644 index 0000000000000..7526bf0e65fbf --- /dev/null +++ b/apps/desktop/scripts/diag-live-state.mjs @@ -0,0 +1,21 @@ +// Quick state probe of the running hgui instance via CDP. +import { attach } from './perf/lib/launch.mjs' + +const { cdp, teardown } = await attach({ port: 9222 }) + +try { + await cdp.send('Runtime.enable') + + const state = await cdp.eval(`(() => { + const rc = !!window.__RENDER_COUNTS__ + const pl = !!window.__PERF_LIVE__ + const tiles = window.__HERMES_SESSION_TILES__ ? Object.keys(window.__HERMES_SESSION_TILES__.states()).length : -1 + const gw = document.querySelector('[data-slot="statusbar"]')?.textContent?.slice(0, 120) ?? '(no statusbar)' + const sidebarRows = document.querySelectorAll('[data-slot="sidebar"] [data-session-id], [data-tree-group] a').length + return JSON.stringify({ rc, pl, tiles, gw, sidebarRows, title: document.title, url: location.href.slice(0, 80) }) + })()`) + + console.log(state) +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-real-loop.mjs b/apps/desktop/scripts/diag-real-loop.mjs new file mode 100644 index 0000000000000..e3bde3b38088d --- /dev/null +++ b/apps/desktop/scripts/diag-real-loop.mjs @@ -0,0 +1,149 @@ +// The real-app perf loop: drive HER hgui instance (real profile, real +// sessions) through the three interactions that matter — session switch, +// sidebar drag, composer typing — and report honest single-clock numbers. +// +// node scripts/diag-real-loop.mjs [--port 9222] [--switches 6] +// +// Unlike the synthetic scenarios this clicks REAL sidebar rows, so session +// switching is measured as the user feels it: click -> transcript painted. + +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 SWITCHES = Number(arg('switches', 6)) + +const { cdp, teardown } = await attach({ port }) + +// --------------------------------------------------------------------------- +// Session switch: click a sidebar session row, await the transcript settling. +// Measures click -> first paint of the new transcript AND click -> settled +// (two rAFs with no further DOM mutation in the thread viewport). +// --------------------------------------------------------------------------- +const SWITCH = swaps => ` + (async () => { + const rows = [...document.querySelectorAll('[data-slot="row-button"]')] + .filter(el => el.offsetParent && (el.textContent ?? '').trim()) + if (rows.length < 2) return JSON.stringify({ error: 'need 2+ visible session rows, found ' + rows.length }) + + const results = [] + for (let i = 0; i < ${swaps}; i++) { + const row = rows[i % Math.min(rows.length, 4)] + const viewport = () => document.querySelector('[data-slot="aui_thread-viewport"]') + const t0 = performance.now() + row.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0, buttons: 1 })) + row.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0 })) + row.click() + + // First paint: next two rAFs after the click. + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))) + const firstPaint = performance.now() - t0 + + // Settled: no mutations in the viewport for 2 consecutive frames, capped 3s. + let lastMutation = performance.now() + const target = viewport() ?? document.body + const mo = new MutationObserver(() => { lastMutation = performance.now() }) + mo.observe(target, { childList: true, subtree: true, characterData: true }) + const deadline = performance.now() + 3000 + while (performance.now() < deadline) { + await new Promise(r => requestAnimationFrame(r)) + if (performance.now() - lastMutation > 120) break + } + mo.disconnect() + results.push({ firstPaint: Math.round(firstPaint), settled: Math.round(performance.now() - t0 - 120) }) + await new Promise(r => setTimeout(r, 250)) + } + return JSON.stringify(results) + })() +` + +// --------------------------------------------------------------------------- +// Drag the first visible sash, single-clock frames. +// --------------------------------------------------------------------------- +const DRAG = ` + (async () => { + const handle = [...document.querySelectorAll('[role="separator"]')].find(el => el.offsetParent || el.getBoundingClientRect().width > 0) + if (!handle) 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 o = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 } + const frames = [] + let last = performance.now() + handle.dispatchEvent(new PointerEvent('pointerdown', { ...o, clientX: x, clientY: y })) + for (let i = 0; i < 60; i++) { + x += (i < 30 ? 2 : -2) + window.dispatchEvent(new PointerEvent('pointermove', { ...o, clientX: x, clientY: y })) + await new Promise(r => requestAnimationFrame(r)) + const now = performance.now(); frames.push(now - last); last = now + } + window.dispatchEvent(new PointerEvent('pointerup', { ...o, buttons: 0, clientX: x, clientY: y })) + const total = frames.reduce((a, b) => a + b, 0) + const sorted = [...frames].sort((a, b) => a - b) + return JSON.stringify({ + fps: Math.round((frames.length / total) * 1000 * 10) / 10, + p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10, + worst: Math.round(sorted[sorted.length - 1] * 10) / 10, + slow33: frames.filter(f => f > 33).length + }) + })() +` + +// --------------------------------------------------------------------------- +// Type into the composer, single-clock frames (one mark per keystroke frame). +// --------------------------------------------------------------------------- +const TYPE = ` + (async () => { + const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent) + if (!el) return JSON.stringify({ error: 'no composer' }) + el.focus() + const frames = [] + let last = performance.now() + for (let i = 0; i < 40; i++) { + const ch = 'the quick brown fox '[i % 20] + el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch })) + el.textContent += ch + el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' })) + el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch })) + await new Promise(r => requestAnimationFrame(r)) + const now = performance.now(); frames.push(now - last); last = now + await new Promise(r => setTimeout(r, 20)) + } + // Clear what we typed. + el.textContent = '' + el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' })) + const moving = frames + const total = moving.reduce((a, b) => a + b, 0) + const sorted = [...moving].sort((a, b) => a - b) + return JSON.stringify({ + fps: Math.round((moving.length / total) * 1000 * 10) / 10, + p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10, + worst: Math.round(sorted[sorted.length - 1] * 10) / 10, + slow33: moving.filter(f => f > 33).length + }) + })() +` + +try { + await cdp.send('Runtime.enable') + + console.log('== SESSION SWITCH (click -> paint / settled ms) ==') + console.log(await cdp.eval(SWITCH(SWITCHES))) + + await sleep(500) + console.log('\n== SIDEBAR DRAG ==') + console.log(await cdp.eval(DRAG)) + + await sleep(500) + console.log('\n== COMPOSER TYPING ==') + console.log(await cdp.eval(TYPE)) +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-sidebar-dom.mjs b/apps/desktop/scripts/diag-sidebar-dom.mjs new file mode 100644 index 0000000000000..64159ca6d5e08 --- /dev/null +++ b/apps/desktop/scripts/diag-sidebar-dom.mjs @@ -0,0 +1,27 @@ +// Dump the sidebar's actual DOM shape so selectors stop being guesses. +import { attach } from './perf/lib/launch.mjs' + +const { cdp, teardown } = await attach({ port: 9222 }) + +try { + await cdp.send('Runtime.enable') + + const out = await cdp.eval(`(() => { + const sidebar = document.querySelector('[data-slot="sidebar"]') ?? document.querySelector('aside') + if (!sidebar) return '(no sidebar el)' + // Find clickable rows: anchors or buttons with text, depth-limited sample. + const clickables = [...sidebar.querySelectorAll('a, button, [role="button"], [data-slot]')].slice(0, 60) + const rows = clickables.map(el => ({ + tag: el.tagName.toLowerCase(), + slot: el.getAttribute('data-slot') ?? '', + cls: (el.className?.baseVal ?? el.className ?? '').toString().slice(0, 40), + text: (el.textContent ?? '').trim().slice(0, 30), + visible: !!el.offsetParent + })).filter(r => r.text) + return JSON.stringify(rows.slice(0, 30), null, 1) + })()`) + + console.log(out) +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-switch-trace.mjs b/apps/desktop/scripts/diag-switch-trace.mjs new file mode 100644 index 0000000000000..8efe5462e3fee --- /dev/null +++ b/apps/desktop/scripts/diag-switch-trace.mjs @@ -0,0 +1,74 @@ +// What happens during a SLOW session switch? Click a heavy row with tracing +// on, dump the style/layout/script split plus top callsites. +import { attach } from './perf/lib/launch.mjs' +import { sleep } from './perf/lib/cdp.mjs' + +const { cdp, teardown } = await attach({ port: 9222 }) + +const CLICK_HEAVIEST = ` + (() => { + const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent) + if (rows.length < 2) return 'need rows' + // Alternate between the first two rows so every run actually switches. + const current = location.hash + const target = rows.find(r => !r.getAttribute('data-active')) ?? rows[1] + target.click() + return 'clicked: ' + (target.textContent ?? '').slice(0, 40) + })() +` + +const events = [] +let complete = false +cdp.on('Tracing.dataCollected', p => events.push(...(p.value ?? []))) +cdp.on('Tracing.tracingComplete', () => { + complete = true +}) + +try { + await cdp.send('Runtime.enable') + + await cdp.send('Tracing.start', { + transferMode: 'ReportEvents', + traceConfig: { includedCategories: ['devtools.timeline'] } + }) + + console.log(await cdp.eval(CLICK_HEAVIEST)) + await sleep(2500) + console.log(await cdp.eval(CLICK_HEAVIEST)) + await sleep(2500) + + await cdp.send('Tracing.end') + + for (let w = 0; !complete && w < 10000; w += 200) { + await sleep(200) + } + + const totals = new Map() + const byFn = new Map() + + for (const e of events) { + if (e.ph !== 'X' || typeof e.dur !== 'number') { + continue + } + + totals.set(e.name, (totals.get(e.name) ?? 0) + e.dur / 1000) + + if (e.name === 'FunctionCall') { + const d = e.args?.data ?? {} + const key = `${d.functionName || '(anon)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}` + byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000) + } + } + + 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 over 2 switches: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms paint=${(totals.get('Paint') ?? 0).toFixed(0)}ms`) + console.log('\nTOP CALLSITES:') + + for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12)) { + console.log(` ${ms.toFixed(1).padStart(8)} ${name}`) + } +} finally { + teardown?.() +} diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index 2346936fa94fe..8a50465cdc214 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -29,7 +29,7 @@ import { setSessionTodos } from '@/store/todos' import type { ClientSessionState } from '../../../types' import { useGatewayEventHandler } from './gateway-event' -import { completionErrorText, delegateTaskPayloads, STREAM_DELTA_FLUSH_MS } from './utils' +import { completionErrorText, delegateTaskPayloads, MAX_STREAM_FLUSH_GAP_MS, STREAM_DELTA_FLUSH_MS } from './utils' interface MessageStreamOptions { activeGatewayProfile?: string @@ -184,6 +184,9 @@ export function useMessageStream({ const queuedDeltasRef = useRef>(new Map()) const flushHandleRef = useRef(null) const lastFlushAtRef = useRef(0) + // What the previous flush cost on the main thread — drives the adaptive + // flush floor in scheduleDeltaFlush so multi-stream load yields to input. + const lastFlushCostRef = useRef(0) const nativeSubagentSessionsRef = useRef>(new Set()) // Turns that auto-compacted: skip post-turn hydrate so live scrollback survives. const compactedTurnRef = useRef>(new Set()) @@ -243,12 +246,28 @@ export function useMessageStream({ // length. With this floor, slower streams still coalesce ~2 tokens per // commit and the synthetic harness shows longtask counts drop from ~5/5s // to ~1/5s on big sessions (see scripts/profile-typing-lag.md). + // + // ADAPTIVE: the floor scales with what the last flush actually cost. + // With several sessions streaming at once (split tiles), one flush carries + // every stream's commit + markdown re-parse; when that work approaches or + // exceeds the fixed 33ms budget, back-to-back flushes leave the main + // thread no idle frames and every interaction (typing, resize, hover) + // stutters even though no render is wasted. Yielding 3x the measured cost + // keeps the thread ~75% idle for input at any load: cheap flushes stay at + // 30fps of text growth, expensive multi-stream flushes degrade text fps + // instead of interactivity — capped so text never updates slower than 4/s. const sinceLast = performance.now() - lastFlushAtRef.current + const adaptiveFloor = Math.min( + Math.max(STREAM_DELTA_FLUSH_MS, lastFlushCostRef.current * 3), + MAX_STREAM_FLUSH_GAP_MS + ) const runFlush = () => { flushHandleRef.current = null - lastFlushAtRef.current = performance.now() + const startedAt = performance.now() + lastFlushAtRef.current = startedAt flushQueuedDeltas() + lastFlushCostRef.current = performance.now() - startedAt } // Always a timer, never requestAnimationFrame. Chromium pauses rAF for a @@ -265,7 +284,7 @@ export function useMessageStream({ // for) while guaranteeing delivery without user interaction. Timers are // clamped in background renderers rather than suspended, and // disable-background-timer-throttling already opts out of that clamp. - flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast)) + flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, adaptiveFloor - sinceLast)) }, [flushQueuedDeltas]) const queueDelta = useCallback( diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts b/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts index d9a90b3669206..0da74927639de 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts @@ -66,6 +66,12 @@ export function hasSessionInfoStatePatch(patch: SessionRuntimeStatePatch): boole // `scripts/profile-typing-lag.md` for the measurement work behind this. export const STREAM_DELTA_FLUSH_MS = 33 +// Ceiling for the ADAPTIVE flush gap (see scheduleDeltaFlush). Under heavy +// multi-stream load the gap stretches to 3x the measured flush cost so the +// main thread stays responsive to input; this cap guarantees streaming text +// still visibly updates at least ~4x per second no matter the load. +export const MAX_STREAM_FLUSH_GAP_MS = 250 + // Gateway/provider failures sometimes arrive as message.complete text instead // of an explicit error event. Treat matches as inline assistant errors so they // persist like real error events and don't get erased by hydrate fallback. diff --git a/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx b/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx index f0fafbc4f18fc..4cae22b5c0c0a 100644 --- a/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx +++ b/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx @@ -13,8 +13,13 @@ const isTransientLookupError = (error: unknown): boolean => error instanceof Error && /(useClientLookup|tapClient(Lookup|Resource)).*out of bounds/.test(error.message) interface Props { - // Changes whenever the message list mutates; remounting clears the caught - // error so the next consistent render recovers silently. + // Changes whenever the message list mutates STRUCTURALLY (ids/roles/count); + // remounting clears the caught error so the next consistent render recovers + // silently. Deliberately NOT the per-token signature: this prop reaches + // every turn's boundary, so a value that ticks with content length would + // re-render every boundary — and reconcile every turn's subtree — on every + // streamed token (measured: 540 wasted Block renders per explain() sample + // with two threads streaming). resetKey: string children: ReactNode } diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index e80938cf4d0ec..676a0f57136c4 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -9,6 +9,7 @@ import { useCallback, useEffect, useLayoutEffect, + useMemo, useRef, useState } from 'react' @@ -141,14 +142,25 @@ const ThreadMessageListInner: FC = ({ loadingIndicator, sessionKey }) => { - const messageSignature = useAuiState(s => - s.thread.messages - .map((message, index) => `${index}:${message.id}:${message.role}:${message.content?.length ?? 1}`) - .join('\n') + // TWO signatures, deliberately split. The STRUCTURAL one (ids/roles/count) + // changes only when messages are added/removed/swapped — it keys the error + // boundaries and the row identity. The WEIGHT one (per-message part counts) + // ticks while a streaming turn appends parts — it feeds only the render + // budget. Folding weights into the structural key handed every boundary a + // new resetKey per appended part, which reconciled every turn's subtree on + // every tick (measured: 540 wasted Block renders per explain() sample with + // two threads streaming). + const structuralSignature = useAuiState(s => + s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n') ) + const weightSignature = useAuiState(s => s.thread.messages.map(message => message.content?.length ?? 1).join(',')) + const { t } = useI18n() - const groups = buildGroups(messageSignature) + // Row structure is memoized on the STRUCTURAL signature only, so streaming + // part-appends can't churn group identity (that would defeat the rows memo + // below on every tick). Weights are folded in separately for the budget. + const groups = useMemo(() => buildGroups(structuralSignature), [structuralSignature]) const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder) // use-stick-to-bottom owns scrollTop (single writer): follow while locked, @@ -213,7 +225,22 @@ const ThreadMessageListInner: FC = ({ return () => cancelAnimationFrame(rafId) }, [renderBudget]) - const hiddenCount = firstVisibleGroupIndex(groups, renderBudget) + // Weights (per-message part counts) fold into the BUDGET only. Group + // identity stays structural, so a streaming append re-runs this cheap sum — + // not the row JSX. Weighted the same way the old combined signature was. + const weightedGroups = useMemo(() => { + const weights = weightSignature.split(',').map(w => Number(w) || 1) + + return groups.map(group => ({ + ...group, + weight: + group.kind === 'turn' + ? group.indices.reduce((sum, index) => sum + (weights[index] ?? 1), 0) + : (weights[group.index] ?? 1) + })) + }, [groups, weightSignature]) + + const hiddenCount = firstVisibleGroupIndex(weightedGroups, renderBudget) const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups const restoreFromBottomRef = useRef(null) // Secondary windows (new-session scratch, subagent watch, cmd-click pop-out) @@ -331,6 +358,59 @@ const ThreadMessageListInner: FC = ({ } }, [scrollRef, renderBudget]) + // The row array is memoized on the inputs the rows actually read. This + // component re-renders on every isAtBottom flip — and use-stick-to-bottom + // flips it from a ResizeObserver, so a sidebar DRAG re-renders this list per + // frame. Without the memo, the inline .map() rebuilt every row's JSX each + // time, and rebuilt children re-render their whole subtree even when nothing + // changed (measured live: 865 wasted Block renders in one drag, walked to + // "MessageRenderBoundary (children only)" by explain()). With it, React + // bails out on element identity and a scroll flip re-renders nothing below. + const rows = useMemo( + () => + visibleGroups.map((group, indexInVisible) => ( + // content-visibility:auto — off-screen turns skip style recalc, + // layout, and paint. On a long transcript this is what keeps + // UNRELATED UI fast: any dialog/popover mount (Radix Presence + // reads getComputedStyle) forces a whole-document style recalc, + // measured ~650-730ms per open on a 1300-message session and + // ~100-200ms with this on. contain-intrinsic-size keeps a + // placeholder height for never-rendered turns (auto: remembered + // real size once rendered), so scrollbar/anchoring stay stable. + // Sticky human bubbles are unaffected — their turn is rendered + // whenever any part of it intersects the viewport. + // + // The live tail (newest turns) is exempt: virtualizing a turn + // whose final size hasn't been remembered yet snaps it to a stale + // height when it scrolls off, drifting stick-to-bottom up over old + // turns. See isVirtualizedGroup. +
+ + {group.kind === 'turn' ? ( +
+ {group.indices.map(index => ( + + ))} +
+ ) : ( + + )} +
+
+ )), + [visibleGroups, components, structuralSignature] + ) + return (
= ({ {t.assistant.thread.showEarlier} )} - {visibleGroups.map((group, indexInVisible) => ( - // content-visibility:auto — off-screen turns skip style recalc, - // layout, and paint. On a long transcript this is what keeps - // UNRELATED UI fast: any dialog/popover mount (Radix Presence - // reads getComputedStyle) forces a whole-document style recalc, - // measured ~650-730ms per open on a 1300-message session and - // ~100-200ms with this on. contain-intrinsic-size keeps a - // placeholder height for never-rendered turns (auto: remembered - // real size once rendered), so scrollbar/anchoring stay stable. - // Sticky human bubbles are unaffected — their turn is rendered - // whenever any part of it intersects the viewport. - // - // The live tail (newest turns) is exempt: virtualizing a turn - // whose final size hasn't been remembered yet snaps it to a stale - // height when it scrolls off, drifting stick-to-bottom up over old - // turns. See isVirtualizedGroup. -
- - {group.kind === 'turn' ? ( -
- {group.indices.map(index => ( - - ))} -
- ) : ( - - )} -
-
- ))} + {rows} {loadingIndicator} {clampToComposer && (
{ - el.scrollTop = el.scrollHeight + // Height-gated: the observer also fires when the container's WIDTH changes + // (sidebar sash drag resizes every message), and pinning there forces a + // scrollHeight read+write per preview per frame. Only actual content + // growth needs the pin; the height rides the RO entry, reflow-free. + let lastHeight = -1 + + const pin = (entries: readonly ResizeObserverEntry[]) => { + const height = entries[entries.length - 1]?.borderBoxSize?.[0]?.blockSize ?? -1 + const grew = height < 0 || height > lastHeight + lastHeight = height + + if (grew) { + el.scrollTop = el.scrollHeight + } } // No sync pin(): the observer's guaranteed initial delivery runs it with diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 7c004e07c1fa3..746a64dccdfee 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -755,7 +755,25 @@ function useToolWindow(enabled: boolean) { return } - const pin = () => { + // Track the content's HEIGHT and only pin when it grows. The observer also + // fires for width changes — a sidebar sash drag resizes every tool window + // once per frame — and pinning there is (a) pointless, the list didn't + // grow, and (b) expensive: `pin` writes scrollTop then `syncFade` reads it + // back, a write->read forced reflow per tool group per frame. Measured on + // a real session while dragging the sash: 927ms of `pin` script plus + // 2.7s of style recalc across one 60-frame drag. Reading the height off + // the RO entry keeps the check reflow-free. + let lastHeight = -1 + + const pin = (entries: readonly ResizeObserverEntry[]) => { + const height = entries[entries.length - 1]?.borderBoxSize?.[0]?.blockSize ?? -1 + const grew = height < 0 || height > lastHeight + lastHeight = height + + if (!grew) { + return + } + if (stickRef.current) { el.scrollTop = el.scrollHeight } diff --git a/apps/desktop/src/components/pane-shell/geometry.ts b/apps/desktop/src/components/pane-shell/geometry.ts index a2a484188b428..31eecd15341ff 100644 --- a/apps/desktop/src/components/pane-shell/geometry.ts +++ b/apps/desktop/src/components/pane-shell/geometry.ts @@ -158,6 +158,31 @@ function sameRect(a: Rect | null, b: Rect | null) { // Workspace-edge CSS vars // --------------------------------------------------------------------------- +// --- Sash-drag deferral ------------------------------------------------------ +// The tree sash sets this for the duration of a resize gesture (pointerdown to +// pointerup). While set, `publishWorkspaceGeometry` skips its :root custom +// property writes: each one invalidates computed style for the whole document, +// and the sash's ResizeObserver fires per frame. Measured live (LoAF, real +// session): drag frames of ~68ms with style+layout=67ms and no script ≥5ms; +// suppressing the writes recovered 14fps → 51fps. The vars only align titlebar +// chrome — republishing once on release is visually identical. +let sashDragDepth = 0 +let onSashDragEnd: null | (() => void) = null + +export function beginSashDrag() { + sashDragDepth += 1 +} + +export function endSashDrag() { + sashDragDepth = Math.max(0, sashDragDepth - 1) + + if (sashDragDepth === 0) { + onSashDragEnd?.() + } +} + +const sashDragging = () => sashDragDepth > 0 + /** * Publish the workspace zone's viewport edges as root CSS vars: * --workspace-left : px from the viewport's left to the main zone @@ -177,6 +202,12 @@ export function publishWorkspaceGeometry(): () => void { const ro = new ResizeObserver(() => measure()) const measure = () => { + // DEFER during a sash drag (see beginSashDrag above) — republished once on + // release via the onSashDragEnd hook registered below. + if (sashDragging()) { + return + } + const next = document.querySelector('[data-session-anchor="workspace"]') if (next !== el) { @@ -216,11 +247,14 @@ export function publishWorkspaceGeometry(): () => void { // frame later, after the DOM committed. RO covers width changes (sash drags, // side collapses); window resize covers the rest. const unsubTree = $layoutTree.listen(() => requestAnimationFrame(measure)) + // Drag released → publish the final geometry the deferral above skipped. + onSashDragEnd = () => requestAnimationFrame(measure) window.addEventListener('resize', measure) measure() return () => { unsubTree() + onSashDragEnd = null window.removeEventListener('resize', measure) ro.disconnect() root.style.removeProperty('--workspace-left') diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx index d963d79efb8cc..63f61f4ad342a 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx @@ -9,6 +9,7 @@ import { useStore } from '@nanostores/react' import { type PointerEvent as ReactPointerEvent, useCallback, useMemo, useRef, useSyncExternalStore } from 'react' +import { beginSashDrag, endSashDrag } from '@/components/pane-shell/geometry' import { useContributions } from '@/contrib/react/use-contributions' import { rafCoalesce } from '@/lib/raf-coalesce' import { cn } from '@/lib/utils' @@ -234,9 +235,33 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo document.body.style.cursor = horizontal ? 'col-resize' : 'row-resize' document.body.style.userSelect = 'none' + // Suppress :root geometry-var writes for the gesture (see geometry.ts — + // each one restyles the whole document; they republish on release). + beginSashDrag() // pointermove outpaces 60fps and each write relayouts the whole pane tree, // so coalesce to one apply per frame (rafCoalesce commits on cleanup). + // + // During the gesture the store is NOT written. setTreeSplitWeights / + // setPaneWidthOverride each mint a new tree/pane-state object, and the + // resulting commit walks every mounted pane — measured live on a real + // 2-session layout: 31 commits across a 58-frame drag, 20.7fps, with + // TreeNode at 490ms and Block/Ct re-parsing markdown for 620ms. The + // store is written ONCE on release; during the drag the seam is + // previewed with inline styles on the same wrappers React sizes. + // + // Preview rules (learned the hard way — a wrong shape here left a + // phantom gap where a hidden sidebar lived): + // - a FIXED side gets ONLY a flex-basis override. Its wrapper renders + // as `flex: 0 1 `, so basis is the whole difference; grow and + // shrink stay React's. Crucially the flex partner is left untouched, + // so it keeps absorbing the remainder and no leftover gap can open. + // - a flex-vs-flex seam pins both sides to `0 1 `. Their combined + // px is constant, so sibling flex tracks see the same leftover. + // - cleanup: a real drag commits the store once, and React's re-render + // rewrites the `flex` shorthand, which clears the overrides (writing + // the shorthand resets the longhands). A no-movement click restores + // the captured style attribute instead, since nothing re-renders. const applyShift = (shiftPx: number) => { if (a.fixed) { a.paneIds.forEach(id => setOverride(id, Math.round(a0px + shiftPx))) @@ -255,14 +280,58 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo } } - const resize = rafCoalesce(applyShift) + const styleA = kidA.getAttribute('style') + const styleB = kidB.getAttribute('style') + + const previewSide = (el: HTMLElement, fixed: boolean, px: number) => { + if (fixed) { + el.style.flexBasis = `${px}px` + } else if (!a.fixed && !b.fixed) { + el.style.flex = `0 1 ${px}px` + } + // Mixed seam, flex side: untouched — it absorbs what the fixed side + // gives up, exactly as the track model would render it. + } + + const previewShift = (shiftPx: number) => { + previewSide(kidA, a.fixed, a0px + shiftPx) + previewSide(kidB, b.fixed, b0px - shiftPx) + } + + const resize = rafCoalesce(previewShift) + let lastShift: null | number = null const onMove = (ev: PointerEvent) => { - resize.push(Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start))) + lastShift = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start)) + resize.push(lastShift) } const cleanup = () => { resize.finish() + + if (lastShift !== null) { + // One store commit; the re-render rewrites `flex` and clears the + // preview overrides. + applyShift(lastShift) + } else { + // Click without movement: nothing will re-render, so put the + // wrappers' inline styles back exactly as React last wrote them. + if (styleA === null) { + kidA.removeAttribute('style') + } else { + kidA.setAttribute('style', styleA) + } + + if (styleB === null) { + kidB.removeAttribute('style') + } else { + kidB.setAttribute('style', styleB) + } + } + + // Geometry vars re-enable AFTER the final store commit above, so the + // release publishes exactly one fresh measurement. + endSashDrag() document.body.style.cursor = restoreCursor document.body.style.userSelect = restoreSelect diff --git a/apps/desktop/src/debug/index.ts b/apps/desktop/src/debug/index.ts index 365cc23fdbfa9..e5eb37b55b212 100644 --- a/apps/desktop/src/debug/index.ts +++ b/apps/desktop/src/debug/index.ts @@ -25,6 +25,10 @@ // with no changed input, and stores that published a value equal to the last. import './render-counter' +// Live interaction profiler — arms on real resize/typing so we can measure the +// app under REAL sessions instead of a synthetic scenario's toy transcripts. +// window.__PERF_LIVE__.on() in the console, then just use the app. +import './perf-live' import { watchSessionAtoms } from './watched-atoms' diff --git a/apps/desktop/src/debug/perf-live.ts b/apps/desktop/src/debug/perf-live.ts new file mode 100644 index 0000000000000..ce73b7738c992 --- /dev/null +++ b/apps/desktop/src/debug/perf-live.ts @@ -0,0 +1,306 @@ +// Live interaction profiler — for driving the app by hand and seeing what the +// app actually does under YOUR sessions, not a synthetic scenario's. +// +// The synthetic scenarios seed toy transcripts (short prose, no tool calls, no +// code blocks). A real session is heavier in ways that matter, so a scenario +// can report 57fps on a gesture that visibly lags in the real app. This closes +// that gap by measuring the real thing. +// +// It arms itself on any pointerdown on a resize handle and on composer typing, +// records frames + render attribution for the duration of the interaction, and +// prints a table when the interaction ends. Idle cost is zero: nothing is +// observed until an interaction starts. +// +// window.__PERF_LIVE__.on() start watching (also: ?perflive=1) +// window.__PERF_LIVE__.off() +// window.__PERF_LIVE__.last() the most recent report as an object +// +// Dev-only; the whole debug/ graph is aliased out of production builds. + +interface Sample { + kind: string + ms: number + frames: number + fps: number + p95: number + worst: number + slow33: number + commits: number + top: Array<{ name: string; renders: number; wasted: number; totalMs: number }> + longFrames: LongFrame[] +} + +/** One Long Animation Frame, attributed. `styleMs` is the engine's style+layout + * time inside the frame; `scripts` names who ran JS and for how long. This is + * the half the render counter cannot see — a frame can cost 900ms with almost + * no React in it, and only LoAF says whether that was layout, a ResizeObserver + * callback loop, or some timer. */ +interface LongFrame { + ms: number + styleMs: number + blockingMs: number + scripts: Array<{ invoker: string; ms: number; src: string }> +} + +const RESIZE_SELECTOR = '[role="separator"], [data-slot="pane-resize-handle"], [class*="cursor-col-resize"], [class*="cursor-row-resize"]' +const TYPING_SELECTOR = '[contenteditable="true"], textarea, input[type="text"]' + +// A gesture is "over" once this long passes with no further input events. +const IDLE_END_MS = 350 +// Don't report trivial blips (a click, a single keypress). +const MIN_FRAMES = 6 + +let watching = false + +let active: null | { + kind: string + startedAt: number + frames: number[] + last: number + raf: number + endTimer: ReturnType | null +} = null + +let lastReport: null | Sample = null + +// Long Animation Frames observed while a gesture is active. LoAF entries name +// the scripts inside each long frame and split out style/layout time — the +// half of a frame the render counter cannot see. +let longFrames: LongFrame[] = [] + +const loafObserver = + typeof PerformanceObserver !== 'undefined' && PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame') + ? new PerformanceObserver(list => { + if (!active) { + return + } + + for (const entry of list.getEntries()) { + const e = entry as PerformanceEntry & { + blockingDuration?: number + styleAndLayoutStart?: number + renderStart?: number + scripts?: Array<{ + duration: number + invoker?: string + invokerType?: string + sourceURL?: string + sourceFunctionName?: string + }> + } + + longFrames.push({ + blockingMs: Math.round(e.blockingDuration ?? 0), + ms: Math.round(e.duration), + scripts: (e.scripts ?? []) + .filter(s => s.duration >= 5) + .map(s => ({ + invoker: `${s.invokerType ?? ''}:${s.invoker ?? s.sourceFunctionName ?? '?'}`, + ms: Math.round(s.duration), + src: (s.sourceURL ?? '').split('/').pop() ?? '' + })), + // styleAndLayoutStart -> frame end is the engine's style+layout tail. + styleMs: e.styleAndLayoutStart + ? Math.round(e.startTime + e.duration - e.styleAndLayoutStart) + : 0 + }) + } + }) + : null + +const pct = (sorted: number[], p: number) => + sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0 + +function finish() { + if (!active) { + return + } + + const { kind, startedAt, frames, raf } = active + active = null + cancelAnimationFrame(raf) + loafObserver?.disconnect() + const capturedLongFrames = longFrames + longFrames = [] + + const counter = window.__RENDER_COUNTS__ + const commits = counter?.commits() ?? 0 + + const top = (counter?.report(12) ?? []).map(r => ({ + name: r.name, + renders: r.renders, + wasted: r.wasted, + totalMs: r.totalMs + })) + + counter?.stop() + + if (frames.length < MIN_FRAMES) { + return + } + + const total = frames.reduce((a, b) => a + b, 0) + const sorted = [...frames].sort((a, b) => a - b) + + const report: Sample = { + commits, + fps: Math.round((frames.length / total) * 1000 * 10) / 10, + frames: frames.length, + kind, + longFrames: capturedLongFrames, + ms: Math.round(performance.now() - startedAt), + p95: Math.round(pct(sorted, 0.95) * 10) / 10, + slow33: frames.filter(f => f > 33).length, + top, + worst: Math.round(sorted[sorted.length - 1] * 10) / 10 + } + + lastReport = report + + const headline = + `%c${kind}%c ${report.fps}fps · ${report.frames} frames in ${report.ms}ms · ` + + `p95 ${report.p95}ms worst ${report.worst}ms · ${report.slow33} slow · ${commits} commits` + + console.log( + headline, + `background:${report.fps < 45 ? '#c0392b' : '#27ae60'};color:#fff;padding:1px 6px;border-radius:3px`, + 'color:inherit' + ) + + if (top.length) { + console.table(top) + } + + // The frame-engine side: what each long frame actually spent its time on. + for (const lf of capturedLongFrames.slice(0, 8)) { + const scripts = lf.scripts.map(s => `${s.invoker}@${s.src} ${s.ms}ms`).join(' | ') || '(no script ≥5ms)' + console.log(` ⏱ longframe ${lf.ms}ms style+layout ${lf.styleMs}ms block ${lf.blockingMs}ms → ${scripts}`) + } +} + +function begin(kind: string) { + if (active) { + // Same gesture continuing — just push the end deadline out. + if (active.endTimer) { + clearTimeout(active.endTimer) + } + + active.endTimer = setTimeout(finish, IDLE_END_MS) + + return + } + + window.__RENDER_COUNTS__?.start() + + try { + // Buffered so a long frame already in flight when the gesture starts is + // still attributed to it. + loafObserver?.observe({ buffered: true, type: 'long-animation-frame' }) + } catch { + // Older runtime without LoAF — headline still works, attribution is empty. + } + + const now = performance.now() + + const state = { + endTimer: setTimeout(finish, IDLE_END_MS) as ReturnType | null, + frames: [] as number[], + kind, + last: now, + raf: 0, + startedAt: now + } + + const tick = () => { + if (active !== state) { + return + } + + const t = performance.now() + state.frames.push(t - state.last) + state.last = t + state.raf = requestAnimationFrame(tick) + } + + active = state + state.raf = requestAnimationFrame(tick) +} + +function onPointerDown(event: PointerEvent) { + const target = event.target as Element | null + + if (target?.closest?.(RESIZE_SELECTOR)) { + begin('resize') + } +} + +function onPointerMove() { + if (active?.kind === 'resize') { + begin('resize') + } +} + +function onKeyDown(event: KeyboardEvent) { + const target = event.target as Element | null + + // Ignore pure modifiers and navigation — we want real text entry. + if (event.key.length !== 1 && event.key !== 'Backspace') { + return + } + + if (target?.closest?.(TYPING_SELECTOR)) { + begin('typing') + } +} + +function on() { + if (watching) { + return 'already watching' + } + + watching = true + window.addEventListener('pointerdown', onPointerDown, true) + window.addEventListener('pointermove', onPointerMove, true) + window.addEventListener('keydown', onKeyDown, true) + + + console.log( + '%cperf-live%c armed — resize a pane or type in the composer; a report prints when you stop.', + 'background:#5a7db0;color:#fff;padding:1px 6px;border-radius:3px', + 'color:inherit' + ) + + return 'watching' +} + +function off() { + watching = false + window.removeEventListener('pointerdown', onPointerDown, true) + window.removeEventListener('pointermove', onPointerMove, true) + window.removeEventListener('keydown', onKeyDown, true) + finish() + + return 'stopped' +} + +declare global { + interface Window { + __PERF_LIVE__?: { + on: () => string + off: () => string + last: () => null | Sample + watching: () => boolean + } + } +} + +if (typeof window !== 'undefined' && !window.__PERF_LIVE__) { + window.__PERF_LIVE__ = { last: () => lastReport, off, on, watching: () => watching } + + // Opt in for a whole session with ?perflive=1 so a reload keeps measuring. + if (new URLSearchParams(window.location.search).get('perflive') === '1') { + on() + } +} + +export {} diff --git a/apps/desktop/src/debug/render-counter.ts b/apps/desktop/src/debug/render-counter.ts index 2a41d792a245b..1bb7e6cf8b7bc 100644 --- a/apps/desktop/src/debug/render-counter.ts +++ b/apps/desktop/src/debug/render-counter.ts @@ -46,6 +46,12 @@ const counts = new Map() let commits = 0 let recording = false +// explain() state: while set, every wasted render of this component walks up +// the fiber tree to find the ancestor whose props/state/context actually +// changed — the origin of the cascade. +let explainTarget: null | string = null +const explainCauses = new Map() + const blank = (): RenderRecord => ({ contextChanged: 0, propsChanged: 0, @@ -76,19 +82,51 @@ function propsChanged(fiber: Fiber): boolean { /** Did any hook's memoizedState change? Covers useState, useSyncExternalStore * (so nanostores `useStore`), useMemo, and useReducer alike. */ function stateChanged(fiber: Fiber): boolean { + return changedHookIndices(fiber).length > 0 +} + +/** Indices (source order) of the hooks whose memoizedState changed. The index + * maps straight onto the component's hook call order, so "hook #3 changed" + * identifies the exact useStore/useState line without guessing. */ +function changedHookIndices(fiber: Fiber): number[] { let next: Fiber['memoizedState'] | null | undefined = fiber.memoizedState let prev: Fiber['memoizedState'] | null | undefined = fiber.alternate?.memoizedState + const changed: number[] = [] + let index = 0 while (next && prev) { if (!Object.is(next.memoizedState, prev.memoizedState)) { - return true + changed.push(index) } next = next.next prev = prev.next + index += 1 } - return false + return changed +} + +/** Names of the props whose identity changed — the cascade origin's smoking + * gun. Used by explain() so the answer is "Streamdown (props: children)" and + * not just "Streamdown (props)". */ +function changedPropKeys(fiber: Fiber): string[] { + const prev = fiber.alternate?.memoizedProps as Record | null | undefined + const next = fiber.memoizedProps as Record | null | undefined + + if (!prev || !next) { + return [] + } + + const keys: string[] = [] + + for (const key of Object.keys(next)) { + if (!Object.is(prev[key], next[key])) { + keys.push(key) + } + } + + return keys } /** Did any consumed context value change? A `memo()` cannot block a re-render @@ -139,6 +177,46 @@ function record(fiber: Fiber) { if (!props && !state && !context) { entry.wasted += 1 + + // explain() support: walk UP from a wasted render to the TOP of the + // cascade — the highest ancestor that also rendered this commit. That + // fiber is the origin; its own changed props/state is the reason. + // Stopping at the first ancestor with changed props is wrong: JSX rebuilt + // by a parent makes every intermediate node report "children changed", + // which is the symptom cascading down, not the cause. + if (explainTarget && name === explainTarget) { + let origin: Fiber = fiber + let cursor = fiber.return + let hops = 0 + + while (cursor && hops < 80) { + if (isCompositeFiber(cursor) && didFiberRender(cursor)) { + origin = cursor + } + + cursor = cursor.return + hops += 1 + } + + const originName = getDisplayName(origin) ?? '?' + const changed = changedPropKeys(origin).filter(k => k !== 'children') + + const why = + origin === fiber + ? 'self' + : stateChanged(origin) + ? `state (hooks #${changedHookIndices(origin).slice(0, 5).join(',#')})` + : changed.length + ? `props: ${changed.slice(0, 4).join(',')}` + : contextChanged(origin) + ? 'context' + : changedPropKeys(origin).length + ? 'children only' + : 'no visible change (external store?)' + + const key = `${originName} (${why})` + explainCauses.set(key, (explainCauses.get(key) ?? 0) + 1) + } } counts.set(name, entry) @@ -168,6 +246,12 @@ declare global { report: (limit?: number) => Array /** Attribution for one component by display name. */ get: (name: string) => RenderRecord | undefined + /** + * Name a component (its displayName, e.g. 'Block'), interact, then call + * with no argument to get the tally of which CHANGED ancestor each of + * its wasted renders cascaded from. The origin, walked — not guessed. + */ + explain: (name?: null | string) => Record | string } } } @@ -195,6 +279,20 @@ if (typeof window !== 'undefined' && !window.__RENDER_COUNTS__) { }, commits: () => commits, counts, + explain: name => { + if (name !== undefined) { + explainTarget = name + explainCauses.clear() + + if (name && !recording) { + recording = true + } + + return name ? `explaining ${name} — interact, then call explain() to read` : 'explain off' + } + + return Object.fromEntries([...explainCauses.entries()].sort((x, y) => y[1] - x[1])) + }, get: name => counts.get(name), recording: () => recording, report,