perf(dashboard): server-prefetch the snapshot into the first HTML + bundle budget gate

The measured first-load gap on / was never the server: FCP painted the
SSR shell at ~1.8 s but LCP landed at ~5.5 s (mobile-throttled) because
the tiles waited for JS download + hydrate + a CLIENT-side snapshot
fetch — while /api/dashboard/snapshot answers in ~8 ms server-side.

src/app/page.tsx is now a thin RSC wrapper: it runs the same cached
snapshot read the API route uses (extracted to
src/lib/dashboard/snapshot-read.ts, one shared SWR cell for both
readers) and hands the result to TanStack via HydrationBoundary +
dehydrate, so the first HTML carries real tile data and the mounted
cell starts warm (staleTime 60 s, refetchOnMount false — no double
fetch). Query keys come only from the central factory
(dashboardSnapshot(locale) + the dashboardWidgets seed the client cell
already performs); the payload is JSON-round-tripped so the hydrated
shape is byte-equal to the wire shape. Fail-soft: no session or any
builder error renders the page exactly as before. The 1,727-line client
page moves unchanged to src/app/page-client.tsx; no component-tree
rewrite.

Companion: the documented bundle tooling was dead on Turbopack
(ANALYZE=1 silent no-op, bundle-report read a webpack-only JSON), so
H2/H3-class regressions shipped invisibly. scripts/check-bundle-budget.mjs
derives per-route gzip totals from the Turbopack client-reference
manifests + rootMainFiles, prints the report (pnpm bundle-report), and
enforces bundle-budget.json (pnpm bundle-check): per-route caps,
total-client-JS cap, a recharts-duplication guard (fingerprint count
must stay 1), and a catalog guard (no message catalog referenced by any
route manifest). Wired into the existing e2e workflow after next build.

H4
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 12:51:18 +02:00
1 parent e24db8a889
commit cb9d61a311
17 files changed
+2151 -1903

No files matched your search

