perf(panel,ui): stop forcing a whole-window layout on every draw and DOM change
Merged
CH
6d ago+235+235+012345678901234567890123456789-39-39-01234567890123456789
Charles asked why Luke was slow and laggy today, then to fix the cheap 90/10 part. This PR is that part.
What the profile showed
I profiled Charles's panel renderer, built from main at 03b922186, while it ran.
- Load: a task with a long agent transcript: about 53k DOM nodes, 30k of them the transcript's 531 messages.
- Symptoms: the renderer used 1.4–2.2 GB of memory and spiked between 20% and 100% CPU.
- Main thread: in 15 s it ran 379 style recalcs taking 3.1 s, plus 2.3 s of forced
getBoundingClientRect.
About 21% of the main thread went to three synchronous layouts. Each one lays out the whole window:
| Where | Cost per 15 s | Why |
|---|---|---|
column-dock.ts | 1.3 s | A subtree MutationObserver re-measured every docked box after every DOM change under the shell, and re-observed every box each time. Since #4046 it also writes --column-dock on :root. |
skill-picker.tsx (and the same code in mention-picker.tsx) | 1.0 s | It measured the composer's field during every draw of every composer, even with its list closed. |
choice-bar.tsx | 0.9 s | useLayoutEffect had no dependency list, so the thumb was re-placed after every draw. |
What changed
- column-dock: DOM changes are batched into one read per animation frame. The column and its parts are re-observed only when they are different elements.
--column-dockand--floating-cardare written only when the value changes. A hidden window no longer lays out at all. - Skill and mention pickers: they measure only while their list is open.
- ChoiceBar: the thumb is re-placed only when the choice, or which options stand and how the bar draws them, changes. Resizes stay with its ResizeObservers.
applyMessagesPage: a message heard again unchanged keeps its held object, and a page that changes nothing returns the held array.
Left for later
These are the larger fixes, out of this PR's scope:
- A transcript follow that doesn't restart from cursor zero.
- Windowing the transcript.
- The server-side GitHub re-reads.
Tests
- ChoiceBar: the thumb follows a new choice, and an option removed ahead of it. This fails if the options key is dropped from the effect's dependencies.
- column-dock: a to-do list landing over the composer raises the dock on the next frame. Frames are driven by the test.
applyMessagesPage: a page that repeats what is held keeps the held messages and the held array. This fails on the old join.
17 nearby test files (349 tests) pass, and the panel, ui and features typechecks pass. I ran no live before/after measurement on the Mac.
Made with Luke