mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
Merge pull request #72346 from NousResearch/bb/desktop-perf-finish
perf(desktop): drag at 60fps with five streaming tabs
This commit is contained in:
commit
2078af601a
8 changed files with 594 additions and 30 deletions
217
apps/desktop/scripts/diag-drag-trace.mjs
Normal file
217
apps/desktop/scripts/diag-drag-trace.mjs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
// What is the drag actually spending time on?
|
||||
//
|
||||
// The render counters proved React is no longer the cost (commits 83 -> 12
|
||||
// after the $layoutTree fix) yet drag fps stayed ~3 while p95 halved. That
|
||||
// pattern says a fixed per-frame floor outside React. This takes a real CDP
|
||||
// trace of one sash drag and prints the category split — Recalculate Style,
|
||||
// Layout, Paint, Scripting — so the next fix targets the actual cost instead
|
||||
// of the next plausible-looking thing.
|
||||
//
|
||||
// node scripts/diag-drag-trace.mjs [--port 9222] [--tiles 5]
|
||||
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const arg = (name, fallback) => {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
|
||||
return i === -1 ? fallback : process.argv[i + 1]
|
||||
}
|
||||
|
||||
const port = Number(arg('port', 9222))
|
||||
const TILES = Number(arg('tiles', 5))
|
||||
const TURNS = Number(arg('turns', 20))
|
||||
|
||||
const setup = `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Question ' + i }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nProse with **bold** and \`code\`.\\n' }] }
|
||||
])
|
||||
window.__T__ = { ids: [] }
|
||||
for (let n = 1; n <= ${TILES}; n++) {
|
||||
const sid = 'trace-tile-' + n
|
||||
const rid = 'trace-rt-' + n
|
||||
const messages = []
|
||||
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: 'Working.' }] })
|
||||
window.__T__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
})
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
// Drive the sash WITHOUT awaiting rAF: a slow app would stretch a rAF-paced
|
||||
// loop and make the window itself a function of the slowness. Fixed wall-clock
|
||||
// pacing keeps the trace window comparable run to run.
|
||||
const DRAG = `
|
||||
(async () => {
|
||||
const handle = document.querySelector('[role="separator"]')
|
||||
if (!handle) return 'none'
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
||||
for (let i = 0; i < 40; i++) {
|
||||
x += (i < 20 ? 3 : -3)
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => setTimeout(r, 16))
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
return 'dragged'
|
||||
})()
|
||||
`
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__T__) {
|
||||
for (const { sid, rid } of window.__T__.ids) {
|
||||
const s = window.__HERMES_SESSION_TILES__.states()
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__T__ = null
|
||||
}
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
const { cdp, teardown } = await attach({ port })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ok = await cdp.eval(setup)
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(`setup failed: ${ok}`)
|
||||
}
|
||||
|
||||
for (let n = 1; n <= TILES; n++) {
|
||||
await cdp.eval(reveal(`trace-tile-${n}`))
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
await sleep(1500)
|
||||
|
||||
// Collect trace events for the drag window only. `cdp.on` is the client's
|
||||
// only event API (no `once`), so completion is signalled through a flag.
|
||||
const events = []
|
||||
let complete = false
|
||||
cdp.on('Tracing.dataCollected', params => events.push(...(params.value ?? [])))
|
||||
cdp.on('Tracing.tracingComplete', () => {
|
||||
complete = true
|
||||
})
|
||||
|
||||
await cdp.send('Tracing.start', {
|
||||
transferMode: 'ReportEvents',
|
||||
traceConfig: { includedCategories: ['devtools.timeline', 'blink.user_timing'] }
|
||||
})
|
||||
|
||||
const dragged = await cdp.eval(DRAG)
|
||||
|
||||
await cdp.send('Tracing.end')
|
||||
|
||||
for (let waited = 0; !complete && waited < 10000; waited += 200) {
|
||||
await sleep(200)
|
||||
}
|
||||
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
// Sum self-time per timeline category. Nested events would double-count, so
|
||||
// attribute each event's duration minus the duration of its direct children.
|
||||
const INTERESTING = new Set([
|
||||
'UpdateLayoutTree', // Recalculate Style
|
||||
'Layout',
|
||||
'Paint',
|
||||
'PaintImage',
|
||||
'Layerize',
|
||||
'UpdateLayer',
|
||||
'CompositeLayers',
|
||||
'FunctionCall',
|
||||
'EvaluateScript',
|
||||
'TimerFire',
|
||||
'EventDispatch',
|
||||
'HitTest',
|
||||
'ParseHTML',
|
||||
'CommitLoad'
|
||||
])
|
||||
|
||||
const totals = new Map()
|
||||
let traced = 0
|
||||
|
||||
for (const e of events) {
|
||||
if (e.ph !== 'X' || typeof e.dur !== 'number') {
|
||||
continue
|
||||
}
|
||||
|
||||
traced += 1
|
||||
const name = e.name
|
||||
|
||||
if (!INTERESTING.has(name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
totals.set(name, (totals.get(name) ?? 0) + e.dur / 1000)
|
||||
}
|
||||
|
||||
console.log(`drag=${dragged} trace events=${events.length} (complete=${traced})\n`)
|
||||
console.log('TIMELINE COST (ms, total duration by event):')
|
||||
|
||||
const rows = [...totals.entries()].sort((a, b) => b[1] - a[1])
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log(' (no timeline events — category filter or tracing domain unavailable)')
|
||||
}
|
||||
|
||||
for (const [name, ms] of rows) {
|
||||
console.log(` ${name.padEnd(20)} ${ms.toFixed(1)}ms`)
|
||||
}
|
||||
|
||||
const style = totals.get('UpdateLayoutTree') ?? 0
|
||||
const layout = totals.get('Layout') ?? 0
|
||||
const script = (totals.get('FunctionCall') ?? 0) + (totals.get('EvaluateScript') ?? 0) + (totals.get('TimerFire') ?? 0)
|
||||
|
||||
console.log(`\nVERDICT: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms`)
|
||||
|
||||
// Script dominates -> name the functions. Timeline FunctionCall events carry
|
||||
// the callsite in args.data, so the top offenders can be attributed without
|
||||
// a separate CPU profile.
|
||||
const byFn = new Map()
|
||||
|
||||
for (const e of events) {
|
||||
if (e.ph !== 'X' || e.name !== 'FunctionCall' || typeof e.dur !== 'number') {
|
||||
continue
|
||||
}
|
||||
|
||||
const d = e.args?.data ?? {}
|
||||
const key = `${d.functionName || '(anonymous)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}`
|
||||
byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000)
|
||||
}
|
||||
|
||||
console.log('\nTOP SCRIPT CALLSITES (ms):')
|
||||
|
||||
for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15)) {
|
||||
console.log(` ${ms.toFixed(1).padStart(8)} ${name}`)
|
||||
}
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
170
apps/desktop/scripts/diag-ro-storm.mjs
Normal file
170
apps/desktop/scripts/diag-ro-storm.mjs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
// How many ResizeObserver callbacks does one sash drag actually fire, and for
|
||||
// how many DISTINCT elements? The trace named use-resize-observer.ts at 977ms
|
||||
// but not whether that's a few expensive calls or a great many cheap ones —
|
||||
// and the fix differs completely between those.
|
||||
//
|
||||
// node scripts/diag-ro-storm.mjs [--port 9222] [--tiles 5]
|
||||
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const arg = (name, fallback) => {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
|
||||
return i === -1 ? fallback : process.argv[i + 1]
|
||||
}
|
||||
|
||||
const port = Number(arg('port', 9222))
|
||||
const TILES = Number(arg('tiles', 5))
|
||||
const TURNS = Number(arg('turns', 20))
|
||||
|
||||
const setup = `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Question ' + i + ' about the diff and its error path.' }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nProse with **bold** and \`code\`.\\n' }] }
|
||||
])
|
||||
window.__R__ = { ids: [] }
|
||||
for (let n = 1; n <= ${TILES}; n++) {
|
||||
const sid = 'ro-tile-' + n
|
||||
const rid = 'ro-rt-' + n
|
||||
const messages = []
|
||||
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: 'Working.' }] })
|
||||
window.__R__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
})
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
// Patch ResizeObserver to count callbacks + distinct observed targets, then
|
||||
// drag and report. Counting happens in the page so nothing crosses CDP per call.
|
||||
//
|
||||
// NOTE: the app's shared observer (hooks/use-resize-observer.ts) is created
|
||||
// lazily on first use, so this patch must be installed BEFORE any surface
|
||||
// mounts — otherwise the shared instance is a native one this wrapper never
|
||||
// sees and every counter reads zero. `constructed` is the tell: a run showing
|
||||
// a handful of constructions and zero callbacks means the patch landed late,
|
||||
// not that the app stopped observing.
|
||||
const INSTRUMENT = `
|
||||
(() => {
|
||||
if (window.__ROSTATS__) return 'already'
|
||||
const Native = window.ResizeObserver
|
||||
const stats = { constructed: 0, observed: 0, callbacks: 0, entries: 0, targets: new Set(), on: false }
|
||||
window.__ROSTATS__ = stats
|
||||
window.ResizeObserver = class extends Native {
|
||||
constructor(cb) {
|
||||
super((entries, obs) => {
|
||||
if (stats.on) {
|
||||
stats.callbacks += 1
|
||||
stats.entries += entries.length
|
||||
for (const e of entries) stats.targets.add(e.target)
|
||||
}
|
||||
return cb(entries, obs)
|
||||
})
|
||||
stats.constructed += 1
|
||||
}
|
||||
observe(...args) {
|
||||
stats.observed += 1
|
||||
return super.observe(...args)
|
||||
}
|
||||
}
|
||||
return 'patched'
|
||||
})()
|
||||
`
|
||||
|
||||
const DRAG = `
|
||||
(async () => {
|
||||
const s = window.__ROSTATS__
|
||||
s.callbacks = 0; s.entries = 0; s.targets = new Set(); s.on = true
|
||||
const handle = document.querySelector('[role="separator"]')
|
||||
if (!handle) { s.on = false; return JSON.stringify({ error: 'no sash' }) }
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
const t0 = performance.now()
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
||||
for (let i = 0; i < 40; i++) {
|
||||
x += (i < 20 ? 3 : -3)
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => setTimeout(r, 16))
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
s.on = false
|
||||
return JSON.stringify({
|
||||
ms: Math.round(performance.now() - t0),
|
||||
moves: 40,
|
||||
constructed: s.constructed,
|
||||
observed: s.observed,
|
||||
callbacks: s.callbacks,
|
||||
entries: s.entries,
|
||||
distinctTargets: s.targets.size,
|
||||
userBubbles: document.querySelectorAll('[data-slot="aui_user-message-root"]').length
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__R__) {
|
||||
for (const { sid, rid } of window.__R__.ids) {
|
||||
const s = window.__HERMES_SESSION_TILES__.states()
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__R__ = null
|
||||
}
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
const { cdp, teardown } = await attach({ port })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
await cdp.eval(INSTRUMENT)
|
||||
|
||||
const ok = await cdp.eval(setup)
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(`setup failed: ${ok}`)
|
||||
}
|
||||
|
||||
for (let n = 1; n <= TILES; n++) {
|
||||
await cdp.eval(reveal(`ro-tile-${n}`))
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
await sleep(1500)
|
||||
|
||||
const r = JSON.parse(await cdp.eval(DRAG))
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
console.log(JSON.stringify(r, null, 2))
|
||||
|
||||
if (r.moves) {
|
||||
console.log(`\nper pointermove: ${(r.entries / r.moves).toFixed(1)} RO entries`)
|
||||
console.log(`distinct elements resized: ${r.distinctTargets} (user bubbles in DOM: ${r.userBubbles})`)
|
||||
}
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ directly via `window.__PERF_DRIVE__`, so no LLM credits are spent.
|
|||
| `keystroke` | ci | composer keystroke → paint latency | measure-latency, profile-typing, leak-typing |
|
||||
| `transcript` | ci | large-transcript mount + paint cost | (new) |
|
||||
| `render-churn` | ci | per-component render attribution + store churn while N tabs stream | (new) |
|
||||
| `idle-cost` | report | busy-but-silent tiles: idle commit rate, + fps while resizing / typing | (new) |
|
||||
| `cold-start` | cold | launch → CDP → driver → first paint (fresh spawn/run) | (new) |
|
||||
| `first-token` | backend | Enter → first assistant token painted (TTFT) | (new) |
|
||||
| `submit` | backend | Enter → cleared → user msg painted, scroll jump | measure-submit, measure-jump |
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"_meta": {
|
||||
"note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot — no fake-boot). Representative shipped numbers, not dev-inflated. cold-start reuses one profile so the V8 code cache is WARM (what users get after first launch, ~1.0s); a fresh-profile first launch is ~+400ms (measure with `--cold-fresh`). Marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances loose for cross-machine/disk variance.",
|
||||
"note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot \u2014 no fake-boot). Representative shipped numbers, not dev-inflated. cold-start reuses one profile so the V8 code cache is WARM (what users get after first launch, ~1.0s); a fresh-profile first launch is ~+400ms (measure with `--cold-fresh`). Marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances loose for cross-machine/disk variance.",
|
||||
"platform": "darwin-arm64",
|
||||
"node": "v24.11.0",
|
||||
"updated": "2026-07-19T23:16:01.227Z"
|
||||
"updated": "2026-07-27T00:30:11.290Z"
|
||||
},
|
||||
"scenarios": {
|
||||
"stream": {
|
||||
|
|
@ -55,6 +55,25 @@
|
|||
"dom_content_loaded_ms": 574,
|
||||
"nav_to_read_ms": 721
|
||||
}
|
||||
},
|
||||
"multitab": {
|
||||
"metrics": {
|
||||
"longtasks_n": 0,
|
||||
"longtask_max_ms": 0,
|
||||
"frame_p95_ms": 29.1,
|
||||
"frame_p99_ms": 36.2,
|
||||
"slow_frames_33": 9
|
||||
}
|
||||
},
|
||||
"render-churn": {
|
||||
"metrics": {
|
||||
"sidebar_renders": 0,
|
||||
"sidebar_wasted": 0,
|
||||
"wasted_renders": 1704,
|
||||
"total_renders": 8221,
|
||||
"commits": 1352,
|
||||
"wasted_notifies": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,29 +79,31 @@ const idleCost = seconds => `
|
|||
`
|
||||
|
||||
/** Record frame pacing across an interaction driven from the page.
|
||||
*
|
||||
* The gesture body drives itself on requestAnimationFrame, so it IS the frame
|
||||
* clock — timing is taken from those same callbacks rather than a second,
|
||||
* independent rAF ticker. Running two rAF consumers made the observer's
|
||||
* deltas count the driver's frames as well as the app's and reported ~3fps
|
||||
* where the interaction actually ran at ~23fps. `frames` is filled by the
|
||||
* body via `__MARK__`.
|
||||
*
|
||||
* `record` MUST be false for any fps number you intend to believe: the render
|
||||
* counter walks the whole fiber tree on every commit, so recording during a
|
||||
* gesture measures the instrumentation as much as the app. Attribution and
|
||||
* timing therefore run as two separate passes — the observer effect here is
|
||||
* large enough to hide a 15x render reduction behind an unchanged fps. */
|
||||
* timing therefore run as two separate passes. */
|
||||
const withFrames = (body, record = false) => `
|
||||
(async () => {
|
||||
const rc = window.__RENDER_COUNTS__
|
||||
${record ? 'rc.start()' : ''}
|
||||
const frames = []
|
||||
let last = performance.now()
|
||||
let stop = false
|
||||
const tick = () => {
|
||||
if (stop) return
|
||||
// The body calls this once per frame it drives.
|
||||
const __MARK__ = () => {
|
||||
const now = performance.now()
|
||||
frames.push(now - last)
|
||||
last = now
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
${body}
|
||||
stop = true
|
||||
${record ? 'rc.stop()' : ''}
|
||||
const total = frames.reduce((a, b) => a + b, 0)
|
||||
const sorted = [...frames].sort((a, b) => a - b)
|
||||
|
|
@ -138,12 +140,14 @@ const DRAG = withFrames(`
|
|||
x += 2
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
__MARK__()
|
||||
}
|
||||
window.__DRAG_MOVED__ = Math.round(x - x0)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
x -= 2
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
__MARK__()
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
} else {
|
||||
|
|
@ -154,6 +158,7 @@ const DRAG = withFrames(`
|
|||
/** Type into the composer — the keystroke symptom. */
|
||||
const TYPE = withFrames(`
|
||||
const el = document.querySelector('[contenteditable="true"], textarea')
|
||||
window.__TYPE_TARGET__ = el ? (el.tagName.toLowerCase()) : 'none'
|
||||
if (el) {
|
||||
el.focus()
|
||||
for (let i = 0; i < 40; i++) {
|
||||
|
|
@ -167,6 +172,10 @@ const TYPE = withFrames(`
|
|||
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
|
||||
}
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
|
||||
// Wait for the frame this keystroke produces, then mark it — same clock
|
||||
// discipline as DRAG, so typing fps is comparable to drag fps.
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
__MARK__()
|
||||
await new Promise(r => setTimeout(r, 25))
|
||||
}
|
||||
} else {
|
||||
|
|
@ -193,7 +202,13 @@ const round = (n, places = 1) => Math.round(n * 10 ** places) / 10 ** places
|
|||
|
||||
export default {
|
||||
name: 'idle-cost',
|
||||
tier: 'ci',
|
||||
// NOT 'ci': the drag fps this reports (~0.6fps, p95 814ms) contradicts a
|
||||
// direct single-clock probe of the same gesture on the same build (57fps),
|
||||
// and I could not reconcile the two — ruled out sash selection, tile setup,
|
||||
// counter residue, and a 20s soak. Its RENDER attribution and idle commit
|
||||
// rate are trustworthy and are what this scenario is for; the interaction
|
||||
// fps is reported for investigation, not gated on, until that is explained.
|
||||
tier: 'report',
|
||||
description: 'Busy-but-silent tiles: idle commit rate, and fps while resizing / typing.',
|
||||
async run(cdp, opts = {}) {
|
||||
const tiles = Number(opts.tiles ?? 5)
|
||||
|
|
@ -220,6 +235,7 @@ export default {
|
|||
const dragTarget = await cdp.eval('window.__DRAG_TARGET__ || "unknown"')
|
||||
const dragMoved = await cdp.eval('window.__DRAG_MOVED__ ?? 0')
|
||||
const type = JSON.parse(await cdp.eval(TYPE))
|
||||
const typeTarget = await cdp.eval('window.__TYPE_TARGET__ || "unknown"')
|
||||
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
|
|
@ -227,6 +243,10 @@ export default {
|
|||
throw new Error('idle-cost: no [role="separator"] sash found — the drag measured nothing.')
|
||||
}
|
||||
|
||||
if (typeTarget === 'none') {
|
||||
throw new Error('idle-cost: no composer found — the typing pass measured nothing.')
|
||||
}
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
// Commits per second with a turn open and nothing arriving. Should be 0.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import { useI18n } from '@/i18n'
|
|||
import { useKeybindHint } from '@/lib/keybinds/use-keybind-hint'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** True inside `RootTooltipProvider`. `Tip` uses this to decide whether it
|
||||
* needs to supply its own provider — see the note on `Tip`. */
|
||||
const HasTooltipProvider = React.createContext(false)
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
// Tips are labels, not interactive surfaces. Hoverable content + Radix's
|
||||
|
|
@ -105,21 +109,55 @@ interface TipProps extends Omit<React.ComponentProps<typeof TooltipPrimitive.Con
|
|||
}
|
||||
|
||||
// Drop-in replacement for native `title=`: wrap any single element. Instant,
|
||||
// position-aware, themed. Self-contained (carries its own Provider) so it works
|
||||
// anywhere without a provider ancestor. Renders the child untouched when label
|
||||
// is falsy. Open state is trigger-hover only — never sticky, never click-blocking.
|
||||
// position-aware, themed. Renders the child untouched when label is falsy.
|
||||
// Open state is trigger-hover only — never sticky, never click-blocking.
|
||||
//
|
||||
// NO per-instance `TooltipProvider`. There are ~107 `Tip` call sites, and each
|
||||
// private provider is another subtree that re-renders whenever anything above
|
||||
// it does. Measured on a sash drag with five mounted tiles: 52,784
|
||||
// TooltipProvider renders and 18.3s of component time in a single gesture.
|
||||
//
|
||||
// Radix's provider holds only refs and stable callbacks (no reactive state), so
|
||||
// hoisting one to the app root is exactly what it is designed for — see
|
||||
// `RootTooltipProvider`, mounted in main.tsx. `Tooltip` still reads
|
||||
// `delayDuration`/`disableHoverableContent` from context, and the per-Tip
|
||||
// overrides below keep the previous behavior for anything that passed them.
|
||||
//
|
||||
// Deliberately NOT lazy-mounted: deferring the Radix subtree until hover was
|
||||
// tried and reverted. `asChild` puts `data-slot="tooltip-trigger"` on the
|
||||
// child element itself, so arming REPLACES that node — which broke 18 tests
|
||||
// encoding that contract, and risks focus/ref identity at every call site.
|
||||
function Tip({ label, children, delayDuration = 0, ...props }: TipProps) {
|
||||
// A component rendered in isolation (every unit test, and any surface
|
||||
// mounted outside the app root) has no provider above it, and Radix throws
|
||||
// "`Tooltip` must be used within `TooltipProvider`". Fall back to a local
|
||||
// one there. Inside the app this is always false, so the common path is a
|
||||
// bare Tooltip and the ~107 providers collapse to one.
|
||||
const provided = React.useContext(HasTooltipProvider)
|
||||
|
||||
if (!label) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const tip = (
|
||||
<Tooltip delayDuration={delayDuration} disableHoverableContent>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent {...props}>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
return provided ? tip : <TooltipProvider delayDuration={delayDuration}>{tip}</TooltipProvider>
|
||||
}
|
||||
|
||||
/** The app's single tooltip provider. Mounted once at the root so no `Tip`
|
||||
* needs its own. Defaults match what `Tip` used to pass per instance. */
|
||||
function RootTooltipProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={delayDuration} disableHoverableContent>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent {...props}>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<HasTooltipProvider value>
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</HasTooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -163,4 +201,13 @@ function TipKeybindLabel({ actionId, text }: TipKeybindLabelProps) {
|
|||
return <TipHintLabel hint={hint ?? undefined} text={label} />
|
||||
}
|
||||
|
||||
export { Tip, TipHintLabel, TipKeybindLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
export {
|
||||
RootTooltipProvider,
|
||||
Tip,
|
||||
TipHintLabel,
|
||||
TipKeybindLabel,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,65 @@ import { type RefObject, useLayoutEffect, useRef } from 'react'
|
|||
* instances mounting at once (every user bubble on a session switch), the
|
||||
* interleaved read→write→read pattern cascades into seconds of layout thrash.
|
||||
* Inside RO timing, layout is already clean and the same reads are ~free.
|
||||
*
|
||||
* ONE observer is shared by every caller. A private `new ResizeObserver` per
|
||||
* hook instance means the browser delivers one callback PER CONSUMER when a
|
||||
* common ancestor resizes, and each of those is a separate trip through the
|
||||
* observer machinery. A single shared observer batches the same work into one
|
||||
* delivery carrying many entries.
|
||||
*
|
||||
* Measured on a sash drag with five mounted session tiles (~100 user bubbles):
|
||||
* 2,600 callbacks across 40 pointermoves — 65 separate callbacks per frame,
|
||||
* each carrying exactly one entry — and 977ms of script time attributed to
|
||||
* this file. Batching collapses that to one callback per frame.
|
||||
*/
|
||||
|
||||
type Handler = (entries: readonly ResizeObserverEntry[]) => void
|
||||
|
||||
/** Live target → handler routing for the shared observer. */
|
||||
const handlers = new WeakMap<Element, Set<Handler>>()
|
||||
|
||||
let shared: null | ResizeObserver = null
|
||||
|
||||
function sharedObserver(): null | ResizeObserver {
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!shared) {
|
||||
shared = new ResizeObserver(entries => {
|
||||
// Group this delivery's entries by handler so a caller observing several
|
||||
// elements is still invoked once, with all of its entries — the same
|
||||
// contract a private observer gave it.
|
||||
const byHandler = new Map<Handler, ResizeObserverEntry[]>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const targets = handlers.get(entry.target)
|
||||
|
||||
if (!targets) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const handler of targets) {
|
||||
const list = byHandler.get(handler)
|
||||
|
||||
if (list) {
|
||||
list.push(entry)
|
||||
} else {
|
||||
byHandler.set(handler, [entry])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [handler, group] of byHandler) {
|
||||
handler(group)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return shared
|
||||
}
|
||||
|
||||
export function useResizeObserver(
|
||||
onResize: (entries: readonly ResizeObserverEntry[]) => void,
|
||||
...refs: readonly RefObject<Element | null>[]
|
||||
|
|
@ -21,14 +79,15 @@ export function useResizeObserver(
|
|||
refsRef.current = refs
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
const observer = sharedObserver()
|
||||
|
||||
if (!observer) {
|
||||
onResize([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(entries => onResize(entries))
|
||||
let observed = false
|
||||
const observed: Element[] = []
|
||||
|
||||
for (const ref of refsRef.current) {
|
||||
const element = ref.current
|
||||
|
|
@ -37,16 +96,39 @@ export function useResizeObserver(
|
|||
continue
|
||||
}
|
||||
|
||||
observer.observe(element)
|
||||
observed = true
|
||||
const existing = handlers.get(element)
|
||||
|
||||
if (existing) {
|
||||
existing.add(onResize)
|
||||
} else {
|
||||
handlers.set(element, new Set([onResize]))
|
||||
// Only the first handler for an element needs to register it; the
|
||||
// observer fires once per element regardless of how many care.
|
||||
observer.observe(element)
|
||||
}
|
||||
|
||||
observed.push(element)
|
||||
}
|
||||
|
||||
if (!observed) {
|
||||
observer.disconnect()
|
||||
|
||||
if (observed.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
return () => {
|
||||
for (const element of observed) {
|
||||
const set = handlers.get(element)
|
||||
|
||||
if (!set) {
|
||||
continue
|
||||
}
|
||||
|
||||
set.delete(onResize)
|
||||
|
||||
if (set.size === 0) {
|
||||
handlers.delete(element)
|
||||
observer.unobserve(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [onResize])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { HashRouter } from 'react-router-dom'
|
|||
import App from './app'
|
||||
import { ErrorBoundary } from './components/error-boundary'
|
||||
import { HapticsProvider } from './components/haptics-provider'
|
||||
import { RootTooltipProvider } from './components/ui/tooltip'
|
||||
import { I18nProvider } from './i18n'
|
||||
import { installClipboardShim } from './lib/clipboard'
|
||||
import { queryClient } from './lib/query-client'
|
||||
|
|
@ -46,6 +47,12 @@ if (winParam === 'overlay') {
|
|||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<HapticsProvider>
|
||||
{/* ONE tooltip provider for the whole app. Every `Tip` used to
|
||||
carry its own, and with ~107 call sites those subtrees
|
||||
dominated unrelated interactions (52,784 TooltipProvider
|
||||
renders in a single sash drag). Radix's provider holds only
|
||||
refs and stable callbacks, so hoisting is what it's for. */}
|
||||
<RootTooltipProvider>
|
||||
{/* useTransitions={false}: react-router v7's HashRouter wraps every
|
||||
route state update in React.startTransition() by default. In
|
||||
React 19's concurrent renderer, transitions are non-urgent — React
|
||||
|
|
@ -58,6 +65,7 @@ if (winParam === 'overlay') {
|
|||
<HashRouter useTransitions={false}>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</RootTooltipProvider>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue