mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +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
@@ -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