perf(desktop): 60fps sash drag on real sessions — height-gate the RO pins
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.
This commit is contained in:
parent
af1cc1c245
commit
2c867b05ce
|
|
@ -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?.()
|
||||
}
|
||||
|
|
@ -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?.()
|
||||
}
|
||||
|
|
@ -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?.()
|
||||
}
|
||||
|
|
@ -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?.()
|
||||
}
|
||||
|
|
@ -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?.()
|
||||
}
|
||||
|
|
@ -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?.()
|
||||
}
|
||||
|
|
@ -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<Map<string, QueuedStreamDeltas>>(new Map())
|
||||
const flushHandleRef = useRef<number | null>(null)
|
||||
const lastFlushAtRef = useRef<number>(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<number>(0)
|
||||
const nativeSubagentSessionsRef = useRef<Set<string>>(new Set())
|
||||
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
|
||||
const compactedTurnRef = useRef<Set<string>>(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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
|
@ -141,14 +142,25 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
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<ThreadMessageListProps> = ({
|
|||
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<number | null>(null)
|
||||
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
|
||||
|
|
@ -331,6 +358,59 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
}
|
||||
}, [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.
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)',
|
||||
isVirtualizedGroup(indexInVisible, visibleGroups.length) &&
|
||||
'[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
|
||||
)}
|
||||
key={group.id}
|
||||
>
|
||||
<MessageRenderBoundary resetKey={structuralSignature}>
|
||||
{group.kind === 'turn' ? (
|
||||
<div
|
||||
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
|
||||
data-slot="aui_turn-pair"
|
||||
>
|
||||
{group.indices.map(index => (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
|
||||
)}
|
||||
</MessageRenderBoundary>
|
||||
</div>
|
||||
)),
|
||||
[visibleGroups, components, structuralSignature]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
|
||||
|
|
@ -380,46 +460,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
{t.assistant.thread.showEarlier}
|
||||
</button>
|
||||
)}
|
||||
{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.
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)',
|
||||
isVirtualizedGroup(indexInVisible, visibleGroups.length) &&
|
||||
'[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
|
||||
)}
|
||||
key={group.id}
|
||||
>
|
||||
<MessageRenderBoundary resetKey={messageSignature}>
|
||||
{group.kind === 'turn' ? (
|
||||
<div
|
||||
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
|
||||
data-slot="aui_turn-pair"
|
||||
>
|
||||
{group.indices.map(index => (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
|
||||
)}
|
||||
</MessageRenderBoundary>
|
||||
</div>
|
||||
))}
|
||||
{rows}
|
||||
{loadingIndicator}
|
||||
{clampToComposer && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -87,8 +87,20 @@ const ThinkingDisclosure: FC<{
|
|||
return
|
||||
}
|
||||
|
||||
const pin = () => {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>('[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')
|
||||
|
|
|
|||
|
|
@ -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 <track>`, 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 <px>`. 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout> | 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<typeof setTimeout> | 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 {}
|
||||
|
|
@ -46,6 +46,12 @@ const counts = new Map<string, RenderRecord>()
|
|||
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<string, number>()
|
||||
|
||||
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<string, unknown> | null | undefined
|
||||
const next = fiber.memoizedProps as Record<string, unknown> | 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<RenderRecord & { name: string }>
|
||||
/** 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, number> | 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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue