perf(desktop): kill the layout-thrash cascade on session switch (#66033)

Follow-up to #65890 (router transitions off) and #65898 (structural
compare + first-paint budget): profiling the switch path on real 1000+-
message sessions with a new CDP harness showed the remaining freeze is
NOT markdown rendering — it's a forced-reflow cascade from mount-time
layout reads interleaved with style writes across the transcript's
layout effects, plus the first-paint budget cut landing too late to
stop the full-budget commit.

Measured on the two largest local sessions (996 and 1363 messages),
main-thread longtask totals per switch: warm 2450ms -> 557ms and
1158ms -> 194ms; first paint 1690ms -> 444ms. Harness:
scripts/profile-session-switch.mjs (same CDP family as
profile-real-stream.mjs).

- use-resize-observer: drop the synchronous initial callback and ride
  the observer's spec-guaranteed first delivery instead (same frame,
  after layout, before paint). The sync call ran while the commit's
  layout was dirty, so every size read in a callback forced a full
  reflow — with one instance per user bubble (measureClamp read
  scrollHeight, then WROTE --human-msg-full, re-dirtying layout for the
  next bubble), the switch commit thrashed for over a second. Inside RO
  timing the same reads are free. Composer metrics (2x
  getBoundingClientRect + documentElement style writes) rides the same
  fix.
- Same class, same fix at the remaining call sites profiling surfaced:
  ExpandableBlock and TerminalOutput (dozens per tool-heavy transcript)
  now measure/pin via RO initial delivery; the tool-window and
  thinking-preview pins drop their sync pin() call; the thread
  timeline's initial active-tick compute joins its existing
  scroll-time rAF batching so back-to-back transcript updates coalesce.
- thread/list: cut the render budget in the RENDER phase (state-from-
  props adjustment) instead of the post-commit layout effect. The
  effect-time cut was too late — on a warm switch React first built and
  committed the full 300-part tree, then re-rendered at 60, then bumped
  back to 300, so the expensive commit still happened (and on a cold
  switch the bump rAF usually fired while the transcript was still
  empty, so the prefetched messages rendered at full budget anyway).
  The render-phase cut restarts the component before any child renders;
  a second trigger handles the cold path where messages land later
  under the same sessionKey.
- thread/list: backfill 60 -> 300 inside startTransition so the older
  turns' markdown+shiki render is interruptible background work instead
  of a synchronous freeze one frame after the switch paints. Functional
  Math.max so an urgent "Show earlier" click can't be rebased back down.
- composer focus: skip the rAF/timeout focus retries when the element
  is already focused — focus() runs the full focusing steps (forcing
  layout) even on the active element, ~585ms per switch on a large
  dirty DOM.
- Replace the tautological render-budget test (it re-declared the
  constants locally and asserted 60 < 300) with behavior tests of the
  now-exported buildGroups + firstVisibleGroupIndex.

Verification: apps/desktop `npx tsc --noEmit` clean; full
`npx vitest run` 210 files / 1763 passed; manual CDP check confirms the
deferred backfill commits the full transcript, stays pinned to bottom,
and "Show earlier" still pages.
This commit is contained in:
brooklyn! 2026-07-16 22:46:08 -04:00 committed by GitHub
parent 3951d769fb
commit c856f36459
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 362 additions and 71 deletions

View file

