fix(i18n): deliver the locale catalog before the client bundle takes its first render

The root layout shipped the active locale's catalog as `<script defer>` and
counted on it executing ahead of Next's hydration scripts. It does not: Next
emits its own chunks as `async` tags AHEAD of anything the layout renders into
`<head>`, and an `async` script runs the moment its fetch settles. So the
370 KB catalog was racing the app bundle, and on a cold load it lost about a
third of the time — `load-locale` initialised with an empty cache, every `t()`
in the hydration render returned its raw key against the server's real text,
and React tore the tree down with a text hydration mismatch. The user saw a
frame of `nav.skipToContent` before the provider's backfill landed.

Register the catalog through `next/script` at `beforeInteractive` instead.
Next's own bootstrap awaits those scripts before requiring a single app module,
and pairs the registration with a `<link rel=preload>` so the download still
starts with the document rather than after it. The CSP nonce rides along, so
the posture is unchanged.

Fold the boot global into the cache lazily rather than snapshotting it at
module evaluation. The snapshot made correctness depend on an ordering the
module cannot see; reading on demand reduces the invariant to something local
and testable — whatever the boot script assigns is visible to the first `t()`
that asks, whenever it lands.

Measured on the dashboard render spec, 30 repeats per project against a
production build: 11 of 30 failed on desktop before, 0 of 60 after.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 00:46:03 +02:00
1 parent 7b56f9ddbd
commit 59acbed5ab
4 files changed
+224 -20

No files matched your search

@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* Root layout — i18n boot-script ordering pin.
*
* The locale catalog is a ~370 KB separate asset, and the client bundle
* needs it in hand for its FIRST render or `t()` resolves raw keys against
* the server's real text (React error #418, plus a visible frame of
* `nav.skipToContent` before the provider backfills).
*
* A plain `<script defer>` cannot deliver that. Next emits its own chunks
* as `async` tags AHEAD of anything the layout renders into `<head>`, and
* an `async` script executes the moment its fetch settles — so the catalog
* was racing the app bundle and lost about a third of cold loads.
* `next/script` at `beforeInteractive` is the only registration Next
* awaits before requiring an app module, so it is the one that holds.
*
* This is a structural pin: reverting the tag to `<script defer>` reads
* like a harmless simplification and its damage is intermittent.
*/
const LAYOUT_PATH = join(process.cwd(), "src/app/layout.tsx");
describe("root layout — i18n boot script", () => {
const src = readFileSync(LAYOUT_PATH, "utf8");
it("registers the catalog through next/script", () => {
expect(src).toContain('from "next/script"');
const tag = src.slice(src.indexOf("<Script"));
expect(tag).toContain('strategy="beforeInteractive"');
expect(tag).toContain("/i18n/${initialLocale}.js");
});
it("carries the CSP nonce", () => {
const tag = src.slice(
src.indexOf("<Script"),
src.indexOf("/>", src.indexOf("<Script")),
);
expect(tag).toContain("nonce={nonce}");
});
it("never falls back to a bare deferred script tag for the catalog", () => {
const bareTag = /<script\b[^>]*\/i18n\//;
expect(bareTag.test(src)).toBe(false);
});
});
+22 -8
View File
@@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import { headers } from "next/headers";
import Script from "next/script";
import "./globals.css";
import { Providers } from "@/components/providers";
import { AuthShell } from "@/components/layout/auth-shell";
@@ -102,18 +103,31 @@ export default async function RootLayout({
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
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. Served as a static asset: ETag
505 KB dashboard HTML). Served as a static asset: ETag
revalidation (304 on repeat loads) + SW cache-first on the
versioned URL replace the per-document payload.
`beforeInteractive` is load-bearing, not a preference. The
catalog has to be in hand when the client bundle takes its FIRST
render, or `t()` resolves raw keys against an empty bundle while
the server HTML carries real text — React tears the tree down
with hydration error #418 and the user watches a frame of
`nav.skipToContent` before the provider's backfill lands. A plain
`<script defer>` cannot promise that: Next emits its own chunks
as `async` tags AHEAD of this element in `<head>`, and an `async`
script runs the moment its fetch settles, so the 370 KB catalog
was simply racing the app bundle — and lost about a third of the
time on a cold load. `beforeInteractive` hands the URL to Next's
own bootstrap, which awaits it before requiring a single app
module (`next/dist/client/app-bootstrap.js`), and pairs it with a
`<link rel=preload>` so the download still starts with the
document rather than after it.
*/}
<script
<Script
id="healthlog-i18n-boot"
strategy="beforeInteractive"
nonce={nonce}
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"}`}
defer
/>
</head>
<body className={`${inter.variable} font-sans antialiased`}>