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

@@ -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 = {
+1 -1
View File
@@ -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(
+5 -60
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+95
View File
@@ -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 };
}