Files
openclaw/ui/config/control-ui-chunking.ts
Peter Steinberger 8fe2c1b83c perf(ui): consolidate Control UI boot chunk graph for HTTP/1.1 gateways (#128514)
* perf(ui): consolidate Control UI boot chunk graph for HTTP/1.1 gateways

The Control UI boot flow (app shell + sidebar + chat route) lazily loaded
~124 automatic chunks in one burst after the gateway handshake, which the
gateway's HTTP/1.1 transport serializes into ~24 six-connection round-trips
on high-latency links (Tailscale, remote gateways).

Add a measured boot-module manifest (ui/config/control-ui-boot-modules.json,
regenerated via pnpm ui:boot-manifest:gen) and a control-ui-boot codeSplitting
group that merges exactly that module set into a handful of chunks with
recursive dependency inclusion. Lazy islands (locales, ghostty-web, novnc,
non-default routes) keep their own chunks; stale manifest entries degrade
gracefully back to automatic chunking.

Measured on the built dist with the mocked gateway (chat route, 3 runs):
unique boot JS requests 140 -> 45, raw boot JS 3751 -> 3717 KiB, chat
composer interactive at simulated 50 ms RTT ~1600 ms -> ~575 ms.

Largest-CSS budget rises 45 -> 47 KiB for the merged boot CSS; startup JS
gzip baseline ratchets down (345049 -> 339214 B) as consolidation shrinks
the startup graph.

* chore(ui): refresh boot module manifest after rebase onto current main

* fix(ui): stop the pending lazy shell action replay loop starving boot

When a pending lazy shell action (command palette open, panel toggle)
replayed while the shell was still splash-gated, the dispatched event had
no rendered element to consume it and re-entered requestLazyElement in a
microtask cycle: request -> load -> replay -> dispatch -> request. The
cycle starved tasks (Gateway WebSocket messages included), so the boot
never finished and the recovery e2e froze on the splash screen.

Gate replay on the element actually being rendered: the controller skips
the action after load until the host's render root contains the tag, and
restorePendingLazyAction skips dispatch while a defined element is still
render-gated. The host retries after every completed update, so the replay
fires on the update that first renders the element. Regression test fails
on the pre-fix controller.

* fix(ui): re-anchor the scope-upgrade details popover before opening

wa-popover resolves its `for` target once per property change and never
re-resolves a missing or replaced anchor. The trigger with the shared id
can render after the popover's first update (the header trigger ships with
the lazy chat chunk), leaving the opened popover permanently invisible:
active popup with a native [popover] part stuck at UA display:none because
showPopover() never ran without an anchor. Re-arm the watcher when opening
while the anchor is missing or disconnected.

* test(ui): compare settled layouts in device-scope stability assertions

The 0.5px no-move assertions sampled geometry that later reflowed when the
details surface's first render fetched glyph subsets, reporting sub-pixel
drift the open never caused. Burn in the one-time open per context and
sample the baseline adjacent to the click.

* fix(ui): map the keyboard shortcuts dialog in lazy replay gating

Current main added the keyboard-shortcuts lazy shell event; the replay
gate's exhaustive event-to-element record needs its entry.

* chore(ui): refresh startup budget baseline after rebase onto current main
2026-08-24 03:55:37 -07:00

125 lines
4.6 KiB
TypeScript

