perf(ui): cut forced reflows in Control UI chat render path (#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).
This commit is contained in:
Peter Steinberger
2026-07-18 08:04:03 +01:00
committed by GitHub
parent f8e417a774
commit cad4e395d2
5 changed files with 86 additions and 30 deletions
+6 -2
View File
@@ -18,7 +18,9 @@ export function controlUiStableChunkName(id: string): string | undefined {
// turn small route-graph changes into extra startup preload requests.
if (
normalized.endsWith("/ui/src/components/config-form.shared.ts") ||
normalized.endsWith("/ui/src/lib/clipboard.ts")
normalized.endsWith("/ui/src/lib/clipboard.ts") ||
normalized.endsWith("/ui/src/build-info-normalizers.ts") ||
normalized.endsWith("/ui/src/build-info.ts")
) {
return "control-ui-shared";
}
@@ -77,7 +79,9 @@ export const controlUiCodeSplitting = {
normalizeModuleId(id).includes("/ui/src/") ? "control-ui-core" : "control-ui-foundation",
tags: ["$initial"] as ["$initial"],
priority: 10,
maxSize: 400 * 1024,
// 448 KiB packs the core graph into fewer chunks; the previous 400 KiB
// boundary split one core chunk in two, costing ~1.4 KiB startup gzip.
maxSize: 448 * 1024,
},
],
};
+5 -1
View File
@@ -26,6 +26,10 @@ describe("Control UI build chunking", () => {
"control-ui-shared",
);
expect(controlUiStableChunkName("/repo/ui/src/lib/clipboard.ts")).toBe("control-ui-shared");
expect(controlUiStableChunkName("/repo/ui/src/build-info.ts")).toBe("control-ui-shared");
expect(controlUiStableChunkName("/repo/ui/src/build-info-normalizers.ts")).toBe(
"control-ui-shared",
);
expect(
controlUiStableChunkName("/tmp/openclaw-pnpm-node-modules/@noble/ed25519/index.js"),
).toBe("gateway-runtime");
@@ -37,7 +41,7 @@ describe("Control UI build chunking", () => {
expect(controlUiCodeSplitting.includeDependenciesRecursively).toBe(false);
expect(controlUiCodeSplitting.groups[1]).toMatchObject({
tags: ["$initial"],
maxSize: 400 * 1024,
maxSize: 448 * 1024,
});
});
+21 -1
View File
@@ -77,6 +77,7 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarBase {
private sessionMutationEpoch = 0;
private sessionsScrollElement: HTMLElement | null = null;
private sessionsScrollResizeObserver: ResizeObserver | null = null;
private sessionsScrollStateFrame: number | null = null;
private readonly sessionCatalogLive = new SessionCatalogLiveState();
private sessionCatalogAgentId: string | null = null;
private sessionCatalogGeneration = 0;
@@ -161,6 +162,10 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarBase {
this.sessionsScrollResizeObserver?.disconnect();
this.sessionsScrollResizeObserver = null;
this.sessionsScrollElement = null;
if (this.sessionsScrollStateFrame !== null) {
cancelAnimationFrame(this.sessionsScrollStateFrame);
this.sessionsScrollStateFrame = null;
}
if (this.activeSessionLineageRetryTimer) {
globalThis.clearTimeout(this.activeSessionLineageRetryTimer);
this.activeSessionLineageRetryTimer = null;
@@ -384,10 +389,25 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarBase {
}
}
if (element) {
this.updateSessionsScrollState(element);
this.scheduleSessionsScrollStateSync();
}
}
// Reading scrollHeight/scrollTop inside updated() forces a layout flush per
// render; one rAF-coalesced read rides the layout computed for paint anyway.
private scheduleSessionsScrollStateSync() {
if (this.sessionsScrollStateFrame !== null) {
return;
}
this.sessionsScrollStateFrame = requestAnimationFrame(() => {
this.sessionsScrollStateFrame = null;
const element = this.sessionsScrollElement;
if (element?.isConnected) {
this.updateSessionsScrollState(element);
}
});
}
protected updateSessionsScrollState(element: HTMLElement) {
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
let nextState: SidebarSessionsScrollState = "none";
+28 -16
View File
@@ -161,6 +161,10 @@ type ChatComposerState = {
composerInputIntentKey: string | null;
pendingClearedSubmittedDraft: PendingClearedSubmittedDraft | null;
goalExpandedId: string | null;
composerTextarea: HTMLTextAreaElement | null;
// Stable Lit ref: an inline arrow would change identity per render and force
// a layout re-measure of the textarea on every chat render, not just attach.
textareaRef: ((element?: Element) => void) | null;
};
function createChatComposerState(): ChatComposerState {
@@ -178,6 +182,8 @@ function createChatComposerState(): ChatComposerState {
composerInputIntentKey: null,
pendingClearedSubmittedDraft: null,
goalExpandedId: null,
composerTextarea: null,
textareaRef: null,
};
}
@@ -1886,7 +1892,23 @@ export function renderChatComposer(props: ChatComposerProps) {
const draftKey = composerDraftKey(props);
const actionDraft =
state.composingDraft?.key === draftKey ? state.composingDraft.value : visibleDraft;
let composerTextarea: HTMLTextAreaElement | null = null;
state.textareaRef ??= (element?: Element) => {
const nextTextarea = element instanceof HTMLTextAreaElement ? element : null;
const prevTextarea = state.composerTextarea;
if (prevTextarea && prevTextarea !== nextTextarea) {
disconnectTextareaOverflowObserver(prevTextarea);
}
state.composerTextarea = nextTextarea;
if (nextTextarea) {
observeTextareaOverflow(nextTextarea);
scheduleTextareaHeightAdjustment(nextTextarea);
}
};
// The stable ref only measures on attach, so programmatic draft swaps (send
// clear, session switch, history restore) must re-measure explicitly.
if (state.composerTextarea?.isConnected && state.composerTextarea.value !== visibleDraft) {
scheduleTextareaHeightAdjustment(state.composerTextarea);
}
const hasVisualAttachments = (props.attachments ?? []).some(
(attachment) => !isLargePastedTextAttachment(attachment),
);
@@ -2142,20 +2164,20 @@ export function renderChatComposer(props: ChatComposerProps) {
commitComposerDraft(props, target.value);
};
const handleSend = () => {
const draft = composerTextarea?.value ?? props.draft;
const draft = state.composerTextarea?.value ?? props.draft;
if (!canSubmitDraft(draft)) {
return;
}
commitComposerDraft(props, draft);
props.onSend();
syncComposerDraftAfterSend(composerTextarea);
syncComposerDraftAfterSend(state.composerTextarea);
};
const handleVoicePrimaryAction = () => {
if (props.realtimeTalkActive) {
props.onToggleRealtimeTalk?.();
return;
}
const liveDraft = composerTextarea?.value ?? visibleDraft;
const liveDraft = state.composerTextarea?.value ?? visibleDraft;
if (liveDraft.trim() || props.attachments?.length) {
handleSend();
return;
@@ -2256,7 +2278,7 @@ export function renderChatComposer(props: ChatComposerProps) {
onGoalEdit: (goal) => {
commitComposerDraft(props, `/goal edit ${goal.objective}`);
requestUpdate();
queueMicrotask(() => composerTextarea?.focus({ preventScroll: true }));
queueMicrotask(() => state.composerTextarea?.focus({ preventScroll: true }));
},
requestUpdate,
})}
@@ -2290,17 +2312,7 @@ export function renderChatComposer(props: ChatComposerProps) {
${renderChatAttachmentMenu({ ...props, disabled: !canCompose })}
<div class="agent-chat__composer-combobox">
<textarea
${ref((element) => {
const nextTextarea = element instanceof HTMLTextAreaElement ? element : null;
if (composerTextarea && composerTextarea !== nextTextarea) {
disconnectTextareaOverflowObserver(composerTextarea);
}
composerTextarea = nextTextarea;
if (composerTextarea) {
observeTextareaOverflow(composerTextarea);
scheduleTextareaHeightAdjustment(composerTextarea);
}
})}
${ref(state.textareaRef)}
.value=${visibleDraft}
dir=${detectTextDirection(visibleDraft)}
?disabled=${!canCompose}
+26 -10
View File
@@ -199,6 +199,25 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
private readonly controllers = new Set<ReactiveController>();
private readonly virtualizerController: VirtualizerController<HTMLDivElement, HTMLElement>;
private scrollElement: HTMLDivElement | null = null;
// Stable Lit refs: inline arrows change identity per render, making Lit
// re-invoke them for every visible row and re-measure each row every render.
// Lit tracks the last element per callback, so each row needs its own.
private readonly scrollElementRef = (element?: Element) => {
this.scrollElement =
element?.parentElement instanceof HTMLDivElement ? element.parentElement : null;
};
private readonly measureRowRefs = new Map<string, (element?: Element) => void>();
private measureRowRefFor(key: string): (element?: Element) => void {
let callback = this.measureRowRefs.get(key);
if (!callback) {
callback = (element?: Element) =>
this.virtualizerController
.getVirtualizer()
.measureElement(element instanceof HTMLElement ? element : null);
this.measureRowRefs.set(key, callback);
}
return callback;
}
private rowKeys: readonly string[] = [];
private rowIndexesByKey = new Map<string, number>();
private focusedRowKey: string | null = null;
@@ -292,13 +311,7 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
const virtualizer = this.virtualizerController.getVirtualizer();
const virtualRows = virtualizer.getVirtualItems();
return html`
<div
class="chat-thread-inner chat-thread-inner--virtual"
${ref((element) => {
this.scrollElement =
element?.parentElement instanceof HTMLDivElement ? element.parentElement : null;
})}
>
<div class="chat-thread-inner chat-thread-inner--virtual" ${ref(this.scrollElementRef)}>
<div
class="chat-virtual-sizer"
style=${styleMap({ height: `${virtualizer.getTotalSize()}px` })}
@@ -324,9 +337,7 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
})}
data-index=${String(virtualRow.index)}
data-virtual-row-key=${row.key}
${ref((element) =>
virtualizer.measureElement(element instanceof HTMLElement ? element : null),
)}
${ref(this.measureRowRefFor(row.key))}
>
${renderRow(row)}
</div>
@@ -388,6 +399,11 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
}
this.rowKeys = Object.freeze(nextKeys);
this.rowIndexesByKey = new Map(this.rowKeys.map((key, index) => [key, index]));
for (const key of this.measureRowRefs.keys()) {
if (!this.rowIndexesByKey.has(key)) {
this.measureRowRefs.delete(key);
}
}
const keys = this.rowKeys;
const virtualizer = this.virtualizerController.getVirtualizer();
virtualizer.setOptions({