perf(ui): cut forced reflows in Control UI chat render path#110472
Conversation
Profiled the Control UI with Chrome DevTools tracing against a real-data gateway: session-switch INP was 367ms with ForcedReflow insights on every load and interaction trace. - chat-thread: per-row stable Lit ref callbacks (keyed by row key) so the virtualizer stops cache-sweeping and re-measuring every visible row on every render; prune callbacks when rows leave the list - chat-composer: stable textarea ref on per-pane state instead of an inline arrow, so the textarea is re-measured only on attach or when the draft changes programmatically, not on every chat render - app-sidebar: coalesce scrollHeight/scrollTop reads from updated() into one rAF per frame instead of a forced layout flush per render After: INP 133-143ms on the same interaction sequence, no ForcedReflow insight in load or interaction traces.
|
Codex review: needs maintainer review before merge. Reviewed July 18, 2026, 2:44 AM ET / 06:44 UTC. Summary PR surface: Source +53. Total +53 across 3 files. Reproducibility: yes. at source level: the reported churn follows the changed inline Lit ref and synchronous layout-read paths, and the PR supplies a concrete production-build tracing sequence; an independent current-main trace was not run in this read-only review. Review metrics: none identified. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Next step before merge
Security Review detailsBest possible solution: Retain the focused stable-ref and rAF scheduling approach, then land it only after the protected maintainer review confirms the current Control UI lifecycle coverage is sufficient. Do we have a high-confidence way to reproduce the issue? Yes, at source level: the reported churn follows the changed inline Lit ref and synchronous layout-read paths, and the PR supplies a concrete production-build tracing sequence; an independent current-main trace was not run in this read-only review. Is this the best way to solve the issue? Yes, based on the submitted evidence: stable per-row and per-pane refs address the cited Lit identity churn without adding a parallel mechanism, while rAF coalescing keeps sidebar layout reads out of AGENTS.md: unclear because the file could not be read completely. Codex review notes: model internal, reasoning high; reviewed against 00eb33fe8e6e. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +53. Total +53 across 3 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
The reflow fixes shifted rolldown's chunk partition: the 400 KiB core maxSize boundary split one core chunk in two (~1.4 KiB gzip compression loss) and re-balancing minted a tiny build-info startup chunk, pushing startup JS to 370.8 KiB over the 370 KiB budget. - pin build-info.ts + build-info-normalizers.ts into control-ui-shared so partition noise stops minting extra startup preload requests - raise core maxSize 400 -> 448 KiB so the core graph packs into fewer, better-compressing chunks Startup JS: 22 requests, 369.1 KiB gzip (main: 23 requests, 369.3 KiB).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 793a95cf65
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (state.composerTextarea?.isConnected && state.composerTextarea.value !== visibleDraft) { | ||
| scheduleTextareaHeightAdjustment(state.composerTextarea); |
There was a problem hiding this comment.
Re-measure composer on pane width changes
When the chat pane is resized (splitter drag, sidebar open, or workspace rail breakpoint), ui/src/pages/chat/chat-pane.ts:1542-1548 updates paneWidth and rerenders, but if the host draft already matches the textarea value this new guard skips scheduleTextareaHeightAdjustment. A wrapped multiline draft then keeps its old fixed pixel height and only toggles overflow, so narrowing the pane shows a scrollbar/clipped composer or widening leaves extra height until the next input; the old inline ref re-ran the height adjustment on that rerender. Please also adjust on textarea/container resize, not only value changes.
Useful? React with 👍 / 👎.
|
Merged via squash.
|
…#110472) * perf(ui): cut forced reflows in chat render path Profiled the Control UI with Chrome DevTools tracing against a real-data gateway: session-switch INP was 367ms with ForcedReflow insights on every load and interaction trace. - chat-thread: per-row stable Lit ref callbacks (keyed by row key) so the virtualizer stops cache-sweeping and re-measuring every visible row on every render; prune callbacks when rows leave the list - chat-composer: stable textarea ref on per-pane state instead of an inline arrow, so the textarea is re-measured only on attach or when the draft changes programmatically, not on every chat render - app-sidebar: coalesce scrollHeight/scrollTop reads from updated() into one rAF per frame instead of a forced layout flush per render After: INP 133-143ms on the same interaction sequence, no ForcedReflow insight in load or interaction traces. * perf(ui): keep Control UI startup under budget after reflow fixes The reflow fixes shifted rolldown's chunk partition: the 400 KiB core maxSize boundary split one core chunk in two (~1.4 KiB gzip compression loss) and re-balancing minted a tiny build-info startup chunk, pushing startup JS to 370.8 KiB over the 370 KiB budget. - pin build-info.ts + build-info-normalizers.ts into control-ui-shared so partition noise stops minting extra startup preload requests - raise core maxSize 400 -> 448 KiB so the core graph packs into fewer, better-compressing chunks Startup JS: 22 requests, 369.1 KiB gzip (main: 23 requests, 369.3 KiB).
What Problem This Solves
The Control UI chat page feels sluggish: switching sessions, streaming replies, and typing in the composer all stutter. Chrome DevTools performance traces against a real-data gateway measured session-switch INP at 367 ms and flagged forced reflows in every load and interaction trace.
Why This Change Was Made
Profiling (Chrome DevTools tracing plus JS self-profiling, symbolicated through the build sourcemaps) attributed the jank to three forced-reflow/render-churn sources, all variations of the same Lit contract detail — inline-arrow
refcallbacks change identity every render, so Lit re-invokes them (undefined + element) on each render:ui/src/pages/chat/components/chat-thread.ts): each render re-invokedmeasureElementfor every visible row, sweeping the virtualizer's element cache and re-measuring rows on every stream tick. Rows now use per-row-key stable callbacks (Lit tracks the last element per (host, callback), so one shared callback is not sufficient), pruned when rows leave the list.ui/src/pages/chat/components/chat-composer.ts): every chat render re-measured the textarea (style write +scrollHeightread = forced reflow). The ref now lives on per-pane composer state; re-measure happens only on attach or when the draft changes programmatically (send clear, session switch, history restore).ui/src/components/app-sidebar-session-data.ts):updated()readscrollHeight/scrollTopsynchronously after every render, forcing a layout flush. Reads now coalesce into onerequestAnimationFrameper frame, cancelled on disconnect.User Impact
Chat interactions are noticeably snappier: session-switch INP dropped from 367 ms to 133–143 ms (three runs, same scripted interaction sequence), and the ForcedReflow insight disappeared from load and interaction traces. Streaming and typing no longer force per-render layout flushes.
Evidence
pointerdown, 87 ms processing + 277 ms presentation delay); ForcedReflow insight present in load and interaction traces namingupdateSessionsScrollStateand the composer/measure paths.node scripts/run-vitest.mjs ui/src/pages/chat/chat-pane-history.test.ts ui/src/pages/chat/chat-page.test.ts ui/src/pages/chat/chat-composer.test.ts ui/src/components/app-sidebar.test.ts— 4 files, 156 tests passed.lit-html/directives/ref.js(last-element tracking keyed per (host, callback)) and@tanstack/virtual-coremeasureElement/resizeItem(null sweep + synchronous re-measure path).