@ -0,0 +1,163 @@
// CPU-profile a session switch — outputs a .cpuprofile, a top-self ranking,
// longtask timings, and paint milestones for cold + warm switches.
//
// Drives the real resume path by setting location.hash (same code path as a
// sidebar click: use-route-resume → resumeSession → prefetch + resume RPC).
//
// Usage:
// node apps/desktop/scripts/profile-session-switch.mjs <sessionA> <sessionB> [rounds]
// OUT=/tmp/switch.cpuprofile node scripts/profile-session-switch.mjs 2026.. 2026..
import { writeFileSync } from 'node:fs'
const CDP_HTTP = 'http://127.0.0.1:9222'
const A = process.argv[2]
const B = process.argv[3]
const ROUNDS = Number(process.argv[4] || 2)
const OUT = process.env.OUT || `/tmp/session-switch-${Date.now()}.cpuprofile`
const SETTLE_TIMEOUT = Number(process.env.SETTLE_TIMEOUT || 30000)
if (!A || !B) {
console.error('usage: profile-session-switch.mjs <sessionA> <sessionB> [rounds]')
process.exit(1)
}
class CDP {
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map() }
static async open(url) {
const ws = new WebSocket(url)
await new Promise((r) => ws.addEventListener('open', r, { once: true }))
const cdp = new CDP(ws)
ws.addEventListener('message', (ev) => {
const m = JSON.parse(ev.data.toString())
if (m.id != null && cdp.pending.has(m.id)) {
const { resolve, reject } = cdp.pending.get(m.id)
cdp.pending.delete(m.id)
if (m.error) reject(new Error(m.error.message))
else resolve(m.result)
}
})
return cdp
}
send(method, params) {
const id = ++this.id
return new Promise((res, rej) => {
this.pending.set(id, { resolve: res, reject: rej })
this.ws.send(JSON.stringify({ id, method, params }))
})
}
async eval(expr) {
const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval failed')
return r.result.value
}
close() { this.ws.close() }
}
async function main() {
const list = await (await fetch(`${CDP_HTTP}/json`)).json()
const target = list.find((t) => t.type === 'page' && /5174/.test(t.url))
if (!target) { console.error('renderer not found on 9222'); process.exit(1) }
const cdp = await CDP.open(target.webSocketDebuggerUrl)
// Install observers once: longtasks + rAF frame gaps, tagged per switch.
await cdp.eval(`(() => {
if (window.__SWITCH_OBS__) return 'already'
const obs = { longtasks: [], marks: [] }
new PerformanceObserver((l) => {
for (const e of l.getEntries()) obs.longtasks.push({ t: e.startTime, dur: e.duration })
}).observe({ entryTypes: ['longtask'] })
window.__SWITCH_OBS__ = obs
return 'installed'
})()`)
const switchTo = async (sid, label) => {
const t0 = await cdp.eval(`(() => {
const o = window.__SWITCH_OBS__
o.marks.push({ label: ${JSON.stringify(label)}, sid: ${JSON.stringify(sid)}, t: performance.now() })
location.hash = '#/' + ${JSON.stringify(sid)}
return performance.now()
})()`)
// Poll until the transcript for this session has painted and settled:
// route matches, >0 message roots, and message count stable for 3 polls.
const deadline = Date.now() + SETTLE_TIMEOUT
let stable = 0
let lastCount = -1
let firstPaintT = null
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 50))
const s = await cdp.eval(`({
t: performance.now(),
route: location.hash,
msgs: document.querySelectorAll('[data-slot="aui_message"], [data-slot="aui_assistant-message-root"], [data-slot="aui_user-message-root"]').length,
parts: document.querySelectorAll('[data-slot="aui_thread-content"] *').length
})`)
if (!s.route.includes(sid)) continue
if (s.msgs > 0 && firstPaintT === null) firstPaintT = s.t
stable = s.msgs === lastCount && s.msgs > 0 ? stable + 1 : 0
lastCount = s.msgs
if (stable >= 3) return { t0, firstPaintT, settledT: s.t, msgs: s.msgs, domNodes: s.parts }
}
return { t0, firstPaintT, settledT: null, msgs: lastCount, timedOut: true }
}
console.log('starting CPU profile')
await cdp.send('Profiler.enable')
await cdp.send('Profiler.setSamplingInterval', { interval: 100 })
await cdp.send('Profiler.start')
const results = []
for (let round = 0; round < ROUNDS; round++) {
for (const [sid, tag] of [[A, 'A'], [B, 'B']]) {
const label = `round${round}:${tag}:${round === 0 ? 'cold' : 'warm'}`
const r = await switchTo(sid, label)
results.push({ label, sid, ...r })
const ftp = r.firstPaintT != null ? (r.firstPaintT - r.t0).toFixed(0) : 'n/a'
const st = r.settledT != null ? (r.settledT - r.t0).toFixed(0) : 'TIMEOUT'
console.log(`${label.padEnd(18)} first-paint ${String(ftp).padStart(6)} ms settled ${String(st).padStart(6)} ms msgs ${r.msgs} dom ${r.domNodes ?? '?'}`)
await new Promise((r2) => setTimeout(r2, 800))
}
}
const { profile } = await cdp.send('Profiler.stop')
writeFileSync(OUT, JSON.stringify(profile))
console.log('\nwrote', OUT)
// Longtasks per switch window.
const obs = await cdp.eval('window.__SWITCH_OBS__')
console.log('\n=== LONGTASKS (>=50ms main-thread blocks) ===')
for (let i = 0; i < obs.marks.length; i++) {
const m = obs.marks[i]
const end = obs.marks[i + 1]?.t ?? Infinity
const lts = obs.longtasks.filter((lt) => lt.t >= m.t && lt.t < end)
const total = lts.reduce((a, b) => a + b.dur, 0)
console.log(`${m.label.padEnd(18)} ${String(lts.length).padStart(2)} longtasks, ${total.toFixed(0).padStart(5)} ms total ${lts.map((l) => Math.round(l.dur)).join(', ')}`)
}
// Self-time ranking.
const samples = profile.samples || []
const timeDeltas = profile.timeDeltas || []
const nodes = new Map(profile.nodes.map((n) => [n.id, n]))
const selfTime = new Map()
for (let i = 0; i < samples.length; i++) {
selfTime.set(samples[i], (selfTime.get(samples[i]) || 0) + (timeDeltas[i] ?? 0))
}
const ranked = [...selfTime.entries()]
.map(([id, us]) => {
const cf = nodes.get(id)?.callFrame || {}
return { ms: us / 1000, name: cf.functionName || '(anonymous)', url: (cf.url || '').slice(-70), line: cf.lineNumber }
})
.filter((x) => !/\(root\)|\(idle\)|\(garbage collector\)|\(program\)/.test(x.name))
.sort((a, b) => b.ms - a.ms)
.slice(0, 30)
console.log('\n=== TOP 30 SELF TIME (ms) ACROSS ALL SWITCHES ===')
for (const r of ranked) {
console.log(`${r.ms.toFixed(1).padStart(8)} ${r.name.padEnd(44)} ${r.url}:${r.line}`)
}
cdp.close()
}
main().catch((e) => { console.error(e); process.exit(1) })

