Merge pull request #72346 from NousResearch/bb/desktop-perf-finish

perf(desktop): drag at 60fps with five streaming tabs
This commit is contained in:
brooklyn! 2026-07-26 20:15:35 -05:00 committed by GitHub
commit 2078af601a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 594 additions and 30 deletions

View 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?.()
}

View 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?.()
}

View file

@ -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 |

View file

@ -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
}
}
}
}

View file

@ -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.

View file

@ -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
}

View file

@ -12,7 +12,65 @@ import { type RefObject, useLayoutEffect, useRef } from 'react'
* instances mounting at once (every user bubble on a session switch), the
* interleaved readwriteread 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])
}

View file

@ -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>