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

+62 -13
View File
@@ -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,
};
}