Skip to content

Commit cad4e39

Browse files
authored
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).
1 parent f8e417a commit cad4e39

5 files changed

Lines changed: 86 additions & 30 deletions

File tree

ui/config/control-ui-chunking.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ export function controlUiStableChunkName(id: string): string | undefined {
1818
// turn small route-graph changes into extra startup preload requests.
1919
if (
2020
normalized.endsWith("/ui/src/components/config-form.shared.ts") ||
21-
normalized.endsWith("/ui/src/lib/clipboard.ts")
21+
normalized.endsWith("/ui/src/lib/clipboard.ts") ||
22+
normalized.endsWith("/ui/src/build-info-normalizers.ts") ||
23+
normalized.endsWith("/ui/src/build-info.ts")
2224
) {
2325
return "control-ui-shared";
2426
}
@@ -77,7 +79,9 @@ export const controlUiCodeSplitting = {
7779
normalizeModuleId(id).includes("/ui/src/") ? "control-ui-core" : "control-ui-foundation",
7880
tags: ["$initial"] as ["$initial"],
7981
priority: 10,
80-
maxSize: 400 * 1024,
82+
// 448 KiB packs the core graph into fewer chunks; the previous 400 KiB
83+
// boundary split one core chunk in two, costing ~1.4 KiB startup gzip.
84+
maxSize: 448 * 1024,
8185
},
8286
],
8387
};