// Control UI config module wires control ui chunking behavior.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const configDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(configDir, "../..");
// Measured module set the default boot flow (app shell + sidebar + chat route)
// loads through dynamic imports. Regenerate with `pnpm ui:boot-manifest:gen`
// after a build; stale entries degrade gracefully back to automatic chunking.
const controlUiBootModules: ReadonlySet<string> = new Set(
JSON.parse(
fs.readFileSync(path.join(configDir, "control-ui-boot-modules.json"), "utf8"),
) as string[],
);
function normalizeModuleId(id: string): string {
return id.replace(/\\/g, "/");
}
export function controlUiBootManifestKey(id: string): string {
// Canonical manifest key: vendor modules key from their innermost
// node_modules entry so pnpm virtual-store paths match; first-party modules
// key repo-relative.
const stripped = id.replace(/[?#].*$/u, "");
const normalized = normalizeModuleId(stripped);
const vendorIndex = normalized.lastIndexOf("/node_modules/");
if (vendorIndex !== -1) {
return `node_modules/${normalized.slice(vendorIndex + "/node_modules/".length)}`;
}
return normalizeModuleId(path.relative(repoRoot, stripped));
}
function moduleIdIncludesPackage(id: string, packageName: string): boolean {
const normalized = normalizeModuleId(id);
return (
normalized.includes(`/node_modules/${packageName}/`) ||
normalized.includes(`/openclaw-pnpm-node-modules/${packageName}/`)
);
}
export function controlUiStableChunkName(id: string): string | undefined {
const normalized = normalizeModuleId(id);
if (normalized.endsWith("/ui/src/lib/gateway-methods.ts")) {
return "gateway-runtime";
}
if (
moduleIdIncludesPackage(id, "lit") ||
moduleIdIncludesPackage(id, "lit-html") ||
moduleIdIncludesPackage(id, "@lit/reactive-element")
) {
return "lit-runtime";
}
if (
moduleIdIncludesPackage(id, "highlight.js") ||
moduleIdIncludesPackage(id, "markdown-it") ||
moduleIdIncludesPackage(id, "markdown-it-task-lists") ||
moduleIdIncludesPackage(id, "dompurify") ||
moduleIdIncludesPackage(id, "entities") ||
moduleIdIncludesPackage(id, "linkify-it") ||
moduleIdIncludesPackage(id, "mdurl") ||
moduleIdIncludesPackage(id, "punycode.js") ||
moduleIdIncludesPackage(id, "uc.micro")
) {
return "markdown-runtime";
}
if (
moduleIdIncludesPackage(id, "zod") ||
moduleIdIncludesPackage(id, "json5") ||
moduleIdIncludesPackage(id, "libphonenumber-js")
) {
return "config-runtime";
}
// @noble/hashes stays out of this startup chunk deliberately: it is only
// dynamically imported as the insecure-context fallback digest provider.
if (moduleIdIncludesPackage(id, "@noble/ed25519") || moduleIdIncludesPackage(id, "ipaddr.js")) {
return "gateway-runtime";
}
return undefined;
}
export const controlUiCodeSplitting = {
includeDependenciesRecursively: false,
groups: [
{
name: (id: string) => controlUiStableChunkName(id) ?? null,
test: (id: string) => controlUiStableChunkName(id) !== undefined,
priority: 20,
},
{
name: (id: string) =>
normalizeModuleId(id).includes("/ui/src/") ? "control-ui-core" : "control-ui-foundation",
tags: ["$initial"] as ["$initial"],
priority: 10,
// 640 KiB keeps the startup graph together; the previous 576 KiB boundary
// split it into two extra requests and added roughly 1 KiB of gzip.
maxSize: 640 * 1024,
},
{
// Boot-path consolidation: the lazily-loaded modules the default boot
// flow always fetches (~124 automatic chunks without this group) merge
// into a handful of chunks so the gateway's HTTP/1.1 6-connection
// transport pays ~7 instead of ~24 serialized round-trips on high-latency
// links. Byte cost is ~zero: every captured module is fetched during boot
// either way. Recursive dependency inclusion is required for correctness
// here — merging without it emitted chunks whose execution order broke at
// startup ("TypeError: X is not a function" during application start).
name: "control-ui-boot",
test: (id: string) => controlUiBootModules.has(controlUiBootManifestKey(id)),
priority: 8,
includeDependenciesRecursively: true,
// Larger ceiling than the startup groups: this sizes pre-minification
// module bytes, and ~1.5 MiB keeps the largest emitted chunk near
// ~190 KiB gzip, inside the 215 KiB largest-JS budget.
maxSize: 1536 * 1024,
},
],
};