Files
HealthLog/src/app/page.tsx
T
Marc-André Bombeck cb9d61a311 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
2026-07-07 12:51:18 +02:00

79 lines
3.3 KiB
TypeScript

import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { getSession } from "@/lib/auth/session";
import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
import { queryKeys } from "@/lib/query-keys";
import DashboardPageClient from "./page-client";
/**
* Thin RSC wrapper around the (client) dashboard page.
*
* The measured first-load gap on `/` was never the server: FCP painted the
* SSR skeleton shell at ~1.8 s but LCP landed at ~5.5 s (mobile-throttled)
* because the real tile content waited for the full JS download + hydrate +
* a CLIENT-side snapshot fetch — while `/api/dashboard/snapshot` itself
* answers in ~8 ms server-side. This wrapper runs that same cached read
* during SSR and hands it to TanStack through `HydrationBoundary`, so the
* first HTML already carries real tile data and the mounted snapshot cell
* (`staleTime` 60 s, `refetchOnMount: false`) starts warm instead of
* skeleton-first.
*
* Contract notes:
* - Query keys come ONLY from the central factory. The live cell is
* locale-keyed (`dashboardSnapshot(locale)`), and the locale here is the
* same resolution the API route uses (`resolveServerLocale`), which the
* client provider mirrors via the layout's `resolveInitialLocale`.
* - The payload is JSON-round-tripped so the hydrated shape is exactly
* what the client `queryFn` would produce from the wire ((await
* res.json()).data — Dates as ISO strings), never a Date-carrying
* sibling that would poison the cell for later readers.
* - `dashboardWidgets` is seeded alongside, mirroring the client cell's
* own warm-up in `use-dashboard-snapshot.ts` (the overlay-prefs hook
* reads it before the chart cells fire).
* - Fail-soft: no session (proxy is mid-redirect), a builder hiccup, or a
* DB blip renders the page exactly as before this wrapper existed — the
* client cell owns the fetch then.
*/
export default async function DashboardPage() {
// Operator/test escape hatch: `DASHBOARD_SSR_PREFETCH=false` renders the
// pure client-fetch dashboard (pre-prefetch behaviour). The e2e server
// sets it so Playwright route mocks — which only see CLIENT fetches —
// keep governing what the dashboard paints.
if (process.env.DASHBOARD_SSR_PREFETCH === "false") {
return <DashboardPageClient />;
}
let dehydratedState = null;
try {
const session = await getSession();
if (session) {
const { body, locale } = await readDashboardSnapshotCached(session.user);
// Match the client cell's wire shape exactly (JSON semantics, ISO
// date strings) — same-key-different-shape is silent cache poison.
const wireBody = JSON.parse(JSON.stringify(body)) as unknown as Record<
string,
unknown
>;
const queryClient = new QueryClient();
queryClient.setQueryData(queryKeys.dashboardSnapshot(locale), wireBody);
queryClient.setQueryData(queryKeys.dashboardWidgets(), wireBody.layout);
dehydratedState = dehydrate(queryClient);
}
} catch {
// Prefetch is an accelerator, never a gate — the client path stands.
}
if (dehydratedState === null) {
return <DashboardPageClient />;
}
return (
<HydrationBoundary state={dehydratedState}>
<DashboardPageClient />
</HydrationBoundary>
);
}