mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
e24db8a889
commit
cb9d61a311
17 files changed
+2151
-1903
No files matched your search
@@ -71,6 +71,14 @@ jobs:
|
||||
|
||||
- run: pnpm exec next build
|
||||
|
||||
# Client-bundle budget gate — rides this job because it is the one
|
||||
# place CI already has a production build on disk. Catches the
|
||||
# regressions the dead webpack-era tooling (ANALYZE=1, the old
|
||||
# bundle-report) let through: a statically imported message catalog
|
||||
# (~100 KB gz on every route) or a chart import bypassing the shared
|
||||
# chart-runtime boundary (a duplicated ~92 KB gz recharts chunk).
|
||||
- run: pnpm bundle-check
|
||||
|
||||
- run: pnpm exec playwright test
|
||||
env:
|
||||
E2E_BASE_URL: http://localhost:3000
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Measured at v1.27.15 + the mobile/PWA fix wave: / 417, /insights 402, /measurements 405, /insights/mood 419, total 2683, baseline 129 — budgets carry ~10-15% headroom so the gate catches step changes (a statically imported ~100 KB catalog, a ~90 KB duplicated library chunk), not noise. Raise a number consciously, in the PR that pays the cost, with the reason in the PR body.",
|
||||
"routesKbGz": {
|
||||
"/page": 460,
|
||||
"/insights/page": 445,
|
||||
"/measurements/page": 445,
|
||||
"/insights/mood/page": 460
|
||||
},
|
||||
"totalClientKbGz": 2950,
|
||||
"maxRechartsChunks": 1
|
||||
}
|
||||
+2
-1
@@ -29,7 +29,8 @@
|
||||
"prebuild": "node scripts/generate-sw-version.mjs",
|
||||
"build": "next build",
|
||||
"analyze": "ANALYZE=1 next build",
|
||||
"bundle-report": "node scripts/print-bundle-report.mjs",
|
||||
"bundle-report": "node scripts/check-bundle-budget.mjs",
|
||||
"bundle-check": "node scripts/check-bundle-budget.mjs --check",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"db:generate": "prisma generate",
|
||||
|
||||
@@ -88,5 +88,17 @@ export default defineConfig({
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
// The dashboard RSC wrapper server-prefetches the snapshot into
|
||||
// the first HTML (HydrationBoundary). Playwright's route mocks
|
||||
// only see CLIENT fetches, so an SSR-embedded snapshot would
|
||||
// bypass `mockDashboardSnapshot` and every dashboard spec would
|
||||
// assert against the seeded account instead of its fixture.
|
||||
// Disable the prefetch for the e2e server — the suite keeps the
|
||||
// deterministic client-fetch path; the SSR fast path is
|
||||
// verified by Lighthouse/manual passes.
|
||||
DASHBOARD_SSR_PREFETCH: "false",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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.");
|
||||
@@ -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);
|
||||
});
|
||||
@@ -27,7 +27,7 @@ import { join } from "node:path";
|
||||
* holds; the matchers only relaxed to tolerate the `enabled` arg.
|
||||
*/
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const PAGE_PATH = join(ROOT, "src/app/page.tsx");
|
||||
const PAGE_PATH = join(ROOT, "src/app/page-client.tsx");
|
||||
|
||||
function load(path: string): string {
|
||||
return readFileSync(path, "utf8");
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
|
||||
const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx");
|
||||
|
||||
describe("resolveChartRevealState", () => {
|
||||
it("stays hidden while the gate is not armed (no visible charts yet)", () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { join } from "node:path";
|
||||
* layout toggle) the header takes it back behind `showGreeting`,
|
||||
* fed from the page's hero gate — the greeting never disappears.
|
||||
*/
|
||||
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
|
||||
const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx");
|
||||
const HEADER_PATH = join(
|
||||
process.cwd(),
|
||||
"src/components/dashboard/dashboard-header.tsx",
|
||||
|
||||
@@ -23,7 +23,7 @@ import { join } from "node:path";
|
||||
* rather than silently regressing the streaming-composition contract.
|
||||
*/
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const PAGE_PATH = join(ROOT, "src/app/page.tsx");
|
||||
const PAGE_PATH = join(ROOT, "src/app/page-client.tsx");
|
||||
|
||||
function load(path: string): string {
|
||||
return readFileSync(path, "utf8");
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
resolveChartRowPlaceholderCount,
|
||||
resolveConfiguredTileCount,
|
||||
resolveDashboardFirstPaintGate,
|
||||
} from "../page";
|
||||
} from "../page-client";
|
||||
import {
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
type DashboardLayout,
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
* snapshot fetch. The pure `resolveDashboardFirstPaintGate` carries the
|
||||
* behaviour; textual pins keep the wiring honest.
|
||||
*/
|
||||
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
|
||||
const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx");
|
||||
|
||||
describe("resolveDashboardFirstPaintGate", () => {
|
||||
const base = {
|
||||
|
||||
@@ -28,7 +28,7 @@ import { join } from "node:path";
|
||||
*/
|
||||
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const DASHBOARD_PATH = join(ROOT, "src/app/page.tsx");
|
||||
const DASHBOARD_PATH = join(ROOT, "src/app/page-client.tsx");
|
||||
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page.tsx");
|
||||
const INSIGHTS_LAYOUT_PATH = join(ROOT, "src/app/insights/layout.tsx");
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ describe("RootLoading — single skeleton source (wiring pins)", () => {
|
||||
join(process.cwd(), "src/app/loading.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const pageSrc = readFileSync(join(process.cwd(), "src/app/page.tsx"), "utf8");
|
||||
const pageSrc = readFileSync(join(process.cwd(), "src/app/page-client.tsx"), "utf8");
|
||||
|
||||
it("imports the same silhouette components the page's loading phase uses", () => {
|
||||
expect(loadingSrc).toContain(
|
||||
|
||||
@@ -29,34 +29,14 @@
|
||||
* read-only from `User.insightsCachedText`. The nightly
|
||||
* `insight-pregenerate` cron keeps that cache warm.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
|
||||
import {
|
||||
buildDashboardSnapshot,
|
||||
type DashboardSnapshot,
|
||||
type SnapshotUserInput,
|
||||
} from "@/lib/dashboard/snapshot";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Per-key TTL for the snapshot cache entry. Strictly greater than the
|
||||
* client's 120 s refetch interval so a scheduled poll lands on a warm
|
||||
* entry instead of a guaranteed miss that re-runs the full builder. The
|
||||
* 60 s headroom absorbs interval jitter / a poll firing a touch late.
|
||||
* The analytics bucket's 60 s default still governs the slim / thick /
|
||||
* mood cells; only this key is lengthened. Eviction on writes is
|
||||
* unchanged (the `${userId}|` prefix sweep / point-delete both ignore
|
||||
* the TTL).
|
||||
*/
|
||||
const SNAPSHOT_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dashboard.snapshot" } });
|
||||
@@ -72,45 +52,10 @@ export const GET = apiHandler(async () => {
|
||||
return result;
|
||||
};
|
||||
|
||||
// `requireAuth().user` is the full Prisma `User` row, so every field
|
||||
// the builder needs is already present — no extra round-trip.
|
||||
const snapshotUser: SnapshotUserInput = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
timezone: user.timezone,
|
||||
heightCm: user.heightCm,
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
glucoseUnit: user.glucoseUnit,
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
disableCoach: user.disableCoach,
|
||||
insightsCachedText: user.insightsCachedText,
|
||||
insightsCachedAt: user.insightsCachedAt,
|
||||
dashboardWidgetsJson: user.dashboardWidgetsJson,
|
||||
};
|
||||
|
||||
// v1.21.2 (A4) — the briefing recall + forward-look is locale-specific prose,
|
||||
// so the snapshot cache key carries the resolved locale: an EN and a DE
|
||||
// session never share a snapshot cell (and never see each other's memory
|
||||
// wording). The `${user.id}|` prefix still covers the key under the
|
||||
// measurement-write stale-sweep.
|
||||
const locale = await resolveServerLocale({ userLocale: user.locale });
|
||||
|
||||
// Stale-while-revalidate: a measurement write marks the analytics
|
||||
// bucket stale rather than hard-evicting the snapshot, so a busy iOS
|
||||
// sync serves the prior snapshot immediately (within the bucket's
|
||||
// stale window) while a single background recompute warms a fresh one
|
||||
// — the foreground request never pays the cold rebuild. Hard-evicting
|
||||
// writes (widget reorder) still drop the key outright, forcing a clean
|
||||
// miss + synchronous rebuild here.
|
||||
const body = await cachedSwr(
|
||||
caches.analytics as ServerCache<DashboardSnapshot>,
|
||||
`${user.id}|dashboard-snapshot|${locale}`,
|
||||
() => buildDashboardSnapshot(prisma, snapshotUser, { time, locale }),
|
||||
annotate,
|
||||
SNAPSHOT_CACHE_TTL_MS,
|
||||
);
|
||||
// The cached read (user-row mapping, locale resolution, SWR cell) is
|
||||
// shared with the dashboard RSC prefetch — see
|
||||
// `src/lib/dashboard/snapshot-read.ts` for the cache-key/TTL contract.
|
||||
const { body } = await readDashboardSnapshotCached(user, time);
|
||||
|
||||
// Only surface timings on the cache-miss path (the hit path skips the
|
||||
// whole builder and leaves `timings` empty).
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+67
-1716
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Shared cached-snapshot read for the two dashboard entry points:
|
||||
*
|
||||
* - `GET /api/dashboard/snapshot` (the client cell's endpoint), and
|
||||
* - the dashboard RSC wrapper (`src/app/page.tsx`), which server-
|
||||
* prefetches the same payload into a dehydrated TanStack cache so the
|
||||
* first HTML paints real tiles instead of skeletons-until-JS.
|
||||
*
|
||||
* Both ride the SAME `caches.analytics` SWR cell (key
|
||||
* `${userId}|dashboard-snapshot|${locale}`), so an RSC prefetch warms the
|
||||
* API path and vice versa — the builder never runs twice for one user
|
||||
* within the TTL, and the write-invalidation semantics (stale-sweep on
|
||||
* measurement writes, hard evict on widget reorder) cover both readers.
|
||||
*/
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
|
||||
import {
|
||||
buildDashboardSnapshot,
|
||||
type DashboardSnapshot,
|
||||
type SnapshotUserInput,
|
||||
} from "@/lib/dashboard/snapshot";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
/**
|
||||
* Per-key TTL for the snapshot cache entry. Strictly greater than the
|
||||
* client's 120 s refetch interval so a scheduled poll lands on a warm
|
||||
* entry instead of a guaranteed miss that re-runs the full builder. The
|
||||
* 60 s headroom absorbs interval jitter / a poll firing a touch late.
|
||||
* The analytics bucket's 60 s default still governs the slim / thick /
|
||||
* mood cells; only this key is lengthened. Eviction on writes is
|
||||
* unchanged (the `${userId}|` prefix sweep / point-delete both ignore
|
||||
* the TTL).
|
||||
*/
|
||||
export const SNAPSHOT_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000;
|
||||
|
||||
export interface SnapshotReadResult {
|
||||
body: DashboardSnapshot;
|
||||
/** The locale the snapshot was resolved (and cache-keyed) under. */
|
||||
locale: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve locale + read the snapshot through the SWR cache for an already
|
||||
* authenticated user row. `time` lets the API route keep its per-sub-query
|
||||
* timing surface; the RSC prefetch passes none.
|
||||
*/
|
||||
export async function readDashboardSnapshotCached(
|
||||
user: User,
|
||||
time?: <T>(label: string, builder: () => Promise<T>) => Promise<T>,
|
||||
): Promise<SnapshotReadResult> {
|
||||
// `User` row is already in hand at both call sites — no extra round-trip.
|
||||
const snapshotUser: SnapshotUserInput = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
timezone: user.timezone,
|
||||
heightCm: user.heightCm,
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
glucoseUnit: user.glucoseUnit,
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
disableCoach: user.disableCoach,
|
||||
insightsCachedText: user.insightsCachedText,
|
||||
insightsCachedAt: user.insightsCachedAt,
|
||||
dashboardWidgetsJson: user.dashboardWidgetsJson,
|
||||
};
|
||||
|
||||
// v1.21.2 (A4) — the briefing recall + forward-look is locale-specific
|
||||
// prose, so the snapshot cache key carries the resolved locale: an EN and
|
||||
// a DE session never share a snapshot cell (and never see each other's
|
||||
// memory wording). The `${user.id}|` prefix still covers the key under
|
||||
// the measurement-write stale-sweep.
|
||||
const locale = await resolveServerLocale({ userLocale: user.locale });
|
||||
|
||||
// Stale-while-revalidate: a measurement write marks the analytics
|
||||
// bucket stale rather than hard-evicting the snapshot, so a busy iOS
|
||||
// sync serves the prior snapshot immediately (within the bucket's
|
||||
// stale window) while a single background recompute warms a fresh one
|
||||
// — the foreground request never pays the cold rebuild. Hard-evicting
|
||||
// writes (widget reorder) still drop the key outright, forcing a clean
|
||||
// miss + synchronous rebuild here.
|
||||
const body = await cachedSwr(
|
||||
caches.analytics as ServerCache<DashboardSnapshot>,
|
||||
`${user.id}|dashboard-snapshot|${locale}`,
|
||||
() => buildDashboardSnapshot(prisma, snapshotUser, { time, locale }),
|
||||
annotate,
|
||||
SNAPSHOT_CACHE_TTL_MS,
|
||||
);
|
||||
|
||||
return { body, locale };
|
||||
}
|
||||
Reference in new issue
Block a user