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.