Skip to content

perf(ui): cut forced reflows in Control UI chat render path#110472

Merged
steipete merged 2 commits into
mainfrom
claude/web-ui-performance-29a825
Jul 18, 2026
Merged

perf(ui): cut forced reflows in Control UI chat render path#110472
steipete merged 2 commits into
mainfrom
claude/web-ui-performance-29a825

Conversation

@steipete

Copy link
Copy Markdown
Contributor

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 ref callbacks change identity every render, so Lit re-invokes them (undefined + element) on each render:

  • Transcript rows (ui/src/pages/chat/components/chat-thread.ts): each render re-invoked measureElement for 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.
  • Composer textarea (ui/src/pages/chat/components/chat-composer.ts): every chat render re-measured the textarea (style write + scrollHeight read = 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).
  • Sidebar (ui/src/components/app-sidebar-session-data.ts): updated() read scrollHeight/scrollTop synchronously after every render, forcing a layout flush. Reads now coalesce into one requestAnimationFrame per 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

  • Before trace (production build, real session data, local gateway): INP 367 ms (pointerdown, 87 ms processing + 277 ms presentation delay); ForcedReflow insight present in load and interaction traces naming updateSessionsScrollState and the composer/measure paths.
  • After trace, same sequence: INP 133/135/143 ms across three runs; no ForcedReflow insight in load or interaction traces.
  • Behavior verified live on the built UI: transcript virtualization renders and measures correctly after scroll, row layout has no overlaps, composer draft survives renders.
  • Focused tests: 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.
  • Dependency contracts checked in source: lit-html/directives/ref.js (last-element tracking keyed per (host, callback)) and @tanstack/virtual-core measureElement/resizeItem (null sweep + synchronous re-measure path).
  • Structured autoreview (gpt-5.6-sol): one finding (shared ref callback across rows) — accepted and fixed with per-row-key callbacks; rerun clean.

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.
@openclaw-barnacle openclaw-barnacle Bot added app: web-ui App: web-ui size: S maintainer Maintainer-authored PR labels Jul 18, 2026
@steipete steipete closed this Jul 18, 2026
@steipete steipete reopened this Jul 18, 2026
@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. P2 Normal backlog priority with limited blast radius. labels Jul 18, 2026
@clawsweeper

clawsweeper Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed July 18, 2026, 2:44 AM ET / 06:44 UTC.

Summary
The PR stabilizes Lit ref callbacks for chat rows and the composer, and defers sidebar scroll-state reads to one animation frame to reduce Control UI layout reflows.

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: 🐚 platinum hermit
Proof: 🦞 diamond lobster
Patch quality: 🐚 platinum hermit
Result: ready for maintainer review.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • none.

Next step before merge

  • [P2] The patch needs normal explicit maintainer handling because the PR carries the protected maintainer label; no narrow automated repair is identified.

Security
Cleared: The reviewed patch is limited to browser-side ref and scheduling lifecycle changes and introduces no dependency, permission, secret, workflow, or artifact-execution surface.

Review details

Best 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 updated().

AGENTS.md: unclear because the file could not be read completely.

Codex review notes: model internal, reasoning high; reviewed against 00eb33fe8e6e.

Label changes

Label changes:

  • add P2: This is a bounded user-facing Control UI responsiveness improvement with credible performance evidence, not a functional outage or data-loss event.
  • add proof: sufficient: Contributor real behavior proof is sufficient. The PR supplies after-fix production-build traces from a real-data gateway, repeated INP measurements, and live checks of virtualization and draft behavior; redact private gateway details if any raw artifacts are posted.
  • add rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • add status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR supplies after-fix production-build traces from a real-data gateway, repeated INP measurements, and live checks of virtualization and draft behavior; redact private gateway details if any raw artifacts are posted.

Label justifications:

  • P2: This is a bounded user-facing Control UI responsiveness improvement with credible performance evidence, not a functional outage or data-loss event.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR supplies after-fix production-build traces from a real-data gateway, repeated INP measurements, and live checks of virtualization and draft behavior; redact private gateway details if any raw artifacts are posted.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR supplies after-fix production-build traces from a real-data gateway, repeated INP measurements, and live checks of virtualization and draft behavior; redact private gateway details if any raw artifacts are posted.
Evidence reviewed

PR surface:

Source +53. Total +53 across 3 files.

View PR surface stats
Area Files Added Removed Net
Source 3 80 27 +53
Tests 0 0 0 0
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 3 80 27 +53

What I checked:

  • Focused UI implementation: The branch changes only the chat transcript virtualizer ref lifecycle, composer textarea ref lifecycle, and sidebar scroll-state scheduling; it does not add dependencies, permissions, configuration, or package-install behavior. (ui/src/pages/chat/components/chat-thread.ts:199, 3afd88203156)
  • Real behavior evidence: The PR body reports production-build traces against a real-data gateway: session-switch INP improved from 367 ms to 133–143 ms across three after runs, ForcedReflow insights disappeared, and transcript virtualization plus composer draft behavior were checked live. (ui/src/pages/chat/components/chat-composer.ts:1893, 3afd88203156)
  • Focused regression coverage: The PR reports four focused UI test files with 156 passing tests, covering chat history, chat page, composer, and sidebar behavior. (ui/src/components/app-sidebar-session-data.ts:389, 3afd88203156)
  • Protected maintainer handling: The GitHub context shows the maintainer label on this open PR, so it should remain open for explicit maintainer review even though no patch defect was identified.

Likely related people:

  • steipete: The available context ties this contributor directly to the targeted Control UI profiling and implementation; current-main feature-history provenance could not be completed during this read-only review. (role: recent area contributor; confidence: low; commits: 3afd88203156; files: ui/src/pages/chat/components/chat-thread.ts, ui/src/pages/chat/components/chat-composer.ts, ui/src/components/app-sidebar-session-data.ts)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

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
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

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).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +1909 to +1910
if (state.composerTextarea?.isConnected && state.composerTextarea.value !== visibleDraft) {
scheduleTextareaHeightAdjustment(state.composerTextarea);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@steipete
steipete merged commit cad4e39 into main Jul 18, 2026
90 checks passed
@steipete
steipete deleted the claude/web-ui-performance-29a825 branch July 18, 2026 07:04
@steipete

Copy link
Copy Markdown
Contributor Author

Merged via squash.

github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jul 19, 2026
…#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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app: web-ui App: web-ui maintainer Maintainer-authored PR P2 Normal backlog priority with limited blast radius. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. size: S status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant