mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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
@@ -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`}>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Boot-catalog adoption — the client half of the i18n handoff.
|
||||
*
|
||||
* The root layout ships the active locale's catalog as a separate script
|
||||
* that assigns `self.__HL_I18N`; `load-locale` folds that global into its
|
||||
* cache so the FIRST client render resolves the same strings the server
|
||||
* rendered. When the fold happens at module-evaluation time it silently
|
||||
* depends on script ordering the module cannot see, and a catalog that
|
||||
* lands a moment late is lost for good — every `t()` in the hydration
|
||||
* render returns its raw key against the server's real text, which React
|
||||
* reports as a text hydration mismatch and the user sees as a frame of
|
||||
* `nav.skipToContent`.
|
||||
*
|
||||
* The invariant these tests pin is local and order-free: whatever the boot
|
||||
* script assigns is visible to the first read, whenever it lands.
|
||||
*
|
||||
* The tests run against a synthetic browser global — the suite's
|
||||
* environment is `node`, where `load-locale` takes its server branch and
|
||||
* seeds every catalog from `shared-resolve` instead.
|
||||
*/
|
||||
|
||||
type BootGlobal = { locale?: string; messages?: Record<string, unknown> };
|
||||
|
||||
const EN_BOOT = { common: { save: "Save" } };
|
||||
|
||||
/**
|
||||
* Load a FRESH `load-locale` with `window` present, so the module takes
|
||||
* its browser branch and starts with an empty cache.
|
||||
*/
|
||||
async function importInBrowser() {
|
||||
vi.resetModules();
|
||||
return import("../load-locale");
|
||||
}
|
||||
|
||||
function setBoot(boot: BootGlobal | undefined) {
|
||||
if (boot === undefined) {
|
||||
delete (globalThis as { __HL_I18N?: BootGlobal }).__HL_I18N;
|
||||
return;
|
||||
}
|
||||
(globalThis as { __HL_I18N?: BootGlobal }).__HL_I18N = boot;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// `load-locale` branches on `window` and reads the global off `self`;
|
||||
// node has neither.
|
||||
(globalThis as { window?: unknown }).window = globalThis;
|
||||
(globalThis as { self?: unknown }).self = globalThis;
|
||||
setBoot(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setBoot(undefined);
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
delete (globalThis as { self?: unknown }).self;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("i18n boot-catalog adoption", () => {
|
||||
it("adopts a catalog assigned AFTER the module was evaluated", async () => {
|
||||
const loadLocale = await importInBrowser();
|
||||
// Nothing yet — the boot script has not run.
|
||||
expect(loadLocale.getCachedMessages("en")).toBeUndefined();
|
||||
|
||||
setBoot({ locale: "en", messages: EN_BOOT });
|
||||
|
||||
// The regression: a module-evaluation snapshot returns undefined here
|
||||
// and the hydration render falls through to raw keys.
|
||||
expect(loadLocale.getCachedMessages("en")).toEqual(EN_BOOT);
|
||||
expect(loadLocale.getFallbackMessages()).toEqual(EN_BOOT);
|
||||
});
|
||||
|
||||
it("adopts a catalog that was already assigned before evaluation", async () => {
|
||||
setBoot({ locale: "en", messages: EN_BOOT });
|
||||
const loadLocale = await importInBrowser();
|
||||
expect(loadLocale.getCachedMessages("en")).toEqual(EN_BOOT);
|
||||
});
|
||||
|
||||
it("serves the boot catalog to loadMessages without a dynamic import", async () => {
|
||||
setBoot({ locale: "en", messages: EN_BOOT });
|
||||
const loadLocale = await importInBrowser();
|
||||
await expect(loadLocale.loadMessages("en")).resolves.toEqual(EN_BOOT);
|
||||
});
|
||||
|
||||
it("never clobbers a bundle already in the cache", async () => {
|
||||
const loadLocale = await importInBrowser();
|
||||
const primed = { common: { save: "Primed" } };
|
||||
loadLocale.primeMessages("en", primed);
|
||||
|
||||
setBoot({ locale: "en", messages: EN_BOOT });
|
||||
|
||||
expect(loadLocale.getCachedMessages("en")).toEqual(primed);
|
||||
});
|
||||
|
||||
it("ignores a malformed boot global and keeps asking", async () => {
|
||||
setBoot({ locale: "en" });
|
||||
const loadLocale = await importInBrowser();
|
||||
expect(loadLocale.getCachedMessages("en")).toBeUndefined();
|
||||
|
||||
// A later, well-formed assignment still counts — the bad read must not
|
||||
// latch the adoption closed.
|
||||
setBoot({ locale: "en", messages: EN_BOOT });
|
||||
expect(loadLocale.getCachedMessages("en")).toEqual(EN_BOOT);
|
||||
});
|
||||
|
||||
it("keys the catalog by the locale the boot script names", async () => {
|
||||
setBoot({ locale: "de", messages: { common: { save: "Speichern" } } });
|
||||
const loadLocale = await importInBrowser();
|
||||
expect(loadLocale.getCachedMessages("de")).toEqual({
|
||||
common: { save: "Speichern" },
|
||||
});
|
||||
expect(loadLocale.getCachedMessages("en")).toBeUndefined();
|
||||
expect(loadLocale.getFallbackMessages()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+39
-12
@@ -16,12 +16,14 @@
|
||||
* 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
|
||||
* 2. First client paint: the root layout registers a versioned,
|
||||
* immutable-cacheable `<script src="/i18n/<locale>?v=…">` through
|
||||
* `next/script` at `beforeInteractive`, so Next's own bootstrap
|
||||
* awaits it before requiring any app module. It assigns
|
||||
* `self.__HL_I18N`, which `adoptBootBundle()` below folds into the
|
||||
* cache on first read — the first client render holds the same
|
||||
* bundle the server rendered with (no raw-key flash, no hydration
|
||||
* mismatch), 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
|
||||
@@ -67,8 +69,32 @@ function initialCache(): Partial<Record<Locale, MessageBundle>> {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
// Browser: the deferred boot script (emitted by the root layout ahead of
|
||||
// the hydration scripts) has already assigned the active locale's bundle.
|
||||
return {};
|
||||
}
|
||||
|
||||
const cache: Partial<Record<Locale, MessageBundle>> = initialCache();
|
||||
|
||||
/**
|
||||
* Whether the boot global has already been folded into the cache. The
|
||||
* adoption is idempotent and one-way: once a bundle is in, a later call
|
||||
* must not re-copy it over a locale switch or a `primeMessages` seed.
|
||||
*/
|
||||
let bootAdopted = typeof window === "undefined";
|
||||
|
||||
/**
|
||||
* Fold `self.__HL_I18N` (assigned by the layout's boot script) into the
|
||||
* cache, at most once.
|
||||
*
|
||||
* Deliberately LAZY rather than a module-evaluation snapshot. The snapshot
|
||||
* form made correctness depend on this module being evaluated after the boot
|
||||
* script had run — an ordering that lives outside this file and, before the
|
||||
* layout moved the tag to `beforeInteractive`, did not actually hold. Reading
|
||||
* the global on demand means a boot script that lands a moment late still
|
||||
* counts, and the invariant reduces to something local and testable: whatever
|
||||
* the boot script assigns is visible to the first `t()` that asks.
|
||||
*/
|
||||
function adoptBootBundle(): void {
|
||||
if (bootAdopted) return;
|
||||
const boot = (self as { __HL_I18N?: I18nBootGlobal }).__HL_I18N;
|
||||
if (
|
||||
boot &&
|
||||
@@ -76,14 +102,13 @@ function initialCache(): Partial<Record<Locale, MessageBundle>> {
|
||||
boot.messages &&
|
||||
typeof boot.messages === "object"
|
||||
) {
|
||||
return { [boot.locale as Locale]: boot.messages };
|
||||
bootAdopted = true;
|
||||
cache[boot.locale as Locale] ??= boot.messages;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const cache: Partial<Record<Locale, MessageBundle>> = initialCache();
|
||||
|
||||
export function getCachedMessages(locale: Locale): MessageBundle | undefined {
|
||||
adoptBootBundle();
|
||||
return cache[locale];
|
||||
}
|
||||
|
||||
@@ -94,6 +119,7 @@ export function getCachedMessages(locale: Locale): MessageBundle | undefined {
|
||||
* callers kick off the async load and re-render when it lands.
|
||||
*/
|
||||
export function getFallbackMessages(): MessageBundle | undefined {
|
||||
adoptBootBundle();
|
||||
return cache.en;
|
||||
}
|
||||
|
||||
@@ -103,6 +129,7 @@ export function primeMessages(locale: Locale, messages: MessageBundle): void {
|
||||
}
|
||||
|
||||
export async function loadMessages(locale: Locale): Promise<MessageBundle> {
|
||||
adoptBootBundle();
|
||||
const cached = cache[locale];
|
||||
if (cached) return cached;
|
||||
const mod = await loaders[locale]();
|
||||
|
||||
Reference in new issue
Block a user