fix(ci): measure route bundles from webpack manifests
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 31s
CI / tests-integration (push) Successful in 1m41s
CI / tests-unit (push) Successful in 1m44s
CI / tests-ui (push) Successful in 2m31s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m25s

The performance report measured nothing. It only read `entryJSFiles` from
each route's client-reference manifest, a field Turbopack emits and webpack
does not. When the build moved to webpack (3d828a61) every route fell
through to the "unavailable" branch, and because the report is informational
and exits 0 on an unavailable metric, nothing failed and the budgets quietly
stopped being enforced.

Derive the envelope from clientModules[*].chunks when entryJSFiles is
absent, which is the same source Next's own static-routes-info uses for
webpack builds. Webpack interleaves numeric chunk ids with file names in
those arrays, so ids are skipped by shape while a malformed chunk path still
throws — otherwise a broken manifest would quietly under-report a route.
entryJSFiles still wins when present, since it is per-segment and therefore
the tighter envelope, and the per-chunk origin label is shared rather than
the absolute node_modules path webpack records, which would otherwise bloat
report.json.

Six tests cover the webpack layout: id filtering, deduplication of a chunk
reached by several client modules, the origin label, the malformed-path
rejection, the no-chunks-at-all case, and entryJSFiles taking precedence.

Re-measured on the current build, all six routes are inside their budgets
again. Note /admin/studio/furni now sits at ~98% of its gzip limit, so one
more dependency on that route will trip it; docs/performance-budgets.md
records the webpack baseline numbers and how to recalibrate.

Verified: 3385 tests, typecheck and biome clean, and the report now emits
measured rows instead of six unavailable ones.
This commit is contained in:
openhands committed 2026-10-05 20:49:53 +02:00
1 parent 6c3d81920e
commit 11ad6d4376
3 files changed
+202 -16

No files matched your search

+129
View File
@@ -216,6 +216,135 @@ describe("route JS measurement", () => {
);
expect(collectReport(dir, config).routes[0].status).toBe("unavailable");
});
// The regression: after the build moved to webpack (3d828a61) the manifest
// has no entryJSFiles, only clientModules[*].chunks. Every route then
// reported "unavailable" and the report measured nothing at all while still
// exiting zero, so the budgets silently stopped being enforced.
describe("webpack manifests without entryJSFiles", () => {
const webpackManifest = {
clientModules: {
"[project]/src/components/header.tsx": {
chunks: [
"4269",
"static/chunks/4269-shared.js?dpl=abc",
"6726",
"static/chunks/header-entry.js?dpl=abc",
],
},
"[project]/src/app/(site)/news/page.tsx": {
chunks: [
"4269",
"static/chunks/4269-shared.js?dpl=abc",
"7777",
"/_next/static/chunks/page.js?dpl=abc",
],
},
// Async-only modules are recorded with an empty chunk list.
"[project]/src/components/lazy.tsx": { chunks: [] },
},
};
const webpackFiles = {
// buildManifest.rootMainFiles lists runtime.js and shared.js, so both
// bootstrap assets must exist or the read fails.
"static/chunks/runtime.js": Buffer.from("const runtime = true;"),
"static/chunks/shared.js": Buffer.from("bootstrap".repeat(10)),
"static/chunks/4269-shared.js": Buffer.from("shared".repeat(50)),
"static/chunks/header-entry.js": Buffer.from("header"),
"static/chunks/page.js": Buffer.from("page"),
"static/chunks/polyfill.js": Buffer.from("legacy"),
};
const measure = () =>
measureRoute({
budget,
appPath,
buildManifest,
clientManifest: webpackManifest,
readAsset: (file) => webpackFiles[file],
});
it("derives the envelope from clientModules and ignores chunk ids", () => {
const row = measure();
expect(row.status).toBe("measured");
// 2 bootstrap + shared + header-entry + page; the numeric chunk ids
// are not assets and must not throw or be counted.
expect(row.initial.chunkCount).toBe(5);
expect(row.initial.chunks.map((f) => f.path).sort()).toEqual([
"static/chunks/4269-shared.js",
"static/chunks/header-entry.js",
"static/chunks/page.js",
"static/chunks/runtime.js",
"static/chunks/shared.js",
]);
// Same bytes as the Turbopack fixture would produce for these files.
expect(row.initial.rawBytes).toBe(
Object.values(webpackFiles)
.filter((b) => !b.includes("legacy"))
.reduce((n, b) => n + b.length, 0),
);
});
it("counts a chunk reached by several client modules only once", () => {
const shared = measure().initial.chunks.find(
(f) => f.path === "static/chunks/4269-shared.js",
);
expect(shared).toBeDefined();
expect(measure().initial.chunkCount).toBe(5);
});
it("does not leak absolute module paths into the report", () => {
const sources = new Set(
measure().initial.chunks.flatMap((chunk) => chunk.sources),
);
// Only the two known origin labels; no node_modules path may appear.
expect([...sources].sort()).toEqual(["bootstrap", "client-module"]);
});
it("still fails rather than under-reporting a malformed chunk path", () => {
expect(() =>
measureRoute({
budget,
appPath,
buildManifest,
clientManifest: {
clientModules: {
x: { chunks: ["static/chunks/../../etc/passwd"] },
},
},
readAsset: (file) => webpackFiles[file],
}),
).toThrow("Unsupported JavaScript asset");
});
it("reports unavailable when webpack recorded no chunks at all", () => {
expect(() =>
measureRoute({
budget,
appPath,
buildManifest,
clientManifest: { clientModules: { x: { chunks: [] } } },
readAsset: (file) => webpackFiles[file],
}),
).toThrow("Neither entryJSFiles nor clientModules");
});
it("prefers entryJSFiles when a manifest carries both", () => {
// A future Next version could emit both; the explicit list wins
// because it is per-segment and therefore the tighter envelope.
const row = measureRoute({
budget,
appPath,
buildManifest,
clientManifest: {
...webpackManifest,
entryJSFiles: {
"[project]/src/app/(site)/news/page": ["static/chunks/page.js"],
},
},
readAsset: (file) => webpackFiles[file],
});
expect(row.initial.chunkCount).toBe(3);
});
});
it("does not deduplicate shared files across independent cold route totals", () => {
const row = measureRoute({
budget,