perf(ui): record only route-reachable modules in the boot manifest (#162382)

The boot manifest generator recorded every module inside each chunk the
browser fetched while capturing /new and /chat. Rolldown's base splitting
co-locates a few modules New Session needs inside a large chat-only common
chunk, so 325 chat-only modules (2.95 MB source, incl. the chat view,
composer, and transcript renderers) were classified as shared and /new
downloaded them on every cold load.

The generator now keeps only fetched modules reachable from the HTML entry
and the dynamic entries each route requested: static imports, plus dynamic
imports whose target has no facade chunk and whose holding chunk was
fetched. Such an import resolves into an already-loaded chunk without a
request, so the capture cannot observe it (app-sidebar -> session
narration on /chat); facade targets the route never requested stay out.

Remove the hand-tuned chat-transcript-layout stable group. It bundled
chat-transcript-scroll-events (needed by /new) with six chat-only layout
modules, which statically pulled the chat boot chunks into the /new
preload template once the chat code left the shared group.

Measured with ui:build + ui:check-performance against main 00f3a20630
(same numbers on a Linux Testbox):
- /new boot JS: 1,566,917 B -> 1,190,648 B gzip (-24.0%), 52 -> 45 requests
- /new boot CSS: 120,083 B -> 108,603 B gzip, 8 -> 7 requests
- /chat boot JS: 1,486,675 B -> 1,483,409 B gzip, 50 -> 51 requests
- largest JS chunk: 206.3 KiB -> 179.2 KiB gzip (budget 215 KiB unchanged)

A Playwright capture of both routes on both builds (route preloads active,
mock Gateway) found no late chunk fetches beyond the template, no page
errors, and identical computed styles for every body element.

The regenerated manifest also carries drift main already had: a plain
regeneration moves session-snapshot-database to shared, so its chunking
test now checks the stable group against whichever boot group claims it.
This commit is contained in:
Peter Steinberger 2026-09-30 22:00:49 -07:00 • committed by GitHub
parent 317424a56c
commit 11828a081f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 451 additions and 359 deletions

View file

@ -94,7 +94,7 @@ This directory owns Control UI-specific guidance that should not live in the rep
## Build Chunking
- `ui/config/control-ui-boot-modules.json` is generated from ready `/new` and `/chat` captures. Shared modules and each route's exclusive modules get separate `control-ui-boot-*` groups in `ui/config/control-ui-chunking.ts`, reducing requests without pulling chat-only code into New Session. Its `entries` record contains the dynamic entry points actually requested by each route; `control-ui-boot-preloads.ts` follows their static dependencies to emit inert route preload templates, which the Gateway activates for the requested route. CSS hints preload bytes without changing stylesheet insertion order. Regenerate with `pnpm ui:boot-manifest:gen` when boot-path surfaces change materially; it builds into a temporary directory with all measured boot groups disabled and inactive preload templates so stale entries cannot feed back into the capture. Rebuild with `pnpm ui:build` afterward to verify grouped output. Do not hand-edit the manifest.
- `ui/config/control-ui-boot-modules.json` is generated from ready `/new` and `/chat` captures. Each route records only fetched modules reachable from the HTML entry and the dynamic entries it requested (static imports, plus dynamic imports that resolve into an already-fetched chunk without a request), so chat-only code co-located in a fetched common chunk stays out of New Session. Shared modules and each route's exclusive modules get separate `control-ui-boot-*` groups in `ui/config/control-ui-chunking.ts`, reducing requests without pulling chat-only code into New Session. Its `entries` record contains the dynamic entry points actually requested by each route; `control-ui-boot-preloads.ts` follows their static dependencies to emit inert route preload templates, which the Gateway activates for the requested route. CSS hints preload bytes without changing stylesheet insertion order. Regenerate with `pnpm ui:boot-manifest:gen` when boot-path surfaces change materially; it builds into a temporary directory with all measured boot groups disabled and inactive preload templates so stale entries cannot feed back into the capture. Rebuild with `pnpm ui:build` afterward to verify grouped output. Do not hand-edit the manifest.
## Live Verification

File diff suppressed because it is too large Load diff

View file

@ -68,16 +68,6 @@ export function controlUiStableChunkName(id: string): string | undefined {
case "ui/src/components/sidebar-update-card.ts":
case "ui/src/styles/sidebar-update-card.css":
return "sidebar-update-runtime";
case "ui/src/pages/chat/components/chat-transcript-geometry.ts":
case "ui/src/pages/chat/components/chat-transcript-header.ts":
case "ui/src/pages/chat/components/chat-transcript-layout-owner.ts":
case "ui/src/pages/chat/components/chat-transcript-layout.ts":
case "ui/src/pages/chat/components/chat-transcript-offset-observer.ts":
case "ui/src/pages/chat/components/chat-transcript-row-refs.ts":
case "ui/src/pages/chat/components/chat-transcript-scroll-events.ts":
// Keep row layout, measurement, and their event channel together,
// independent of the growing shared transcript content renderers.
return "chat-transcript-layout";
case "ui/src/pages/chat/session-snapshot-database.ts":
// Warm boot reads while the Gateway connects; the chat boot group made it wait for the whole route.
return "session-snapshot-database";

View file

@ -1,8 +1,8 @@
#!/usr/bin/env -S node --import tsx
// Regenerates ui/config/control-ui-boot-modules.json: the measured module set
// shared shell and route-specific boot flows load lazily, plus requested dynamic
// entry points. Builds without the previous boot groups, then captures ready
// routes against the mocked Gateway.
// Regenerates ui/config/control-ui-boot-modules.json: the modules shared shell and
// route-specific boot flows need, plus requested dynamic entry points. Builds
// without the previous boot groups, captures ready routes against the mocked
// Gateway, and keeps fetched modules reachable from what each route requested.
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
@ -125,6 +125,42 @@ function manifestKeysForChunks(chunkPaths: Iterable<string>, distDir: string): s
return [...keys].toSorted();
}
type BootCaptureGraph = {
entryModules: string[];
// Dynamic-entry facade chunk path -> facade module id.
dynamicEntries: Map<string, string>;
moduleChunks: Map<string, string>;
imports: Map<string, { static: readonly string[]; dynamic: readonly string[] }>;
};
function routeModuleKeys(
graph: BootCaptureGraph,
chunks: ReadonlySet<string>,
roots: readonly string[],
): Set<string> {
const facades = new Set(graph.dynamicEntries.values());
const seen = new Set<string>();
const pending = [...roots];
for (let id = pending.pop(); id !== undefined; id = pending.pop()) {
if (seen.has(id)) {
continue;
}
seen.add(id);
const imports = graph.imports.get(id);
pending.push(...(imports?.static ?? []));
// A dynamic import without its own facade chunk resolves to the chunk that
// holds its target. Once that chunk is loaded the import sends no request,
// so the capture cannot tell whether it ran; keep its dependencies.
for (const target of imports?.dynamic ?? []) {
const chunk = graph.moduleChunks.get(target);
if (!facades.has(target) && chunk !== undefined && chunks.has(chunk)) {
pending.push(target);
}
}
}
return new Set([...seen].map(controlUiBootManifestKey));
}
function partitionBootKeys(routes: Record<"new" | "chat", Set<string>>) {
const shared = new Set([...routes.new].filter((key) => routes.chat.has(key)));
const sorted = (keys: Iterable<string>) =>
@ -140,7 +176,12 @@ async function main(): Promise<void> {
const distDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-control-ui-boot-"));
try {
const config = controlUiViteConfig({ outDir: distDir });
const dynamicEntries = new Map<string, string>();
const graph: BootCaptureGraph = {
entryModules: [],
dynamicEntries: new Map(),
moduleChunks: new Map(),
imports: new Map(),
};
await build({
...config,
configFile: false,
@ -158,12 +199,25 @@ async function main(): Promise<void> {
};
},
generateBundle(_options, bundle) {
for (const id of this.getModuleIds()) {
const info = this.getModuleInfo(id);
graph.imports.set(id, {
static: info?.importedIds ?? [],
dynamic: info?.dynamicallyImportedIds ?? [],
});
}
for (const chunk of Object.values(bundle)) {
if (chunk.type === "chunk" && chunk.isDynamicEntry && chunk.facadeModuleId) {
dynamicEntries.set(
`/${chunk.fileName}`,
controlUiBootManifestKey(chunk.facadeModuleId),
);
if (chunk.type !== "chunk") {
continue;
}
for (const id of chunk.moduleIds) {
graph.moduleChunks.set(id, `/${chunk.fileName}`);
}
if (chunk.isEntry && chunk.facadeModuleId) {
graph.entryModules.push(chunk.facadeModuleId);
}
if (chunk.isDynamicEntry && chunk.facadeModuleId) {
graph.dynamicEntries.set(`/${chunk.fileName}`, chunk.facadeModuleId);
}
}
},
@ -176,20 +230,22 @@ async function main(): Promise<void> {
const routeEntries = { new: new Set<string>(), chat: new Set<string>() };
for (const route of ["new", "chat"] as const) {
const chunks = await collectBootChunkPaths(server.baseUrl, distDir, route);
routes[route] = new Set(manifestKeysForChunks(chunks, distDir));
for (const chunk of chunks) {
const entry = dynamicEntries.get(chunk);
if (entry) {
routeEntries[route].add(entry);
}
}
const requestedEntries = [...chunks].flatMap(
(chunk) => graph.dynamicEntries.get(chunk) ?? [],
);
routeEntries[route] = new Set(requestedEntries.map(controlUiBootManifestKey));
// Fetched shared chunks can co-locate modules only the other route
// imports; keep the modules reachable from what this route requested.
const needed = routeModuleKeys(graph, chunks, [...graph.entryModules, ...requestedEntries]);
const fetched = manifestKeysForChunks(chunks, distDir);
routes[route] = new Set(fetched.filter((key) => needed.has(key)));
if (routes[route].size < 100) {
throw new Error(
`Boot capture looks truncated: ${route} recorded only ${routes[route].size} modules`,
);
}
console.log(
`control-ui-boot-manifest: ${route}: ${chunks.size} chunks, ${routes[route].size} modules, ${routeEntries[route].size} dynamic entries`,
`control-ui-boot-manifest: ${route}: ${chunks.size} chunks, ${routes[route].size} of ${fetched.length} fetched modules reachable, ${routeEntries[route].size} dynamic entries`,
);
}
const modules = partitionBootKeys(routes);

View file

@ -129,18 +129,21 @@ describe("Control UI build chunking", () => {
});
});
it("lets snapshot prewarming load independently of the measured chat boot group", () => {
it("lets snapshot prewarming load independently of the measured boot groups", () => {
const database = new URL("../pages/chat/session-snapshot-database.ts", import.meta.url)
.pathname;
const stableGroup = controlUiCodeSplitting.groups[0];
const chatGroup = controlUiCodeSplitting.groups.find(
(group) => group.name === "control-ui-boot-chat",
const bootGroup = controlUiCodeSplitting.groups.find(
(group) =>
typeof group.name === "string" &&
/^control-ui-boot-(?:shared|new|chat)$/u.test(group.name) &&
group.test?.(database),
)!;
expect(chatGroup.test?.(database)).toBe(true);
expect(bootGroup).toBeDefined();
expect(stableGroup?.test?.(database)).toBe(true);
expect(controlUiStableChunkName(database)).toBe("session-snapshot-database");
expect(stableGroup?.priority).toBeGreaterThan(chatGroup.priority);
expect(stableGroup?.priority).toBeGreaterThan(bootGroup.priority);
});
it("consolidates shared boot without pulling in the chat route or optional panels", () => {
@ -158,7 +161,13 @@ describe("Control UI build chunking", () => {
// Representative always-loaded boot surface and a lazy island that must
// keep its own chunk (terminal runtime is not part of the default boot).
expect(bootGroup.test(`${repoRoot}/ui/src/components/app-sidebar.ts`)).toBe(true);
// Chat reaches narration through a dynamic import without a request of its own.
expect(bootGroup.test(`${repoRoot}/ui/src/components/app-sidebar-session-narration.ts`)).toBe(
true,
);
expect(bootGroup.test(`${repoRoot}/ui/src/pages/chat/chat-page.ts`)).toBe(false);
// Fetched shared chunks once co-located the chat view with modules New Session needs.
expect(bootGroup.test(`${repoRoot}/ui/src/pages/chat/chat-view.ts`)).toBe(false);
expect(bootGroup.test(`${repoRoot}/ui/src/styles/chat.ts`)).toBe(false);
expect(bootGroup.test(`${repoRoot}/ui/src/components/assistant-panel-content.ts`)).toBe(false);
expect(bootGroup.test(`${repoRoot}/ui/src/pages/debug/debug-overlay-content.ts`)).toBe(false);