View file

@ -157,7 +157,14 @@ export const focusComposerInput = (el: HTMLElement | null) => {
return
}
const focus = () => el.focus({ preventScroll: true })
// Skip when already focused: focus() runs the full focusing steps (forcing
// layout) even on the active element, and during a session switch the DOM is
// large and dirty — the redundant retries were measurably expensive there.
const focus = () => {
if (document.activeElement !== el) {
el.focus({ preventScroll: true })
}
}
focus()
window.requestAnimationFrame(focus)

View file

@ -1,26 +1,82 @@
import { describe, expect, it } from 'vitest'
describe('ThreadMessageList render budget constants', () => {
it('defines FIRST_PAINT_BUDGET as smaller than RENDER_BUDGET', () => {
// These constants control the deferred render budget on session switch.
// FIRST_PAINT_BUDGET should be small enough to render quickly (just the
// bottom turn(s) visible after scroll-to-bottom), while RENDER_BUDGET
// is the full cap for "Show earlier" increments and the eventual full
// transcript after the first paint.
const RENDER_BUDGET = 300
const FIRST_PAINT_BUDGET = 60
import { buildGroups, firstVisibleGroupIndex, type MessageGroup } from './list'
expect(FIRST_PAINT_BUDGET).toBeLessThan(RENDER_BUDGET)
expect(FIRST_PAINT_BUDGET).toBeGreaterThan(0)
// Signature rows are `${index}:${id}:${role}:${weight}` (see the useAuiState
// selector in list.tsx).
const signature = (rows: [string, string, number][]) =>
rows.map(([id, role, weight], index) => `${index}:${id}:${role}:${weight}`).join('\n')
describe('buildGroups', () => {
it('returns no groups for an empty signature', () => {
expect(buildGroups('')).toEqual([])
})
it('ensures FIRST_PAINT_BUDGET is sufficient for at least one visible turn', () => {
// A typical bottom turn (user + assistant) might have ~20-40 parts total
// (including tool calls), so 60 is enough for 1-2 visible turns at the
// bottom of the viewport.
const FIRST_PAINT_BUDGET = 60
const TYPICAL_TURN_PARTS = 30
it('groups a user message with the assistant turn(s) that follow it', () => {
const groups = buildGroups(
signature([
['u1', 'user', 1],
['a1', 'assistant', 4],
['a2', 'assistant', 2],
['u2', 'user', 1],
['a3', 'assistant', 3]
])
)
expect(FIRST_PAINT_BUDGET).toBeGreaterThanOrEqual(TYPICAL_TURN_PARTS)
expect(groups).toEqual([
{ id: 'u1', indices: [0, 1, 2], kind: 'turn', weight: 7 },
{ id: 'u2', indices: [3, 4], kind: 'turn', weight: 4 }
])
})
it('keeps leading non-user messages as standalone groups', () => {
const groups = buildGroups(
signature([
['s1', 'system', 1],
['a0', 'assistant', 2],
['u1', 'user', 1],
['a1', 'assistant', 5]
])
)
expect(groups).toEqual([
{ id: 's1', index: 0, kind: 'standalone', weight: 1 },
{ id: 'a0', index: 1, kind: 'standalone', weight: 2 },
{ id: 'u1', indices: [2, 3], kind: 'turn', weight: 6 }
])
})
it('defaults a missing/zero weight to 1', () => {
const groups = buildGroups('0:a:assistant:0')
expect(groups).toEqual([{ id: 'a', index: 0, kind: 'standalone', weight: 1 }])
})
})
describe('firstVisibleGroupIndex', () => {
const group = (id: string, weight: number): MessageGroup => ({ id, index: 0, kind: 'standalone', weight })
it('shows everything when total weight fits the budget', () => {
const groups = [group('a', 10), group('b', 10), group('c', 10)]
expect(firstVisibleGroupIndex(groups, 100)).toBe(0)
})
it('walks newest-first and hides everything before the turn that meets the budget', () => {
const groups = [group('old', 50), group('mid', 30), group('new', 30)]
// newest-first: 30 (new) < 60, +30 (mid) = 60 >= 60 → mid is the first
// visible group, old is hidden.
expect(firstVisibleGroupIndex(groups, 60)).toBe(1)
})
it('keeps whole turns intact — the turn that crosses the budget stays visible', () => {
const groups = [group('old', 5), group('huge', 500)]
expect(firstVisibleGroupIndex(groups, 60)).toBe(1)
})
it('returns groups.length for an empty list', () => {
expect(firstVisibleGroupIndex([], 60)).toBe(0)
})
})

View file

@ -5,6 +5,7 @@ import {
type FC,
memo,
type ReactNode,
startTransition,
useCallback,
useEffect,
useLayoutEffect,
@ -28,7 +29,7 @@ import { MessageRenderBoundary } from '../message-render-boundary'
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
type MessageGroup = { id: string; weight: number } & (
export type MessageGroup = { id: string; weight: number } & (
| { index: number; kind: 'standalone' }
| { indices: number[]; kind: 'turn' }
)
@ -58,7 +59,7 @@ interface ThreadMessageListProps {
// Group each user message with the assistant turn(s) that follow it so the
// human bubble can `position: sticky` against the scroller across its whole
// turn (see StickyHumanMessageContainer in thread.tsx).
function buildGroups(signature: string): MessageGroup[] {
export function buildGroups(signature: string): MessageGroup[] {
if (!signature) {
return []
}
@ -94,6 +95,24 @@ function buildGroups(signature: string): MessageGroup[] {
return groups
}
// Walk turns newest-first, summing their part weights until the budget is met;
// everything before the first kept turn is hidden. Returns the index of that
// first visible group.
export function firstVisibleGroupIndex(groups: readonly MessageGroup[], budget: number): number {
let firstVisible = groups.length
for (let i = groups.length - 1, weight = 0; i >= 0; i--) {
weight += groups[i].weight
firstVisible = i
if (weight >= budget) {
break
}
}
return firstVisible
}
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
clampToComposer,
components,
@ -121,22 +140,59 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
resize: 'instant'
})
const [renderBudget, setRenderBudget] = useState(RENDER_BUDGET)
const [renderBudget, setRenderBudget] = useState(FIRST_PAINT_BUDGET)
// Walk turns newest-first, summing their part weights until the budget is met;
// everything before that first kept turn is hidden.
let firstVisible = groups.length
// Cut the budget during RENDER, not in the post-commit layout effect. An
// effect-time cut is too late: React would first build the whole tree with
// the full budget (up to 300 parts of markdown + syntax highlighting),
// commit it, and only then re-render at the small budget. The render-phase
// state adjustment restarts this component immediately — before any child
// renders — so the heavy commit never happens.
//
// Two triggers, because the transcript swap arrives differently per path:
// a WARM switch publishes sessionKey + messages in one commit (the key
// branch), while a COLD switch changes sessionKey with an empty transcript
// and the prefetched messages land hundreds of ms later under the SAME key
// (the empty→non-empty branch).
const hasGroups = groups.length > 0
const [budgetSessionKey, setBudgetSessionKey] = useState(sessionKey)
const [hadGroups, setHadGroups] = useState(hasGroups)
for (let i = groups.length - 1, weight = 0; i >= 0; i--) {
weight += groups[i].weight
firstVisible = i
if (budgetSessionKey !== sessionKey) {
setBudgetSessionKey(sessionKey)
setHadGroups(hasGroups)
setRenderBudget(FIRST_PAINT_BUDGET)
} else if (hadGroups !== hasGroups) {
setHadGroups(hasGroups)
if (weight >= renderBudget) {
break
if (hasGroups) {
setRenderBudget(FIRST_PAINT_BUDGET)
}
}
const hiddenCount = firstVisible
// Backfill from FIRST_PAINT_BUDGET to the full budget after the small
// commit painted — as a TRANSITION, so the heavy markdown + syntax
// highlight render of the older turns is interruptible instead of one long
// synchronous commit that freezes input right after the switch. Route
// changes stay urgent (main.tsx disables router transitions); it's exactly
// this backfill that belongs at background priority. "Show earlier" pages
// (budget > RENDER_BUDGET) never re-enter here.
useEffect(() => {
if (renderBudget >= RENDER_BUDGET) {
return
}
const rafId = requestAnimationFrame(() => {
// Functional max, not a plain set: an urgent "Show earlier" click can
// land between scheduling and committing this transition, and a plain
// set would rebase over it and shrink the budget back down.
startTransition(() => setRenderBudget(budget => Math.max(budget, RENDER_BUDGET)))
})
return () => cancelAnimationFrame(rafId)
}, [renderBudget])
const hiddenCount = firstVisibleGroupIndex(groups, 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)
@ -192,12 +248,6 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
// Instead: quiet it, glue to the true bottom until the height holds steady,
// then hand back locked. Live streaming afterward uses the normal resize follow.
useLayoutEffect(() => {
// Start with a small first-paint budget (enough for the bottom turn(s) the
// user sees after scroll-to-bottom), then defer the full budget bump to a
// requestAnimationFrame so the heavy markdown render happens after the
// initial commit.
setRenderBudget(FIRST_PAINT_BUDGET)
const el = scrollRef.current
if (!el) {
@ -238,17 +288,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
let rafId = requestAnimationFrame(settle)
// After the settle loop starts, bump the render budget to the full value
// in a subsequent rAF so the full transcript becomes available after the
// first paint.
let budgetRafId = requestAnimationFrame(() => {
setRenderBudget(RENDER_BUDGET)
})
return () => {
cancelAnimationFrame(rafId)
cancelAnimationFrame(budgetRafId)
}
return () => cancelAnimationFrame(rafId)
}, [scrollRef, scrollToBottom, sessionKey, stopScroll])
// Prepend an older page while preserving the on-screen position. The user is

View file

@ -84,7 +84,8 @@ const ThinkingDisclosure: FC<{
el.scrollTop = el.scrollHeight
}
pin()
// No sync pin(): the observer's guaranteed initial delivery runs it with
// layout already clean (still before paint), avoiding a forced reflow.
const observer = new ResizeObserver(pin)
observer.observe(content)

View file

@ -206,7 +206,13 @@ export const ThreadTimeline: FC = () => {
}
}
compute()
// Initial compute rides the same rAF batching as scroll. A sync call here
// reads getBoundingClientRect for every user message while other commit
// effects are still writing styles — on a session switch that interleaving
// forces a full reflow per read on a large transcript. One rAF later the
// reads batch into a single layout pass, and back-to-back entries updates
// (prefetch paint, then resume reconcile) coalesce into one compute.
onScroll()
viewport.addEventListener('scroll', onScroll, { passive: true })
return () => {

View file

@ -659,7 +659,10 @@ function useToolWindow(enabled: boolean) {
syncFade()
}
pin()
// No sync pin() here: the observer's guaranteed initial delivery runs it
// inside RO timing (layout already clean, still before paint). A sync call
// at effect time reads scrollHeight while the commit's layout is dirty —
// one forced reflow per tool group, which cascades on a session switch.
const observer = new ResizeObserver(pin)
observer.observe(content)

View file

@ -1,7 +1,8 @@
'use client'
import { type ReactNode, useLayoutEffect, useRef, useState } from 'react'
import { type ReactNode, useCallback, useRef, useState } from 'react'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { ChevronDown } from '@/lib/icons'
import { cn } from '@/lib/utils'
@ -15,21 +16,19 @@ export function ExpandableBlock({ children, className }: ExpandableBlockProps) {
const [expanded, setExpanded] = useState(false)
const [overflowing, setOverflowing] = useState(false)
useLayoutEffect(() => {
// Measure inside ResizeObserver timing only (layout is clean there). A
// synchronous mount-time scrollHeight read forces a reflow per instance,
// and a tool-heavy transcript mounts dozens of these on a session switch.
const measure = useCallback(() => {
const el = innerRef.current
if (!el) {
return
if (el) {
setOverflowing(el.scrollHeight > 121)
}
const measure = () => setOverflowing(el.scrollHeight > 121)
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [])
useResizeObserver(measure, innerRef)
return (
<div className="relative">
<div className={cn('overflow-y-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)} ref={innerRef}>

View file

@ -1,5 +1,6 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { cn } from '@/lib/utils'
interface TerminalOutputProps {
@ -22,15 +23,25 @@ const NEAR_BOTTOM_PX = 24
export function TerminalOutput({ className, text }: TerminalOutputProps) {
const ref = useRef<HTMLDivElement>(null)
// On open: jump straight to the latest output (no animation, before paint).
useLayoutEffect(() => {
// On open: jump straight to the latest output. Rides the ResizeObserver's
// initial delivery (layout-clean, still before paint) instead of a layout
// effect — a sync scrollHeight read there forces a reflow per instance, and
// a tool-heavy transcript mounts dozens of these on a session switch.
const jumpedRef = useRef(false)
const jumpToBottom = useCallback(() => {
const el = ref.current
if (el) {
// First delivery only; later box resizes must not yank the user back
// down while they're scrolled up reading earlier output.
if (el && !jumpedRef.current) {
jumpedRef.current = true
el.scrollTop = el.scrollHeight
}
}, [])
useResizeObserver(jumpToBottom, ref)
// On growth: tail only when already pinned near the bottom.
useEffect(() => {
const el = ref.current

View file

@ -2,9 +2,16 @@ import { type RefObject, useLayoutEffect, useRef } from 'react'
/**
* Observe element resizes. The callback receives the ResizeObserver entries
* (empty on the initial synchronous call and in non-RO environments) so
* callers can read the observed size off the entry instead of forcing a
* fresh layout read.
* (empty only in non-RO environments) so callers can read the observed size
* off the entry instead of forcing a fresh layout read.
*
* The initial measurement rides the observer's spec-guaranteed first delivery
* (same frame, after layout, before paint) instead of a synchronous call from
* the layout effect. A sync call here runs while the commit's layout is still
* dirty, so any size read in the callback forces a full reflow and with many
* 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.
*/
export function useResizeObserver(
onResize: (entries: readonly ResizeObserverEntry[]) => void,
@ -40,8 +47,6 @@ export function useResizeObserver(
return
}
onResize([])
return () => observer.disconnect()
}, [onResize])
}