diff --git a/docs/performance-budgets.md b/docs/performance-budgets.md index 25c1c32f..48852e80 100644 --- a/docs/performance-budgets.md +++ b/docs/performance-budgets.md @@ -12,17 +12,25 @@ The command writes `report.json` and `report.md` and prints the Markdown report. For each configured App Router route, resolve its exact app path using `app-path-routes-manifest.json` and `server/app-paths-manifest.json`. Read its generated `page_client-reference-manifest.js` as a JSON assignment **without executing JavaScript**. Use its sibling `page/build-manifest.json`, falling back to the root build manifest only if that sibling is absent. -The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every `entryJSFiles` list in that route's client-reference manifest. This includes layout, page and boundary/loading entries. The definition follows the data exposed by the installed Next 16.3.4 Turbopack build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests. +The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every client chunk that route's client-reference manifest lists. This includes layout, page and boundary/loading entries. + +The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk *path* still fails rather than being dropped, so a broken manifest cannot quietly under-report a route. + +> The build runs webpack (`next build --webpack`), so the `clientModules` path is the live one. An earlier revision only read `entryJSFiles`, and after the switch to webpack every route reported `unavailable` while the command still exited 0 — the budgets were silently not being measured. When a bundler switch changes the manifest layout again, re-check this section rather than trusting a clean exit. + +The definition follows the data exposed by the installed Next 16.3.8 build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests. - Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope. - Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes. - Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total. -- Legacy `nomodule` polyfills are measured separately, outside the modern initial budget. CSS, source maps, images, external scripts, HTML/RSC payloads and async-only chunks absent from `entryJSFiles` are excluded. +- Legacy `nomodule` polyfills are measured separately, outside the modern initial budget. CSS, source maps, images, external scripts, HTML/RSC payloads and async-only chunks absent from the manifest's chunk lists are excluded. - This report makes no claims about execution cost, LCP, hydration time or real-user performance. ## Initial limits -The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: existing local production build `build-TfctsWXpff2fKS`, Next 16.3.4; its source commit was not inferred. +The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: local production build `build-TfctsWXpff2fKS`, Next 16.3.4 **Turbopack**; its source commit was not inferred. + +The production build now runs webpack, so the numbers it reports are not directly comparable to the baseline below. Re-measured on the current webpack build the routes land at `/me` 786138/247738, `/news` 781601/245672, `/events` 782011/245923, `/search` 783262/246578, `/admin/catalog` 1172089/370843, `/admin/studio/furni` 1374128/440546 (raw/gzip). All remain inside the limits below, but `/admin/studio/furni` sits at ~98% of its gzip limit, so the next dependency added to that route will trip it. Recalibrate the table and `scripts/performance-budgets.json` together if the intent is to reset the baseline on webpack. | Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit | | --- | ---: | ---: | ---: | ---: | diff --git a/scripts/performance-report.mjs b/scripts/performance-report.mjs index 7803fb76..234cfbed 100644 --- a/scripts/performance-report.mjs +++ b/scripts/performance-report.mjs @@ -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, }; } diff --git a/scripts/performance-report.test.mjs b/scripts/performance-report.test.mjs index cceae38b..92f8adc0 100644 --- a/scripts/performance-report.test.mjs +++ b/scripts/performance-report.test.mjs @@ -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,