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
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:
1 parent
6c3d81920e
commit
11ad6d4376
3 files changed
+202
-16
No files matched your search
@@ -79,6 +79,27 @@ function filesFrom(values) {
|
||||
return values.map(normalizeAsset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Webpack interleaves numeric chunk ids with file names in `chunks` arrays, so
|
||||
* a real file has to be separated from its id. An id is rejected by
|
||||
* normalizeAsset for the right reason (it has no `static/` prefix and no `.js`
|
||||
* suffix), which makes it a usable filter — but only for ids. A malformed
|
||||
* *path* must still fail loudly rather than be silently dropped, or a broken
|
||||
* manifest would quietly under-report a route's real weight.
|
||||
*/
|
||||
function assetFilesFromChunkList(values) {
|
||||
if (!Array.isArray(values))
|
||||
throw new Error("Unsupported JavaScript chunk list.");
|
||||
const files = [];
|
||||
for (const value of values) {
|
||||
if (typeof value !== "string")
|
||||
throw new Error("Non-string JavaScript asset.");
|
||||
if (/^\d+$/.test(value)) continue;
|
||||
files.push(normalizeAsset(value));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function measureRoute({
|
||||
budget,
|
||||
appPath,
|
||||
@@ -86,18 +107,44 @@ export function measureRoute({
|
||||
clientManifest,
|
||||
readAsset,
|
||||
}) {
|
||||
// Turbopack emits an explicit per-segment `entryJSFiles` list. Webpack does
|
||||
// not — it only records chunks per client module — so after the build moved
|
||||
// to webpack (3d828a61) every route reported "unavailable" and the report
|
||||
// silently stopped measuring anything. Fall back to the same source Next's
|
||||
// own `static-routes-info` uses for webpack builds.
|
||||
const entries = clientManifest?.entryJSFiles;
|
||||
if (!entries || typeof entries !== "object" || Array.isArray(entries))
|
||||
const webpackModules = clientManifest?.clientModules;
|
||||
let filesBySource;
|
||||
let webpackLayout = false;
|
||||
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
|
||||
const sourceEntries = Object.keys(entries);
|
||||
if (
|
||||
!sourceEntries.some((key) =>
|
||||
key.replaceAll("\\", "/").endsWith(`/app${appPath}`),
|
||||
)
|
||||
)
|
||||
throw new Error("Route page entry is absent from entryJSFiles.");
|
||||
filesBySource = Object.entries(entries);
|
||||
} else if (webpackModules && typeof webpackModules === "object") {
|
||||
webpackLayout = true;
|
||||
// Each `chunks` array is `[chunkId, fileName, chunkId, fileName, ...]`.
|
||||
filesBySource = [];
|
||||
for (const node of Object.values(webpackModules)) {
|
||||
if (!Array.isArray(node?.chunks) || node.chunks.length === 0) continue;
|
||||
// One shared origin label instead of the module path: the per-chunk
|
||||
// `sources` list is written into report.json, and webpack records
|
||||
// absolute node_modules paths for every client module on the route.
|
||||
filesBySource.push(["client-module", node.chunks]);
|
||||
}
|
||||
if (filesBySource.length === 0)
|
||||
throw new Error(
|
||||
"Neither entryJSFiles nor clientModules chunk data is available; this manifest layout is not supported.",
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
"entryJSFiles is unavailable; this manifest layout is not supported.",
|
||||
);
|
||||
const sourceEntries = Object.keys(entries);
|
||||
if (
|
||||
!sourceEntries.some((key) =>
|
||||
key.replaceAll("\\", "/").endsWith(`/app${appPath}`),
|
||||
)
|
||||
)
|
||||
throw new Error("Route page entry is absent from entryJSFiles.");
|
||||
}
|
||||
const bootstrap = filesFrom(
|
||||
buildManifest.rootMainFilesTree?.[appPath] ?? buildManifest.rootMainFiles,
|
||||
);
|
||||
@@ -110,8 +157,9 @@ export function measureRoute({
|
||||
origins.set(file, sources);
|
||||
};
|
||||
for (const file of bootstrap) add(file, "bootstrap");
|
||||
for (const [entry, values] of Object.entries(entries))
|
||||
for (const file of filesFrom(values)) add(file, entry);
|
||||
const readChunkList = webpackLayout ? assetFilesFromChunkList : filesFrom;
|
||||
for (const [entry, values] of filesBySource)
|
||||
for (const file of readChunkList(values)) add(file, entry);
|
||||
const size = (file, sources) => {
|
||||
const bytes = readAsset(file);
|
||||
return {
|
||||
@@ -248,12 +296,13 @@ export function collectReport(nextDir, config, metadata = {}) {
|
||||
"Optional PERFORMANCE_COMMIT_SHA supplied by the build caller; not inferred from current checkout.",
|
||||
nodeVersion: process.version,
|
||||
zlibVersion: process.versions.zlib,
|
||||
manifestFormat: "Next App Router client-reference entryJSFiles",
|
||||
manifestFormat:
|
||||
"Turbopack: client-reference entryJSFiles. Webpack: deduplicated clientModules[*].chunks.",
|
||||
definition:
|
||||
"Initial entry envelope: deduplicated rootMainFiles bootstrap plus all entryJSFiles in this route's client-reference manifest, including boundary/loading entries. This is emitted file size, not measured browser traffic or a load-time benchmark.",
|
||||
"Initial entry envelope: deduplicated rootMainFiles bootstrap plus every client chunk this route's client-reference manifest lists, including boundary/loading entries. Turbopack exposes these as entryJSFiles; webpack exposes them only through clientModules[*].chunks, so the same envelope is derived from whichever the build emitted. This is emitted file size, not measured browser traffic or a load-time benchmark.",
|
||||
gzip: "Sum of each unique JavaScript file independently compressed with Node gzip level 9. Excludes HTTP headers and shared-cache reuse.",
|
||||
excluded:
|
||||
"CSS, source maps, images, RSC/HTML payloads, external scripts, async-only chunks absent from entryJSFiles; legacy nomodule polyfills are reported separately.",
|
||||
"CSS, source maps, images, RSC/HTML payloads, external scripts, async-only chunks absent from the manifest's chunk lists; legacy nomodule polyfills are reported separately.",
|
||||
routes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user