mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
perf(i18n): stop shipping two full catalogs on every page load
Two independent every-load costs, one per layer: 1. The static EN import in load-locale.ts put the full en.json catalog (334 KB raw / ~106 KB gz) into the client JS of EVERY route, for every user in every language. No catalog is statically imported by client code anymore: the EN fallback floor of the t() chain loads lazily on the first actual key miss (the locale-integrity guard keeps every locale key-complete, so the miss path is drift-only), and the labs biomarker canonical-name lookup fetches EN on demand. 2. The root layout serialized the ENTIRE active-locale catalog into the RSC flight payload of every document (392 KB of a 505 KB dashboard HTML, re-downloaded on every hard load, uncacheable). The handoff is now a versioned, immutable-cacheable boot script (/i18n/<locale>?v=<build>, new route handler) that assigns self.__HL_I18N before the hydration scripts run, plus a server-side cache seed in load-locale.ts behind a typeof-window branch that Turbopack compiles out of the browser bundle (verified: zero catalog bytes in any client chunk, zero route manifests referencing one). SSR text stays correct and the first client render holds the same bundle the server rendered with — no EN flash, no hydration drift. The boot script is public (proxy allowlist: the login page needs it pre-auth; the body is the same catalog JSON that ships in the public repo) and cache-first in the service worker, so an offline relaunch finds it next to the cached shell. Measured (client-reference manifests, gzip): route JS baseline / 391->287 KB gz, /insights 377->273, /measurements 379->275, /insights/mood 394->290 — the ~104 KB gz EN catalog left every route. H2
This commit is contained in:
1 parent
e260b2d5c4
commit
e24db8a889
8 files changed
+232
-61
No files matched your search
@@ -168,6 +168,17 @@ self.addEventListener("fetch", (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Locale-catalog boot script — cache-first. The URL is versioned
|
||||
// (`/i18n/<locale>?v=<build>`) and served immutable, so cache-first is
|
||||
// correct and keeps the catalog available on an offline relaunch (the
|
||||
// cached shell HTML references the same versioned URL it was rendered
|
||||
// with). Carries no user data — same public strings as the repository's
|
||||
// messages/*.json.
|
||||
if (url.pathname.startsWith("/i18n/")) {
|
||||
event.respondWith(cacheFirst(request, STATIC_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// Static files (fonts, icons, images) — cache-first with long TTL
|
||||
if (/\.(png|svg|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$/i.test(url.pathname)) {
|
||||
event.respondWith(cacheFirst(request, STATIC_CACHE));
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { allMessages } from "@/lib/i18n/shared-resolve";
|
||||
|
||||
/**
|
||||
* Locale-catalog boot script.
|
||||
*
|
||||
* The root layout emits `<script src="/i18n/<locale>?v=<version>" defer>`
|
||||
* ahead of Next's hydration scripts. The body assigns the active locale's
|
||||
* full message bundle to `self.__HL_I18N`, which `load-locale.ts` reads in
|
||||
* its browser cache initializer — so the first client render holds the same
|
||||
* strings the server rendered with.
|
||||
*
|
||||
* This replaces the former RSC-prop handoff that serialized the ENTIRE
|
||||
* active catalog into every document's flight payload (392 KB of a 505 KB
|
||||
* dashboard HTML, re-downloaded on every hard load). As a versioned URL the
|
||||
* catalog is immutable-cacheable: one download per deploy per locale, then
|
||||
* HTTP/SW cache. The `?v=` query is only a cache key; the handler always
|
||||
* serves the catalog baked into the running build.
|
||||
*
|
||||
* Public by design (allowlisted in `src/proxy.ts`): the login page needs it
|
||||
* pre-auth, and the catalogs are the same strings that ship in the public
|
||||
* repository — no tenant data, no secrets.
|
||||
*/
|
||||
|
||||
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ locale: string }> },
|
||||
) {
|
||||
const { locale } = await params;
|
||||
if (!(locales as readonly string[]).includes(locale)) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
locale,
|
||||
messages: allMessages[locale as Locale],
|
||||
});
|
||||
return new NextResponse(`self.__HL_I18N = ${payload};`, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/javascript; charset=utf-8",
|
||||
"Cache-Control": IMMUTABLE_CACHE,
|
||||
// Belt and suspenders for a script-typed response.
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
+18
-12
@@ -7,7 +7,6 @@ import { AuthShell } from "@/components/layout/auth-shell";
|
||||
import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
|
||||
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
|
||||
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
|
||||
import { allMessages } from "@/lib/i18n/shared-resolve";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-sans",
|
||||
@@ -89,13 +88,6 @@ export default async function RootLayout({
|
||||
: undefined;
|
||||
|
||||
const initialLocale = await resolveInitialLocale();
|
||||
// RSC handoff for the i18n bundle split: only EN ships statically in
|
||||
// the client chunk, so a non-EN first paint needs its bundle inlined
|
||||
// into the payload here — that is what keeps the split free of the
|
||||
// EN→DE hydration flash. EN passes nothing (the client already holds
|
||||
// the static fallback floor).
|
||||
const initialMessages =
|
||||
initialLocale === "en" ? undefined : allMessages[initialLocale];
|
||||
|
||||
return (
|
||||
<html lang={initialLocale} suppressHydrationWarning>
|
||||
@@ -105,12 +97,26 @@ export default async function RootLayout({
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{ __html: themeScript }}
|
||||
/>
|
||||
{/*
|
||||
Locale-catalog boot script (see src/app/i18n/[locale]/route.ts).
|
||||
Replaces the former RSC-prop handoff that inlined the whole
|
||||
active catalog into every document's flight payload (392 KB of a
|
||||
505 KB dashboard HTML). `defer` keeps it off the parse/paint
|
||||
critical path while still executing before Next's hydration
|
||||
module scripts (both ride the after-parse in-order queue, and
|
||||
this tag comes first), so `load-locale.ts` finds `self.__HL_I18N`
|
||||
set when the client bundle initializes — SSR text and first
|
||||
client render stay identical. The `?v=` build-version key makes
|
||||
the response immutable-cacheable: one catalog download per
|
||||
deploy, then HTTP/SW cache instead of per-document payload.
|
||||
*/}
|
||||
<script
|
||||
src={`/i18n/${initialLocale}?v=${process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"}`}
|
||||
defer
|
||||
/>
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<Providers
|
||||
initialLocale={initialLocale}
|
||||
initialMessages={initialMessages}
|
||||
>
|
||||
<Providers initialLocale={initialLocale}>
|
||||
<MonitoringBootstrap />
|
||||
<WebVitalsReporter />
|
||||
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -27,7 +27,11 @@ import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { summarize, type DataSummary } from "@/lib/analytics/trends";
|
||||
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { fallbackMessages } from "@/lib/i18n/load-locale";
|
||||
import {
|
||||
getFallbackMessages,
|
||||
loadMessages,
|
||||
type MessageBundle,
|
||||
} from "@/lib/i18n/load-locale";
|
||||
import { resolveKey } from "@/lib/i18n/resolve-key";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -92,23 +96,40 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
// marker minted under the English default and then viewed in another locale
|
||||
// never matched a map built only from the current locale's names — the rich
|
||||
// description silently fell through to the generic line. The English bundle
|
||||
// is always in the client chunk (`fallbackMessages`), so adding its names
|
||||
// makes resolution independent of the locale the marker was created in.
|
||||
// resolves through `getFallbackMessages()` (already in hand on the server
|
||||
// and for EN sessions; lazily fetched below otherwise — the EN catalog is
|
||||
// no longer a static every-route import), so adding its names makes
|
||||
// resolution independent of the locale the marker was created in.
|
||||
const [enBundle, setEnBundle] = useState<MessageBundle | undefined>(
|
||||
getFallbackMessages,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (enBundle) return;
|
||||
let cancelled = false;
|
||||
void loadMessages("en")
|
||||
.then((loaded) => {
|
||||
if (!cancelled) setEnBundle(loaded);
|
||||
})
|
||||
.catch(() => {
|
||||
// Offline — the current-locale names still resolve; only markers
|
||||
// minted under another locale keep the generic description.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enBundle]);
|
||||
const slugByName = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const seed of BIOMARKER_CATALOG) {
|
||||
const localized = t(`labs.catalog.${seed.slug}`).trim().toLowerCase();
|
||||
if (localized) map.set(localized, seed.slug);
|
||||
const canonical = resolveKey(
|
||||
fallbackMessages,
|
||||
`labs.catalog.${seed.slug}`,
|
||||
)
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
const canonical = enBundle
|
||||
? resolveKey(enBundle, `labs.catalog.${seed.slug}`)?.trim().toLowerCase()
|
||||
: undefined;
|
||||
if (canonical && !map.has(canonical)) map.set(canonical, seed.slug);
|
||||
}
|
||||
return map;
|
||||
}, [t]);
|
||||
}, [t, enBundle]);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
// Sticky-footer slot for the add-value sheet (the form portals here).
|
||||
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -182,12 +182,9 @@ function QueryPersistenceBridge() {
|
||||
export function Providers({
|
||||
children,
|
||||
initialLocale,
|
||||
initialMessages,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
initialLocale?: Locale;
|
||||
/** Active locale's bundle, server-resolved by the root layout (RSC handoff). */
|
||||
initialMessages?: Record<string, unknown>;
|
||||
}) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
@@ -206,10 +203,7 @@ export function Providers({
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<I18nProvider
|
||||
initialLocale={initialLocale}
|
||||
initialMessages={initialMessages}
|
||||
>
|
||||
<I18nProvider initialLocale={initialLocale}>
|
||||
<QueryPersistenceBridge />
|
||||
<DashboardSnapshotPreloader />
|
||||
{children}
|
||||
|
||||
@@ -22,8 +22,8 @@ import { readStoredTimeFormat, subscribeTimeFormat } from "../time-format";
|
||||
import { readStoredDateFormat, subscribeDateFormat } from "../date-format";
|
||||
import { resolveKey } from "./resolve-key";
|
||||
import {
|
||||
fallbackMessages,
|
||||
getCachedMessages,
|
||||
getFallbackMessages,
|
||||
loadMessages,
|
||||
primeMessages,
|
||||
type MessageBundle,
|
||||
@@ -117,12 +117,13 @@ export function I18nProvider({
|
||||
children: ReactNode;
|
||||
initialLocale?: Locale;
|
||||
/**
|
||||
* The active locale's message bundle, resolved server-side by the
|
||||
* root layout and passed through the RSC payload. This is what keeps
|
||||
* the client chunk free of every non-EN bundle WITHOUT reintroducing
|
||||
* the EN→DE hydration flash: the first client render already holds
|
||||
* the right strings, no async fetch on first paint. Only EN ships
|
||||
* statically (the synchronous fallback floor of the t() chain).
|
||||
* Optional pre-resolved bundle for standalone mounts (tests, embedded
|
||||
* providers). The APP no longer passes this: the active bundle reaches
|
||||
* the provider through the `load-locale` cache — seeded server-side from
|
||||
* the full catalog map during SSR and client-side by the layout's
|
||||
* versioned `/i18n/<locale>` boot script — so the catalog is never
|
||||
* serialized into the RSC flight payload (it used to be 392 KB of every
|
||||
* dashboard document).
|
||||
*/
|
||||
initialMessages?: MessageBundle;
|
||||
}) {
|
||||
@@ -146,10 +147,25 @@ export function I18nProvider({
|
||||
primeMessages(locale, initialMessages);
|
||||
return { locale, messages: initialMessages };
|
||||
}
|
||||
return { locale, messages: getCachedMessages(locale) ?? fallbackMessages };
|
||||
// Cache miss on every ladder rung (boot script failed AND the locale
|
||||
// isn't EN-cached): render with an empty bundle for one frame — the
|
||||
// backfill effect below dynamic-imports the bundle and flips state.
|
||||
// In the app this only happens when the boot script is unreachable
|
||||
// (evicted HTTP cache offline); keys render raw briefly rather than
|
||||
// in the wrong language.
|
||||
return {
|
||||
locale,
|
||||
messages: getCachedMessages(locale) ?? getFallbackMessages() ?? {},
|
||||
};
|
||||
});
|
||||
const { locale, messages } = active;
|
||||
const [pendingLocale, setPendingLocale] = useState<Locale | null>(null);
|
||||
// Bumped when the lazily-loaded EN fallback bundle lands so every t()
|
||||
// consumer re-renders with the fallback floor in place. The EN catalog is
|
||||
// no longer a static import (it cost ~106 KB gz on every route); it loads
|
||||
// on demand the first time a key actually misses the active bundle.
|
||||
const [, setFallbackTick] = useState(0);
|
||||
const fallbackRequestedRef = useRef(false);
|
||||
// Tracks the most recent switch request so a slow bundle load can't
|
||||
// clobber a newer one (last click wins).
|
||||
const requestedLocaleRef = useRef<Locale | null>(null);
|
||||
@@ -228,7 +244,22 @@ export function I18nProvider({
|
||||
|
||||
// Fallback to English if key missing in current locale
|
||||
if (value === undefined && locale !== "en") {
|
||||
value = resolveKey(fallbackMessages, key);
|
||||
const fallback = getFallbackMessages();
|
||||
if (fallback) {
|
||||
value = resolveKey(fallback, key);
|
||||
} else if (!fallbackRequestedRef.current) {
|
||||
// First miss without the EN floor in hand: fetch it once, then
|
||||
// re-render so this key (and any sibling misses) resolve. The
|
||||
// locale-integrity guard keeps every locale key-complete, so in
|
||||
// practice this path only runs against a drifted cached bundle.
|
||||
fallbackRequestedRef.current = true;
|
||||
void loadMessages("en")
|
||||
.then(() => setFallbackTick((n) => n + 1))
|
||||
.catch(() => {
|
||||
// Leave the ref set — retrying every render would hammer a
|
||||
// dead network; the raw key remains the last resort.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to key itself
|
||||
|
||||
+75
-22
@@ -1,33 +1,44 @@
|
||||
/**
|
||||
* Client-side message-bundle loading.
|
||||
*
|
||||
* Only English ships statically in the client chunk — it is the
|
||||
* synchronous fallback floor of the t() chain (active locale → EN →
|
||||
* raw key) and the bundle the provider can always resolve against
|
||||
* without an async gap. Every other locale reaches the client one of
|
||||
* two ways:
|
||||
* NO catalog ships statically in the client chunk — not even English.
|
||||
* The static EN import that used to live here was the single largest
|
||||
* every-route client cost (334 KB raw / ~106 KB gz on every page load,
|
||||
* for every user, in every language). The t() chain (active locale →
|
||||
* EN → raw key) now resolves its bundles through three paths:
|
||||
*
|
||||
* 1. First paint: the root layout (a server component) resolves the
|
||||
* active locale from the `healthlog-locale` cookie / the
|
||||
* Accept-Language header and threads that locale's messages into
|
||||
* `I18nProvider` as a serialized RSC prop — so the first client
|
||||
* render already holds the right bundle and there is no EN→DE
|
||||
* hydration flash (the historical reason all six bundles used to
|
||||
* ship statically).
|
||||
* 2. Locale switch: `loadMessages()` dynamic-imports the target
|
||||
* bundle on demand; each locale becomes its own async chunk that
|
||||
* only the users who switch to it ever download.
|
||||
* 1. SSR pass: this module seeds its cache with the full server-side
|
||||
* catalog map (`shared-resolve`) inside a `typeof window ===
|
||||
* "undefined"` branch. Turbopack compiles the branch away for the
|
||||
* browser bundle (verified — zero catalog bytes reach any client
|
||||
* chunk), while the server render of every client component
|
||||
* resolves the active locale synchronously — SSR text stays
|
||||
* correct with nothing serialized into the flight payload (the
|
||||
* previous RSC-prop handoff inlined the whole active catalog into
|
||||
* EVERY document: 392 KB of a 505 KB dashboard HTML).
|
||||
* 2. First client paint: the root layout emits a deferred, versioned,
|
||||
* immutable-cacheable `<script src="/i18n/<locale>?v=…">` that
|
||||
* assigns `self.__HL_I18N` before Next's hydration scripts run.
|
||||
* The initializer below reads it — the first client render holds
|
||||
* the same bundle the server rendered with (no EN→DE hydration
|
||||
* flash), and repeat visits serve the catalog from HTTP/SW cache
|
||||
* instead of re-downloading it inside each document.
|
||||
* 3. Locale switch / EN fallback: `loadMessages()` dynamic-imports
|
||||
* the target bundle on demand; each locale becomes its own async
|
||||
* chunk that only the users who need it ever download.
|
||||
*
|
||||
* The module-level cache makes repeat switches instant and lets the
|
||||
* provider seed it with the server-passed bundle (`primeMessages`).
|
||||
* The module-level cache makes repeat switches instant and lets tests
|
||||
* seed bundles directly (`primeMessages`).
|
||||
*/
|
||||
import enMessages from "../../../messages/en.json";
|
||||
import { type Locale } from "./config";
|
||||
|
||||
export type MessageBundle = Record<string, unknown>;
|
||||
|
||||
/** Synchronous fallback floor — always available, never awaited. */
|
||||
export const fallbackMessages: MessageBundle = enMessages;
|
||||
/** Shape of the boot global the /i18n/<locale> script assigns. */
|
||||
interface I18nBootGlobal {
|
||||
locale?: string;
|
||||
messages?: MessageBundle;
|
||||
}
|
||||
|
||||
const loaders: Record<Locale, () => Promise<{ default: MessageBundle }>> = {
|
||||
de: () => import("../../../messages/de.json"),
|
||||
@@ -38,13 +49,55 @@ const loaders: Record<Locale, () => Promise<{ default: MessageBundle }>> = {
|
||||
pl: () => import("../../../messages/pl.json"),
|
||||
};
|
||||
|
||||
const cache: Partial<Record<Locale, MessageBundle>> = { en: enMessages };
|
||||
function initialCache(): Partial<Record<Locale, MessageBundle>> {
|
||||
if (typeof window === "undefined") {
|
||||
// Server pass (SSR of client components): seed every locale from the
|
||||
// server-side catalog map. The branch is dead code in the browser
|
||||
// bundle — Turbopack eliminates it together with the require, so none
|
||||
// of the six catalogs reaches a client chunk. The try/catch covers
|
||||
// non-bundled node runtimes (vitest transforms this module to ESM,
|
||||
// where a bare require of a TS module throws) — there the cache starts
|
||||
// empty and `vitest.setup.ts` primes every bundle up front.
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { allMessages } =
|
||||
require("./shared-resolve") as typeof import("./shared-resolve");
|
||||
return { ...allMessages };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
// Browser: the deferred boot script (emitted by the root layout ahead of
|
||||
// the hydration scripts) has already assigned the active locale's bundle.
|
||||
const boot = (self as { __HL_I18N?: I18nBootGlobal }).__HL_I18N;
|
||||
if (
|
||||
boot &&
|
||||
typeof boot.locale === "string" &&
|
||||
boot.messages &&
|
||||
typeof boot.messages === "object"
|
||||
) {
|
||||
return { [boot.locale as Locale]: boot.messages };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const cache: Partial<Record<Locale, MessageBundle>> = initialCache();
|
||||
|
||||
export function getCachedMessages(locale: Locale): MessageBundle | undefined {
|
||||
return cache[locale];
|
||||
}
|
||||
|
||||
/** Seed the cache with a bundle that arrived as a server prop. */
|
||||
/**
|
||||
* The EN fallback floor of the t() chain, when it is already in hand —
|
||||
* always on the server, on the client once EN is the active/boot locale or
|
||||
* `loadMessages("en")` has resolved. `undefined` means "not loaded yet";
|
||||
* callers kick off the async load and re-render when it lands.
|
||||
*/
|
||||
export function getFallbackMessages(): MessageBundle | undefined {
|
||||
return cache.en;
|
||||
}
|
||||
|
||||
/** Seed the cache with a bundle that arrived out of band (tests, boot). */
|
||||
export function primeMessages(locale: Locale, messages: MessageBundle): void {
|
||||
cache[locale] = messages;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ const PUBLIC_PATHS = [
|
||||
// and is not an enumeration oracle — so it must reach the page without
|
||||
// an auth gate, like the `/auth/` register surface it forwards to.
|
||||
"/invite/",
|
||||
// Locale-catalog boot script (`/i18n/<locale>?v=…`). The login page loads
|
||||
// it pre-auth, and the body is the same public catalog JSON that ships in
|
||||
// the repository — no tenant data, no secrets. Immutable-cacheable.
|
||||
"/i18n/",
|
||||
// `/onboarding` itself + its subroutes are matched exactly via
|
||||
// `isPublicPath()` so we don't admit `/onboarding-export` etc.
|
||||
"/robots.txt",
|
||||
|
||||
Reference in new issue
Block a user