mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +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
96 lines
4.0 KiB
TypeScript
96 lines
4.0 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|