ui/src/app/control-ui-chunking.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ describe("Control UI build chunking", () => {
2626
"control-ui-shared",
2727
);
2828
expect(controlUiStableChunkName("/repo/ui/src/lib/clipboard.ts")).toBe("control-ui-shared");
29+
expect(controlUiStableChunkName("/repo/ui/src/build-info.ts")).toBe("control-ui-shared");
30+
expect(controlUiStableChunkName("/repo/ui/src/build-info-normalizers.ts")).toBe(
31+
"control-ui-shared",
32+
);
2933
expect(
3034
controlUiStableChunkName("/tmp/openclaw-pnpm-node-modules/@noble/ed25519/index.js"),
3135
).toBe("gateway-runtime");
@@ -37,7 +41,7 @@ describe("Control UI build chunking", () => {
3741
expect(controlUiCodeSplitting.includeDependenciesRecursively).toBe(false);
3842
expect(controlUiCodeSplitting.groups[1]).toMatchObject({
3943
tags: ["$initial"],
40-
maxSize: 400 * 1024,
44+
maxSize: 448 * 1024,
4145
});
4246
});
4347

ui/src/components/app-sidebar-session-data.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarBase {
7777
private sessionMutationEpoch = 0;
7878
private sessionsScrollElement: HTMLElement | null = null;
7979
private sessionsScrollResizeObserver: ResizeObserver | null = null;
80+
private sessionsScrollStateFrame: number | null = null;
8081
private readonly sessionCatalogLive = new SessionCatalogLiveState();
8182
private sessionCatalogAgentId: string | null = null;
8283
private sessionCatalogGeneration = 0;
@@ -161,6 +162,10 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarBase {
161162
this.sessionsScrollResizeObserver?.disconnect();
162163
this.sessionsScrollResizeObserver = null;
163164
this.sessionsScrollElement = null;
165+
if (this.sessionsScrollStateFrame !== null) {
166+
cancelAnimationFrame(this.sessionsScrollStateFrame);
167+
this.sessionsScrollStateFrame = null;
168+
}
164169
if (this.activeSessionLineageRetryTimer) {
165170
globalThis.clearTimeout(this.activeSessionLineageRetryTimer);
166171
this.activeSessionLineageRetryTimer = null;
@@ -384,10 +389,25 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarBase {
384389
}
385390
}
386391
if (element) {
387-
this.updateSessionsScrollState(element);
392+
this.scheduleSessionsScrollStateSync();
388393
}
389394
}
390395

396+
// Reading scrollHeight/scrollTop inside updated() forces a layout flush per
397+
// render; one rAF-coalesced read rides the layout computed for paint anyway.
398+
private scheduleSessionsScrollStateSync() {
399+
if (this.sessionsScrollStateFrame !== null) {
400+
return;
401+
}
402+
this.sessionsScrollStateFrame = requestAnimationFrame(() => {
403+
this.sessionsScrollStateFrame = null;
404+
const element = this.sessionsScrollElement;
405+
if (element?.isConnected) {
406+
this.updateSessionsScrollState(element);
407+
}
408+
});
409+
}
410+
391411
protected updateSessionsScrollState(element: HTMLElement) {
392412
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
393413
let nextState: SidebarSessionsScrollState = "none";

ui/src/pages/chat/components/chat-composer.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ type ChatComposerState = {
161161
composerInputIntentKey: string | null;
162162
pendingClearedSubmittedDraft: PendingClearedSubmittedDraft | null;
163163
goalExpandedId: string | null;
164+
composerTextarea: HTMLTextAreaElement | null;
165+
// Stable Lit ref: an inline arrow would change identity per render and force
166+
// a layout re-measure of the textarea on every chat render, not just attach.
167+
textareaRef: ((element?: Element) => void) | null;
164168
};
165169

166170
function createChatComposerState(): ChatComposerState {
@@ -178,6 +182,8 @@ function createChatComposerState(): ChatComposerState {
178182
composerInputIntentKey: null,
179183
pendingClearedSubmittedDraft: null,
180184
goalExpandedId: null,
185+
composerTextarea: null,
186+
textareaRef: null,
181187
};
182188
}
183189

@@ -1886,7 +1892,23 @@ export function renderChatComposer(props: ChatComposerProps) {
18861892
const draftKey = composerDraftKey(props);
18871893
const actionDraft =
18881894
state.composingDraft?.key === draftKey ? state.composingDraft.value : visibleDraft;
1889-
let composerTextarea: HTMLTextAreaElement | null = null;
1895+
state.textareaRef ??= (element?: Element) => {
1896+
const nextTextarea = element instanceof HTMLTextAreaElement ? element : null;
1897+
const prevTextarea = state.composerTextarea;
1898+
if (prevTextarea && prevTextarea !== nextTextarea) {
1899+
disconnectTextareaOverflowObserver(prevTextarea);
1900+
}
1901+
state.composerTextarea = nextTextarea;
1902+
if (nextTextarea) {
1903+
observeTextareaOverflow(nextTextarea);
1904+
scheduleTextareaHeightAdjustment(nextTextarea);
1905+
}
1906+
};
1907+
// The stable ref only measures on attach, so programmatic draft swaps (send
1908+
// clear, session switch, history restore) must re-measure explicitly.
1909+
if (state.composerTextarea?.isConnected && state.composerTextarea.value !== visibleDraft) {
1910+
scheduleTextareaHeightAdjustment(state.composerTextarea);
1911+
}
18901912
const hasVisualAttachments = (props.attachments ?? []).some(
18911913
(attachment) => !isLargePastedTextAttachment(attachment),
18921914
);
@@ -2142,20 +2164,20 @@ export function renderChatComposer(props: ChatComposerProps) {
21422164
commitComposerDraft(props, target.value);
21432165
};
21442166
const handleSend = () => {
2145-
const draft = composerTextarea?.value ?? props.draft;
2167+
const draft = state.composerTextarea?.value ?? props.draft;
21462168
if (!canSubmitDraft(draft)) {
21472169
return;
21482170
}
21492171
commitComposerDraft(props, draft);
21502172
props.onSend();
2151-
syncComposerDraftAfterSend(composerTextarea);
2173+
syncComposerDraftAfterSend(state.composerTextarea);
21522174
};
21532175
const handleVoicePrimaryAction = () => {
21542176
if (props.realtimeTalkActive) {
21552177
props.onToggleRealtimeTalk?.();
21562178
return;
21572179
}
2158-
const liveDraft = composerTextarea?.value ?? visibleDraft;
2180+
const liveDraft = state.composerTextarea?.value ?? visibleDraft;
21592181
if (liveDraft.trim() || props.attachments?.length) {
21602182
handleSend();
21612183
return;
@@ -2256,7 +2278,7 @@ export function renderChatComposer(props: ChatComposerProps) {
22562278
onGoalEdit: (goal) => {
22572279
commitComposerDraft(props, `/goal edit ${goal.objective}`);
22582280
requestUpdate();
2259-
queueMicrotask(() => composerTextarea?.focus({ preventScroll: true }));
2281+
queueMicrotask(() => state.composerTextarea?.focus({ preventScroll: true }));
22602282
},
22612283
requestUpdate,
22622284
})}
@@ -2290,17 +2312,7 @@ export function renderChatComposer(props: ChatComposerProps) {
22902312
${renderChatAttachmentMenu({ ...props, disabled: !canCompose })}
22912313
<div class="agent-chat__composer-combobox">
22922314
<textarea
2293-
${ref((element) => {
2294-
const nextTextarea = element instanceof HTMLTextAreaElement ? element : null;
2295-
if (composerTextarea && composerTextarea !== nextTextarea) {
2296-
disconnectTextareaOverflowObserver(composerTextarea);
2297-
}
2298-
composerTextarea = nextTextarea;
2299-
if (composerTextarea) {
2300-
observeTextareaOverflow(composerTextarea);
2301-
scheduleTextareaHeightAdjustment(composerTextarea);
2302-
}
2303-
})}
2315+
${ref(state.textareaRef)}
23042316
.value=${visibleDraft}
23052317
dir=${detectTextDirection(visibleDraft)}
23062318
?disabled=${!canCompose}

ui/src/pages/chat/components/chat-thread.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,25 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
199199
private readonly controllers = new Set<ReactiveController>();
200200
private readonly virtualizerController: VirtualizerController<HTMLDivElement, HTMLElement>;
201201
private scrollElement: HTMLDivElement | null = null;
202+
// Stable Lit refs: inline arrows change identity per render, making Lit
203+
// re-invoke them for every visible row and re-measure each row every render.
204+
// Lit tracks the last element per callback, so each row needs its own.
205+
private readonly scrollElementRef = (element?: Element) => {
206+
this.scrollElement =
207+
element?.parentElement instanceof HTMLDivElement ? element.parentElement : null;
208+
};
209+
private readonly measureRowRefs = new Map<string, (element?: Element) => void>();
210+
private measureRowRefFor(key: string): (element?: Element) => void {
211+
let callback = this.measureRowRefs.get(key);
212+
if (!callback) {
213+
callback = (element?: Element) =>
214+
this.virtualizerController
215+
.getVirtualizer()
216+
.measureElement(element instanceof HTMLElement ? element : null);
217+
this.measureRowRefs.set(key, callback);
218+
}
219+
return callback;
220+
}
202221
private rowKeys: readonly string[] = [];
203222
private rowIndexesByKey = new Map<string, number>();
204223
private focusedRowKey: string | null = null;
@@ -292,13 +311,7 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
292311
const virtualizer = this.virtualizerController.getVirtualizer();
293312
const virtualRows = virtualizer.getVirtualItems();
294313
return html`
295-
<div
296-
class="chat-thread-inner chat-thread-inner--virtual"
297-
${ref((element) => {
298-
this.scrollElement =
299-
element?.parentElement instanceof HTMLDivElement ? element.parentElement : null;
300-
})}
301-
>
314+
<div class="chat-thread-inner chat-thread-inner--virtual" ${ref(this.scrollElementRef)}>
302315
<div
303316
class="chat-virtual-sizer"
304317
style=${styleMap({ height: `${virtualizer.getTotalSize()}px` })}
@@ -324,9 +337,7 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
324337
})}
325338
data-index=${String(virtualRow.index)}
326339
data-virtual-row-key=${row.key}
327-
${ref((element) =>
328-
virtualizer.measureElement(element instanceof HTMLElement ? element : null),
329-
)}
340+
${ref(this.measureRowRefFor(row.key))}
330341
>
331342
${renderRow(row)}
332343
</div>
@@ -388,6 +399,11 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
388399
}
389400
this.rowKeys = Object.freeze(nextKeys);
390401
this.rowIndexesByKey = new Map(this.rowKeys.map((key, index) => [key, index]));
402+
for (const key of this.measureRowRefs.keys()) {
403+
if (!this.rowIndexesByKey.has(key)) {
404+
this.measureRowRefs.delete(key);
405+
}
406+
}
391407
const keys = this.rowKeys;
392408
const virtualizer = this.virtualizerController.getVirtualizer();
393409
virtualizer.setOptions({

0 commit comments

Comments
 (0)