mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
perf(desktop): kill the layout-thrash cascade on session switch
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:
parent
56e2ba5e79
commit
dc58758a4b
10 changed files with 362 additions and 71 deletions
163
apps/desktop/scripts/profile-session-switch.mjs
Normal file
163
apps/desktop/scripts/profile-session-switch.mjs
Normal 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) })
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 read→write→read 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])
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue