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

+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}