fix(i18n): key the catalog's cache by its content, not the release

The service worker serves the locale catalog's boot script cache-first,
and its URL carried the package version. Every build of one release
therefore shared one URL: an instance updated within a release (a beta,
a redeploy) kept the catalog of its first build, and every message added
since read as its raw key ("timeline.zoom.range", "timeline.selection.week")
or fell back to English.

next.config.ts now hashes the message catalogs at build time and the
layout keys the script by that hash, so a changed catalog is a new URL.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 13:13:26 +02:00
1 parent cd78415084
commit 3f47224825
5 files changed
+63 -3

No files matched your search

+24
View File
@@ -1,8 +1,31 @@
import { createHash } from "node:crypto";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { NextConfig } from "next";
import bundleAnalyzer from "@next/bundle-analyzer";
import { version as PKG_VERSION } from "./package.json";
/**
* The message catalogs' content hash, the cache key of the boot script
* (`/i18n/<locale>.js?v=…`, `src/app/layout.tsx`). The service worker serves
* that URL cache-first, so a key that does not move with the catalog keeps
* an old catalog alive: keyed by the package version, every build of one
* release (a beta, a redeploy) shared one URL, and a key added since the
* first of them read as its raw name.
*/
function catalogVersion(): string {
const dir = join(process.cwd(), "messages");
const hash = createHash("sha256");
for (const file of readdirSync(dir)
.filter((f) => f.endsWith(".json"))
.sort()) {
hash.update(file).update(readFileSync(join(dir, file)));
}
return hash.digest("hex").slice(0, 16);
}
const nextConfig: NextConfig = {
output: "standalone",
// `next build` type-checks the whole tsconfig program since 16.3, which
@@ -20,6 +43,7 @@ const nextConfig: NextConfig = {
// had to discover "pull-to-refresh" themselves after every release.
env: {
NEXT_PUBLIC_APP_VERSION: PKG_VERSION,
NEXT_PUBLIC_I18N_CATALOG_VERSION: catalogVersion(),
},
// v1.4.33 IW2 — strip `console.*` calls from the production bundle
// (keep `console.error` + `console.warn` so the GlitchTip reporter
+3 -2
View File
@@ -230,8 +230,9 @@ 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
// Locale-catalog boot script — cache-first. The URL is versioned by the
// catalogs' content hash (`/i18n/<locale>?v=<hash>`, `next.config.ts`),
// so a changed catalog is a new URL and 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
@@ -102,6 +102,8 @@ const NOT_OPERATOR_CONFIGURABLE: Readonly<Record<string, string>> = {
// --- Baked at build time by the Dockerfile's ARG/ENV pairs. ---
NEXT_PUBLIC_APP_VERSION:
"Dockerfile build arg; a runtime value cannot change what is compiled in",
NEXT_PUBLIC_I18N_CATALOG_VERSION:
"computed by next.config.ts from the message catalogs at build time",
NEXT_PUBLIC_APP_BUILD_SHA:
"Dockerfile build arg, stamped from the release commit",
NEXT_PUBLIC_APP_BUILT_AT: "Dockerfile build arg, stamped at image build",
@@ -0,0 +1,31 @@
/**
* The locale catalog's boot script is cached by its URL: the service worker
* serves `/i18n/<locale>.js?v=…` cache-first. Keyed by the package version,
* every build of one release shared one URL, so a beta updated within a
* release kept its first catalog and every key added since read as its raw
* name ("timeline.zoom.range"). The key is the catalogs' content hash now.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const read = (rel: string) => readFileSync(join(process.cwd(), rel), "utf8");
describe("the locale catalog's cache key", () => {
it("moves with the catalogs, not with the release", () => {
const layout = read("src/app/layout.tsx");
const src =
/src=\{`\/i18n\/\$\{initialLocale\}\.js\?v=\$\{([^}]*)\}`\}/.exec(layout);
expect(src?.[1]).toContain("NEXT_PUBLIC_I18N_CATALOG_VERSION");
expect(src?.[1]).not.toContain("NEXT_PUBLIC_APP_VERSION");
});
it("is computed from the message catalogs at build time", () => {
const config = read("next.config.ts");
expect(config).toMatch(
/NEXT_PUBLIC_I18N_CATALOG_VERSION:\s*catalogVersion\(\)/,
);
expect(config).toMatch(/join\(process\.cwd\(\), "messages"\)/);
});
});
+3 -1
View File
@@ -131,7 +131,9 @@ export default async function RootLayout({
id="healthlog-i18n-boot"
strategy="beforeInteractive"
nonce={nonce}
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION || "dev"}`}
// Keyed by the catalogs' content (`next.config.ts`), not the
// release: the service worker serves this URL cache-first.
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_I18N_CATALOG_VERSION || "dev"}`}
/>
</head>
<body className={`${inter.variable} font-sans antialiased`}>