+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env node
/**
* Client-bundle report + budget gate for the Turbopack build.
*
* The webpack-era tooling died on Next 16: `ANALYZE=1` is a silent no-op
* under Turbopack and the old `.next/analyze/client.json` reader had
* nothing to read — so bundle regressions (a statically imported message
* catalog, a duplicated recharts chunk group) shipped invisibly. This
* script derives the signal straight from the build output instead:
*
* - per-route client-JS totals (gzip) from the Turbopack
* `*_client-reference-manifest.js` files plus the shared
* `rootMainFiles` baseline from `build-manifest.json`;
* - the total gzip weight of every emitted client chunk;
* - a recharts-duplication guard: the number of chunks carrying the
* recharts library fingerprint must stay exactly 1 (the shared
* chart-runtime chunk group);
* - a catalog guard: no message-catalog-fingerprinted chunk may be
* referenced by any route's client-reference manifest or by the
* shared baseline (catalogs load only lazily / via /i18n/<locale>).
*
* Usage:
* pnpm bundle-report # print the report table
* pnpm bundle-report --check # enforce bundle-budget.json (CI gate)
*
* Budgets live in `bundle-budget.json` (repo root). Numbers are KB gzip
* with deliberate headroom over the measured value — the gate exists to
* catch step-change regressions (a ~100 KB catalog, a ~90 KB duplicate
* library), not day-to-day noise. Raise a budget consciously, in the same
* PR that pays the cost, with the reason in the PR body.
*/
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
const CHECK = process.argv.includes("--check");
const BUILD_DIR = join(process.cwd(), ".next");
const APP_DIR = join(BUILD_DIR, "server", "app");
const BUDGET_PATH = join(process.cwd(), "bundle-budget.json");
if (!existsSync(join(BUILD_DIR, "build-manifest.json"))) {
console.error(
"check-bundle-budget: no .next/build-manifest.json — run `pnpm build` first.",
);
process.exit(2);
}
/** Recharts library fingerprint (present once in the shared runtime chunk). */
const RECHARTS_MARK = "CartesianGrid";
/**
* Message-catalog fingerprint. The key set is identical across all six
* locales, so a KEY-position match ("typeTimeInDaylight" directly followed
* by a colon, in either the plain or the JSON.parse-escaped chunk encoding)
* marks a bundled catalog. Source modules only ever carry the namespaced
* call-site form (`measurements.typeTimeInDaylight`), which matches neither.
*/
const CATALOG_MARKS = ['"typeTimeInDaylight":', 'typeTimeInDaylight\\":'];
const sizeCache = new Map();
function chunkInfo(file) {
if (!sizeCache.has(file)) {
try {
const buf = readFileSync(join(BUILD_DIR, file));
sizeCache.set(file, {
raw: buf.length,
gz: gzipSync(buf, { level: 9 }).length,
recharts: buf.includes(RECHARTS_MARK),
catalog: CATALOG_MARKS.some((m) => buf.includes(m)),
});
} catch {
sizeCache.set(file, { raw: 0, gz: 0, recharts: false, catalog: false });
}
}
return sizeCache.get(file);
}
function routeChunks(manifestFile) {
const src = readFileSync(manifestFile, "utf8");
const idx = src.indexOf("= {");
const json = JSON.parse(src.slice(idx + 2).replace(/;\s*$/, ""));
const files = new Set();
for (const key of Object.keys(json.clientModules ?? {})) {
for (const chunk of json.clientModules[key].chunks ?? []) {
if (chunk.endsWith(".js")) files.add(chunk.replace(/^\/_next\//, ""));
}
}
return files;
}
function findRouteManifests(dir, prefix = "") {
let out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) {
out = out.concat(findRouteManifests(p, `${prefix}/${entry.name}`));
} else if (entry.name.endsWith("_client-reference-manifest.js")) {
out.push({
route: `${prefix}/${entry.name.replace("_client-reference-manifest.js", "")}`,
file: p,
});
}
}
return out;
}
const kb = (bytes) => bytes / 1024;
const fmt = (bytes) => `${kb(bytes).toFixed(0)} KB`;
// ── Shared baseline ─────────────────────────────────────────────────────────
const buildManifest = JSON.parse(
readFileSync(join(BUILD_DIR, "build-manifest.json"), "utf8"),
);
const rootFiles = buildManifest.rootMainFiles ?? [];
let rootGz = 0;
for (const f of rootFiles) rootGz += chunkInfo(f).gz;
// ── Per-route totals ────────────────────────────────────────────────────────
const manifests = findRouteManifests(APP_DIR);
const routeTotals = new Map();
for (const { route, file } of manifests) {
let gz = 0;
let catalogRef = false;
for (const chunk of routeChunks(file)) {
const info = chunkInfo(chunk);
gz += info.gz;
if (info.catalog) catalogRef = true;
}
routeTotals.set(route, { gz: gz + rootGz, catalogRef });
}
// ── Whole-build chunk scan ──────────────────────────────────────────────────
const chunkDir = join(BUILD_DIR, "static", "chunks");
let totalGz = 0;
let rechartsChunks = 0;
let largest = { file: "-", gz: 0 };
for (const f of readdirSync(chunkDir)) {
if (!f.endsWith(".js")) continue;
const info = chunkInfo(join("static", "chunks", f));
totalGz += info.gz;
if (info.recharts) rechartsChunks += 1;
if (info.gz > largest.gz) largest = { file: f, gz: info.gz };
}
const rootCatalogRef = rootFiles.some((f) => chunkInfo(f).catalog);
// ── Report ──────────────────────────────────────────────────────────────────
const budget = existsSync(BUDGET_PATH)
? JSON.parse(readFileSync(BUDGET_PATH, "utf8"))
: null;
console.log(`shared baseline (rootMainFiles): ${fmt(rootGz)} gz`);
console.log(`all client chunks: ${fmt(totalGz)} gz`);
console.log(`largest chunk: ${fmt(largest.gz)} gz ${largest.file}`);
console.log(`recharts-fingerprint chunks: ${rechartsChunks}`);
console.log("");
const watched = budget ? Object.keys(budget.routesKbGz ?? {}) : [];
const rows = [...routeTotals.entries()]
.filter(([route]) => !watched.length || watched.includes(route))
.sort((a, b) => b[1].gz - a[1].gz);
for (const [route, { gz }] of rows.slice(0, watched.length || 15)) {
const cap = budget?.routesKbGz?.[route];
console.log(
`${fmt(gz).padStart(8)} gz ${route}${cap ? ` (budget ${cap} KB)` : ""}`,
);
}
if (!CHECK) process.exit(0);
// ── Budget gate ─────────────────────────────────────────────────────────────
if (!budget) {
console.error("check-bundle-budget: bundle-budget.json missing.");
process.exit(2);
}
const failures = [];
for (const [route, capKb] of Object.entries(budget.routesKbGz ?? {})) {
const actual = routeTotals.get(route);
if (!actual) {
failures.push(`route ${route} not found in the build output`);
continue;
}
if (kb(actual.gz) > capKb) {
failures.push(
`${route}: ${fmt(actual.gz)} gz exceeds the ${capKb} KB budget`,
);
}
}
if (budget.totalClientKbGz && kb(totalGz) > budget.totalClientKbGz) {
failures.push(
`total client JS ${fmt(totalGz)} gz exceeds the ${budget.totalClientKbGz} KB budget`,
);
}
if (
budget.maxRechartsChunks != null &&
rechartsChunks > budget.maxRechartsChunks
) {
failures.push(
`${rechartsChunks} recharts-fingerprint chunks (budget ${budget.maxRechartsChunks}) — a chart import bypassed the shared chart-runtime boundary`,
);
}
const catalogRefRoutes = [...routeTotals.entries()]
.filter(([, v]) => v.catalogRef)
.map(([r]) => r);
if (catalogRefRoutes.length > 0 || rootCatalogRef) {
failures.push(
`message catalog statically referenced by ${rootCatalogRef ? "the shared baseline" : catalogRefRoutes.join(", ")} — catalogs must stay lazy (/i18n/<locale> + dynamic import)`,
);
}
if (failures.length > 0) {
console.error("\nBundle budget check FAILED:");
for (const f of failures) console.error(` - ${f}`);
process.exit(1);
}
console.log("\nBundle budget check passed.");
-118
View File
@@ -1,118 +0,0 @@
#!/usr/bin/env node
/**
* Reads `.next/analyze/client.json` (emitted when `pnpm analyze` runs
* `@next/bundle-analyzer` with `ANALYZE=1`) and prints a sorted table
* of the largest client chunks. Turbopack dropped the webpack
* "First Load JS" summary line from `next build`; this script restores
* an equivalent at-a-glance signal so release rounds can spot
* client-bundle regressions without re-running Lighthouse.
*
* Usage:
* pnpm analyze # writes .next/analyze/client.json
* pnpm bundle-report # reads it back, prints top chunks
*
* Output columns: rank, chunk label, parsed (post-minify) size, gzip
* size, stat (pre-minify) size. Sizes report in KiB to two decimals.
* Exits 0 unless `client.json` is missing or unreadable.
*/
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
const REPORT_PATH = resolve(process.cwd(), ".next", "analyze", "client.json");
const DEFAULT_TOP_N = 10;
function formatKib(bytes) {
if (typeof bytes !== "number" || Number.isNaN(bytes)) return "—";
return `${(bytes / 1024).toFixed(2)} KiB`;
}
function padCell(value, width) {
const str = String(value ?? "");
return str.length >= width ? str : str + " ".repeat(width - str.length);
}
async function main() {
const argTopN = Number(process.argv[2]);
const topN =
Number.isFinite(argTopN) && argTopN > 0
? Math.floor(argTopN)
: DEFAULT_TOP_N;
let raw;
try {
raw = await readFile(REPORT_PATH, "utf8");
} catch (err) {
if (err && err.code === "ENOENT") {
console.error(
`bundle-report: ${REPORT_PATH} not found. Run \`pnpm analyze\` first.`,
);
} else {
console.error(`bundle-report: failed to read ${REPORT_PATH}:`, err);
}
process.exit(1);
}
let chunks;
try {
chunks = JSON.parse(raw);
} catch (err) {
console.error(`bundle-report: failed to parse ${REPORT_PATH}:`, err);
process.exit(1);
}
if (!Array.isArray(chunks)) {
console.error(
"bundle-report: client.json is not an array of chunks — analyzer output shape changed?",
);
process.exit(1);
}
const sorted = [...chunks].sort(
(a, b) => (b.parsedSize ?? 0) - (a.parsedSize ?? 0),
);
const top = sorted.slice(0, topN);
const totals = chunks.reduce(
(acc, c) => ({
parsed: acc.parsed + (c.parsedSize ?? 0),
gzip: acc.gzip + (c.gzipSize ?? 0),
stat: acc.stat + (c.statSize ?? 0),
}),
{ parsed: 0, gzip: 0, stat: 0 },
);
const labelWidth = Math.max(
20,
...top.map((c) => String(c.label ?? "").length),
);
const header =
`${padCell("#", 3)} ${padCell("chunk", labelWidth)} ` +
`${padCell("parsed", 12)} ${padCell("gzip", 12)} ${padCell("stat", 12)}`;
console.log(
`Top ${top.length} client chunks by parsed size (of ${chunks.length} total):`,
);
console.log(header);
console.log("-".repeat(header.length));
top.forEach((chunk, idx) => {
console.log(
`${padCell(idx + 1, 3)} ${padCell(chunk.label ?? "(unnamed)", labelWidth)} ` +
`${padCell(formatKib(chunk.parsedSize), 12)} ` +
`${padCell(formatKib(chunk.gzipSize), 12)} ` +
`${padCell(formatKib(chunk.statSize), 12)}`,
);
});
console.log("-".repeat(header.length));
console.log(
`${padCell("Σ", 3)} ${padCell("all chunks", labelWidth)} ` +
`${padCell(formatKib(totals.parsed), 12)} ` +
`${padCell(formatKib(totals.gzip), 12)} ` +
`${padCell(formatKib(totals.stat), 12)}`,
);
}
main().catch((err) => {
console.error("bundle-report: unexpected failure:", err);
process.exit(1);
});