From 3f47224825567c31ea88da214467ba3ba7d1f30a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 13:13:26 +0200 Subject: [PATCH] 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. --- next.config.ts | 24 ++++++++++++++ public/sw.js | 5 +-- .../compose-env-whitelist-guard.test.ts | 2 ++ src/__tests__/i18n-catalog-cache-key.test.ts | 31 +++++++++++++++++++ src/app/layout.tsx | 4 ++- 5 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/i18n-catalog-cache-key.test.ts diff --git a/next.config.ts b/next.config.ts index 2edfcfba3..672f10a91 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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/.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 diff --git a/public/sw.js b/public/sw.js index ce689494f..603d4a6af 100644 --- a/public/sw.js +++ b/public/sw.js @@ -230,8 +230,9 @@ self.addEventListener("fetch", (event) => { return; } - // Locale-catalog boot script — cache-first. The URL is versioned - // (`/i18n/?v=`) and served immutable, so cache-first is + // Locale-catalog boot script — cache-first. The URL is versioned by the + // catalogs' content hash (`/i18n/?v=`, `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 diff --git a/src/__tests__/compose-env-whitelist-guard.test.ts b/src/__tests__/compose-env-whitelist-guard.test.ts index 79f28af68..027cb5262 100644 --- a/src/__tests__/compose-env-whitelist-guard.test.ts +++ b/src/__tests__/compose-env-whitelist-guard.test.ts @@ -102,6 +102,8 @@ const NOT_OPERATOR_CONFIGURABLE: Readonly> = { // --- 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", diff --git a/src/__tests__/i18n-catalog-cache-key.test.ts b/src/__tests__/i18n-catalog-cache-key.test.ts new file mode 100644 index 000000000..6888e79f6 --- /dev/null +++ b/src/__tests__/i18n-catalog-cache-key.test.ts @@ -0,0 +1,31 @@ +/** + * The locale catalog's boot script is cached by its URL: the service worker + * serves `/i18n/.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"\)/); + }); +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 57e112094..e935efd77 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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"}`} />