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:
Marc-André Bombeck committed 2026-07-07 12:34:26 +02:00
1 parent e260b2d5c4
commit e24db8a889
8 files changed
+232 -61

No files matched your search

+11
View File
@@ -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));
+51
View File
@@ -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
View File
@@ -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
+32 -11
View File
@@ -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);
+1 -7
View File
@@ -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}
+40 -9
View File
@@ -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
View File
@@ -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;
}
+4
View File
@@ -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",