perf(i18n): generate the catalog boot scripts as static assets

The dynamic /i18n/[locale] route handler answered with Next's default
Cache-Control (private, max-age=0, must-revalidate) and no validator —
its immutable-cache header was overridden, so a non-PWA browser would
re-download the full catalog on every hard load. Static public/ files
keep an ETag (repeat loads revalidate to a 304) and the service
worker's cache-first rule covers the offline relaunch; the ?v= build
key the layout appends busts both per deploy.

scripts/generate-i18n-boot.mjs writes public/i18n/<locale>.js for all
six locales as a prebuild/predev step (the sw-version.js pattern);
output is gitignored. The route handler is removed.

Measured on a production build, authenticated DE session: dashboard
document 522 KB raw / 139 KB gz -> 170 KB raw / 24.6 KB gz; the DE
catalog moves into a one-time 119 KB gz cached asset.

H2
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 13:15:37 +02:00
1 parent cb9d61a311
commit 9f2a3e25fe
10 files changed
+86 -68

No files matched your search

+1
View File
@@ -54,6 +54,7 @@ next-env.d.ts
# scripts/generate-sw-version.mjs; loaded by public/sw.js to key
# the cache-version baseline.
/public/sw-version.js
/public/i18n/
# v1.4.27 B3 — MaxMind GeoLite2 databases are fetched at build time
# via `scripts/fetch-geolite2.sh`. Too large for git; license key
+4 -1
View File
@@ -113,7 +113,10 @@ export async function mockMoodInsights(page: Page): Promise<void> {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: CORRELATION_DISCOVERY_PAYLOAD, error: null }),
body: JSON.stringify({
data: CORRELATION_DISCOVERY_PAYLOAD,
error: null,
}),
});
});
}
+2 -1
View File
@@ -26,7 +26,8 @@
"scripts": {
"prepare": "lefthook install || echo 'lefthook install skipped (no git repo or binary unavailable)'",
"dev": "next dev",
"prebuild": "node scripts/generate-sw-version.mjs",
"predev": "node scripts/generate-i18n-boot.mjs",
"prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs",
"build": "next build",
"analyze": "ANALYZE=1 next build",
"bundle-report": "node scripts/check-bundle-budget.mjs",
+3 -1
View File
@@ -150,7 +150,9 @@ const budget = existsSync(BUDGET_PATH)
console.log(`shared baseline (rootMainFiles): ${fmt(rootGz)} gz`);
console.log(`all client chunks: ${fmt(totalGz)} gz`);
console.log(`largest chunk: ${fmt(largest.gz)} gz ${largest.file}`);
console.log(
`largest chunk: ${fmt(largest.gz)} gz ${largest.file}`,
);
console.log(`recharts-fingerprint chunks: ${rechartsChunks}`);
console.log("");
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* Generate the locale-catalog boot scripts (`public/i18n/<locale>.js`).
*
* The root layout emits `<script src="/i18n/<locale>.js?v=<version>" defer>`
* ahead of Next's hydration scripts; the body assigns the active locale's
* full message bundle to `self.__HL_I18N`, which `src/lib/i18n/load-locale.ts`
* reads in its browser cache initializer. 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).
*
* Static `public/` files (rather than a route handler) because Next serves
* them with an ETag — repeat loads revalidate to a 304 instead of
* re-downloading the catalog — and the service worker caches the versioned
* URL cache-first for the offline relaunch. The `?v=` query the layout
* appends busts both caches per deploy.
*
* Runs as a `prebuild`/`predev` step (the `sw-version.js` pattern); output
* is gitignored. Content is the same public catalog JSON that ships in the
* repository — no tenant data, no secrets.
*/
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, "..");
const outDir = join(repoRoot, "public", "i18n");
const LOCALES = ["de", "en", "fr", "es", "it", "pl"];
mkdirSync(outDir, { recursive: true });
const banner =
"// AUTO-GENERATED by scripts/generate-i18n-boot.mjs — do not edit.\n";
for (const locale of LOCALES) {
const messages = JSON.parse(
readFileSync(join(repoRoot, "messages", `${locale}.json`), "utf8"),
);
const payload = JSON.stringify({ locale, messages });
writeFileSync(
join(outDir, `${locale}.js`),
`${banner}self.__HL_I18N = ${payload};\n`,
"utf8",
);
}
process.stdout.write(
`generate-i18n-boot: wrote ${LOCALES.length} catalogs to public/i18n/\n`,
);
-51
View File
@@ -1,51 +0,0 @@
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",
},
});
}
+6 -5
View File
@@ -98,7 +98,8 @@ export default async function RootLayout({
dangerouslySetInnerHTML={{ __html: themeScript }}
/>
{/*
Locale-catalog boot script (see src/app/i18n/[locale]/route.ts).
Locale-catalog boot script (generated into public/i18n/ by
scripts/generate-i18n-boot.mjs, a prebuild/predev step).
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
@@ -106,12 +107,12 @@ export default async function RootLayout({
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.
client render stay identical. Served as a static asset: ETag
revalidation (304 on repeat loads) + SW cache-first on the
versioned URL replace the per-document payload.
*/}
<script
src={`/i18n/${initialLocale}?v=${process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"}`}
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"}`}
defer
/>
</head>
+8 -4
View File
@@ -55,9 +55,11 @@ import type {
// loading shell matches the in-card chart footprint so the layout is stable.
const LabBiomarkerChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/chart-runtime")).then((mod) => ({
default: mod.LabBiomarkerChart,
})),
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({
default: mod.LabBiomarkerChart,
}),
),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
function LabBiomarkerChart(
@@ -124,7 +126,9 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const localized = t(`labs.catalog.${seed.slug}`).trim().toLowerCase();
if (localized) map.set(localized, seed.slug);
const canonical = enBundle
? resolveKey(enBundle, `labs.catalog.${seed.slug}`)?.trim().toLowerCase()
? resolveKey(enBundle, `labs.catalog.${seed.slug}`)
?.trim()
.toLowerCase()
: undefined;
if (canonical && !map.has(canonical)) map.set(canonical, seed.slug);
}
@@ -36,9 +36,9 @@ interface MockQueryState {
}
let queryState: MockQueryState;
vi.mock("@tanstack/react-query", async () => {
const actual = await vi.importActual<
typeof import("@tanstack/react-query")
>("@tanstack/react-query");
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query",
);
return {
...actual,
useQuery: () => ({ ...queryState, refetch: vi.fn() }),
@@ -59,7 +59,12 @@ import {
const store = new Map<string, string>();
beforeEach(() => {
store.clear();
queryState = { data: undefined, isError: false, error: null, isLoading: false };
queryState = {
data: undefined,
isError: false,
error: null,
isLoading: false,
};
vi.stubGlobal("localStorage", {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
+1 -1
View File
@@ -59,8 +59,8 @@ function initialCache(): Partial<Record<Locale, MessageBundle>> {
// 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 } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./shared-resolve") as typeof import("./shared-resolve");
return { ...allMessages };
} catch {