mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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
79 lines
3.3 KiB
TypeScript
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>
|
|
);
|
|
}
|