mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user