diff --git a/apps/web/e2e/tests/i18n/lazy-catalogs.spec.ts b/apps/web/e2e/tests/i18n/lazy-catalogs.spec.ts new file mode 100644 index 0000000000..fd3b839d41 --- /dev/null +++ b/apps/web/e2e/tests/i18n/lazy-catalogs.spec.ts @@ -0,0 +1,143 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { test, expect } from "../../fixtures/test-base"; + +/** + * Locale catalogs are fetched on demand, not shipped in the initial download. + * + * `lib/i18n/index.ts` bundles `en` eagerly and glob-imports every other locale + * lazily; `vite.config.ts` groups each one into a single `i18n-` chunk. + * A unit test can prove the module never registered a bundle, but only a real + * browser against a real production build can prove the BYTES never crossed the + * wire — which is the entire point of the change. + * + * This spec exists because the alternative is a change that is indistinguishable + * from a no-op: if a future refactor puts every locale back in the entry chunk, + * every other i18n test still passes. The first assertion below is the one that + * fails. + * + * It runs against the same production bundle the e2e harness always serves, so + * the chunk names are the real ones. + */ + +const APPEARANCE_URL = "/settings/general/appearance"; + +/** + * The locales this spec must account for, read from the same directory listing + * `localeChunkGroups()` in vite.config.ts reads to NAME the chunks. Deriving + * them is the point: a hardcoded `(pt-pt|zh-cn|pseudo)` does not fail the day + * `fr` is added — it just stops covering it, so an eagerly-bundled `fr` sails + * past the "no non-English catalog" assertion below and an `i18n-fr-.js` + * satisfies the "en is present" one. Both checks stay green while the thing + * they check shrinks. + * + * `SUPPORTED_LOCALES` would be the more obvious source and is not reachable + * here: importing `lib/i18n` executes `import.meta.glob` and reads + * `__KANDEV_PSEUDO_LOCALE_BUNDLED__` at module scope, both of which exist only + * under Vite, and Playwright loads this file in plain Node. + * + * A locale directory whose chunk this build did not emit — `pseudo` in a plain + * `vite build` — needs no special case: it only ever widens a "was not + * downloaded" assertion and an exclusion list, and no request can name it. + */ +const NON_EN_LOCALES = fs + .readdirSync(path.resolve(__dirname, "../../../src/locales"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name !== "en") + .map((entry) => entry.name); + +if (NON_EN_LOCALES.length === 0) { + // Both patterns below degrade to "matches nothing" on an empty list, which is + // a green run that checked nothing at all — the exact failure this derivation + // exists to prevent. Refuse to load instead. + throw new Error("no non-en locale directories under src/locales; this spec would assert nothing"); +} + +const LOCALE_ALTERNATION = NON_EN_LOCALES.map((locale) => + locale.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), +).join("|"); + +const LAZY_LOCALE_CHUNK = new RegExp(`/assets/i18n-(?:${LOCALE_ALTERNATION})-[^/]+\\.js$`); +/** The eager chunk: `i18n-.js`, with no locale segment. */ +const EAGER_EN_CHUNK = new RegExp(`/assets/i18n-(?!(?:${LOCALE_ALTERNATION})-)[^/]+\\.js$`); + +test.describe("i18n lazy catalogs", () => { + test("ships only en up front and fetches a locale when it is activated", async ({ testPage }) => { + const scripts: string[] = []; + testPage.on("request", (request) => { + if (request.resourceType() === "script") scripts.push(new URL(request.url()).pathname); + }); + + await testPage.goto(APPEARANCE_URL); + const select = testPage.getByLabel("Display language"); + await expect(select).toBeVisible({ timeout: 10_000 }); + + // 1. An English boot downloads `en` and nothing else. This is the assertion + // that fails the day the catalogs go back into the entry chunk. + expect( + scripts.filter((path) => EAGER_EN_CHUNK.test(path)), + "the eager en catalog chunk must be part of the initial download", + ).not.toEqual([]); + expect( + scripts.filter((path) => LAZY_LOCALE_CHUNK.test(path)), + "no non-English catalog may be downloaded before it is asked for", + ).toEqual([]); + + // 2. Activating a locale fetches exactly that locale's chunk. + scripts.length = 0; + await select.click(); + await testPage.getByRole("listbox").getByRole("option", { name: "简体中文" }).click(); + await expect(testPage.getByLabel("显示语言")).toBeVisible({ timeout: 10_000 }); + + const fetched = scripts.filter((path) => LAZY_LOCALE_CHUNK.test(path)); + expect(fetched, "activating zh-cn must fetch the zh-cn catalog on demand").toHaveLength(1); + expect(fetched[0]).toContain("i18n-zh-cn-"); + }); + + test("a cold boot in a non-English locale renders translated, not key names", async ({ + testPage, + }) => { + // The sequencing case. `main.tsx` awaits the boot locale's catalogs before + // `createRoot().render()`, so the first committed render is already + // translated. If that await were dropped, `returnNull: false` would paint + // `settings:displayLanguage` — visible, wrong, and thrown by nothing. + await testPage.goto(APPEARANCE_URL); + await testPage.evaluate(() => { + document.cookie = "kandev_locale=zh-cn; path=/; max-age=31536000; SameSite=Lax"; + }); + + const scripts: string[] = []; + testPage.on("request", (request) => { + if (request.resourceType() === "script") scripts.push(new URL(request.url()).pathname); + }); + await testPage.reload(); + + await expect(testPage.locator("html")).toHaveAttribute("lang", "zh-cn", { timeout: 10_000 }); + await expect(testPage.getByLabel("显示语言")).toBeVisible({ timeout: 10_000 }); + + expect( + scripts.filter((path) => path.includes("i18n-zh-cn-")), + "a cold zh-cn boot must fetch the zh-cn catalog", + ).not.toEqual([]); + + // Nothing anywhere on the page rendered as a raw `namespace:key`. Scoped to + // the catalog's own namespaces so a URL or a code identifier containing a + // colon does not read as a finding. + const rawKeys = await testPage.evaluate(() => { + const pattern = + /\b(common|settings|sidebar|kanban|task|tasks|chat|workspaces|system|account):[a-z][A-Za-z0-9_]+/g; + return [...new Set((document.body.textContent ?? "").match(pattern) ?? [])]; + }); + expect( + rawKeys, + `Untranslated keys rendered on a cold zh-cn boot: ${rawKeys.join(", ")}`, + ).toEqual([]); + + // No cookie restore here on purpose. `kandev_locale` is browser state and + // nothing else — the switcher persists it with `document.cookie` and writes + // nothing server-side — while the `testPage` fixture opens a fresh + // `browser.newContext()` per test and closes it in teardown. The cookie dies + // with the context whether this test passes or throws, so a restore step + // would only ever be a conditional no-op that reads like a real guarantee. + }); +}); diff --git a/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts b/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts index efe1176910..e7f058df17 100644 --- a/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts +++ b/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts @@ -482,6 +482,12 @@ async function activatePseudo(page: Page, url: string) { document.cookie = "kandev_locale=pseudo; path=/; max-age=31536000; SameSite=Lax"; }); await page.reload(); + // `` proves the SHELL is on the pseudo locale and nothing more: the + // Go shell writes it from the cookie, so it is already correct in the HTML the + // browser parses, before any script has run. Every stronger fact — the route + // rendered, and the catalog reached its copy — is `waitForScreen`'s job, and + // it is per-screen precisely because anything generic enough to assert here is + // something the app shell alone satisfies. await expect(page.locator("html")).toHaveAttribute("lang", "pseudo", { timeout: 15_000 }); } @@ -515,11 +521,13 @@ async function activatePseudo(page: Page, url: string) { * 1. it is VISIBLE — the lazy route mounted and this screen, specifically, is * the one on screen; * 2. its text is ACCENTED — the pseudo catalog has actually been applied to - * copy this route owns. Locale catalogs are eagerly bundled today, but a - * catalog that arrives asynchronously would otherwise let the walk run - * against a route rendered in English fallback and report every string on - * it as a miss. Waiting here is robust to that without weakening anything: - * a wait can only delay the scan, never excuse a finding. + * copy this route owns. This was written as insurance against a catalog + * that arrives asynchronously; it stopped being hypothetical the moment + * lazy catalog loading landed, and `pseudo` is now a fetched chunk like + * every locale but `en`. Without it the walk could run against a route + * rendered in English fallback and report every string on it as a miss. + * Waiting here is robust to that without weakening anything: a wait can + * only delay the scan, never excuse a finding. * * Both are `expect` assertions, not `waitFor`s, so an anchor that never arrives * FAILS this spec instead of quietly scanning whatever the shell had painted. diff --git a/apps/web/lib/i18n/index.ts b/apps/web/lib/i18n/index.ts index be9229bd67..a613062df3 100644 --- a/apps/web/lib/i18n/index.ts +++ b/apps/web/lib/i18n/index.ts @@ -12,9 +12,9 @@ import { writeLocaleCookie } from "./cookie"; * `pseudo` is filtered out of the language switcher in production builds, and * its catalog is not compiled into them at all — see `PSEUDO_LOCALE_BUNDLED`. * - * Catalogs live at `src/locales//.json` and are loaded - * eagerly per locale via Vite's glob import, so a locale switch needs no - * network round-trip. + * Catalogs live at `src/locales//.json`. `en` is bundled into + * the entry chunk; every other locale is a lazily-fetched chunk, loaded before + * the locale is activated (see `loadLocale`). */ export const DEFAULT_LOCALE = "en"; export const DEFAULT_NAMESPACE = "common"; @@ -88,34 +88,67 @@ export function selectableLocales(isProd: boolean): SupportedLocale[] { type CatalogModule = Record; -// Vite resolves these at build time; each entry is `../../src/locales//.json`. -// -// `pseudo` is globbed separately, behind the build-time constant, so a production -// build folds the branch away and rolldown never pulls those JSON modules into -// the graph. Filtering the merged object at runtime instead would still ship -// every byte — and the bytes are the entire problem: pseudo is the LARGEST -// catalog we have (accented characters are multi-byte) and no production user -// can select it. -const catalogModules: Record = { +/** + * `en` is bundled eagerly and lands in the entry chunk. + * + * It is both `DEFAULT_LOCALE` and `fallbackLng`, and `returnNull: false` renders + * a missing key as the key itself — so any instant where `en` is not in memory + * is an instant where the UI paints `common:save` instead of "Save". Keeping it + * eager is also what lets `ensureInitialized` stay synchronous, which the boot + * path depends on: react-i18next suspends on an uninitialized instance and + * there is no Suspense boundary above the React root. + */ +const eagerEnCatalogs = import.meta.glob("../../src/locales/en/*.json", { + eager: true, + import: "default", +}); + +/** + * Every other locale, as loader thunks — one lazily-fetched chunk per locale. + * + * Without `eager` Vite still resolves the file list at BUILD time; only the + * module bodies move out of the initial download. That is what keeps + * `knownNamespaces()` a static, synchronous answer. + * + * `en` is excluded rather than left to overlap the eager glob: a file that is + * both statically and dynamically imported stays in the static chunk anyway, and + * only earns an `INEFFECTIVE_DYNAMIC_IMPORT` warning for saying so twice. + * + * `pseudo` is globbed separately, behind the build-time constant, so a + * production build folds the branch away and rolldown never pulls those JSON + * modules into the graph — no chunk is emitted for it at all. Lazy loading is + * NOT a substitute for that exclusion: an unfetched chunk is still built, + * deployed, and reachable by anyone who can set the cookie. The two changes are + * orthogonal, and this is where they compose. + */ +const lazyCatalogs: Record Promise> = { ...import.meta.glob( - ["../../src/locales/*/*.json", "!../../src/locales/pseudo/*.json"], - { eager: true, import: "default" }, + [ + "../../src/locales/*/*.json", + "!../../src/locales/en/*.json", + "!../../src/locales/pseudo/*.json", + ], + { import: "default" }, ), ...(__KANDEV_PSEUDO_LOCALE_BUNDLED__ - ? import.meta.glob("../../src/locales/pseudo/*.json", { - eager: true, - import: "default", - }) + ? import.meta.glob("../../src/locales/pseudo/*.json", { import: "default" }) : {}), }; -function localeResources(locale: SupportedLocale): Record { +const CATALOG_PATH = /\/locales\/([^/]+)\/([^/]+)\.json$/; + +function parseCatalogPath(path: string): { locale: string; namespace: string } | undefined { + const match = CATALOG_PATH.exec(path); + if (!match) return undefined; + return { locale: match[1], namespace: match[2] }; +} + +/** The bundled `en` catalogs, keyed by namespace. */ +function enResources(): Record { const resources: Record = {}; - for (const [path, messages] of Object.entries(catalogModules)) { - const match = /\/locales\/([^/]+)\/([^/]+)\.json$/.exec(path); - if (!match) continue; - const [, fileLocale, namespace] = match; - if (fileLocale === locale) resources[namespace] = messages; + for (const [path, messages] of Object.entries(eagerEnCatalogs)) { + const parsed = parseCatalogPath(path); + if (parsed) resources[parsed.namespace] = messages; } return resources; } @@ -123,13 +156,82 @@ function localeResources(locale: SupportedLocale): Record /** Namespaces present in the source catalog; used to seed i18next. */ export function knownNamespaces(): string[] { const names = new Set([DEFAULT_NAMESPACE]); - for (const path of Object.keys(catalogModules)) { - const match = /\/locales\/[^/]+\/([^/]+)\.json$/.exec(path); - if (match) names.add(match[1]); + for (const path of [...Object.keys(eagerEnCatalogs), ...Object.keys(lazyCatalogs)]) { + const parsed = parseCatalogPath(path); + if (parsed) names.add(parsed.namespace); } return [...names]; } +/** Locales whose catalogs are registered on the shared instance. */ +const loadedLocales = new Set([DEFAULT_LOCALE]); +/** In-flight loads, so two switches in a row share one fetch. */ +const pendingLoads = new Map>(); + +async function fetchLocale(locale: SupportedLocale): Promise { + const loaders = Object.entries(lazyCatalogs).flatMap(([path, load]) => { + const parsed = parseCatalogPath(path); + return parsed?.locale === locale ? [[parsed.namespace, load] as const] : []; + }); + const bundles = await Promise.all( + loaders.map(async ([namespace, load]) => [namespace, await load()] as const), + ); + for (const [namespace, messages] of bundles) { + i18next.addResourceBundle(locale, namespace, messages, true, true); + } +} + +/** + * Fetch and register `locale`'s catalogs. Idempotent, and concurrent callers + * share one load. `en` is bundled, so it resolves without touching the network. + * + * Rejects if a chunk fails to load; `loadLocaleOrFallback` is the boot-safe + * wrapper every caller in this module actually uses. + */ +export async function loadLocale(locale: SupportedLocale): Promise { + if (loadedLocales.has(locale)) return; + const inflight = pendingLoads.get(locale); + if (inflight) return inflight; + const load = fetchLocale(locale) + .then(() => { + loadedLocales.add(locale); + }) + .finally(() => { + pendingLoads.delete(locale); + }); + pendingLoads.set(locale, load); + return load; +} + +/** + * `loadLocale` that never rejects. + * + * A catalog chunk that 404s or times out must not blank the app or produce an + * unhandled rejection from the switcher's fire-and-forget handler. `en` is + * bundled, so the failure mode is English copy under the requested `` + * — degraded, but readable, and self-healing: `installVitePreloadRecovery` + * reloads once on `vite:preloadError`, and the next boot retries the fetch. + */ +async function loadLocaleOrFallback(locale: SupportedLocale): Promise { + try { + await loadLocale(locale); + } catch (error) { + console.error( + `[i18n] could not load the "${locale}" catalogs; falling back to ${DEFAULT_LOCALE}`, + error, + ); + } +} + +// Safety net for anything that drives the shared instance directly rather than +// through `activateLocale`. It repairs the locale a tick late (English until the +// chunk lands, then a re-render from `addResourceBundle`), which is why it is a +// net and not the mechanism — see `activateLocale`. +i18next.on("languageChanged", (next: string) => { + if (!isSupportedLocale(next)) return; + void loadLocaleOrFallback(normalizeLocale(next)); +}); + let initialized = false; function ensureInitialized(locale: SupportedLocale) { @@ -140,9 +242,11 @@ function ensureInitialized(locale: SupportedLocale) { fallbackLng: DEFAULT_LOCALE, defaultNS: DEFAULT_NAMESPACE, ns: knownNamespaces(), - resources: Object.fromEntries( - SUPPORTED_LOCALES.map((candidate) => [candidate, localeResources(candidate)]), - ), + // `en` only. Inline resources with no backend mean init completes + // synchronously and `hasLoadedNamespace` is always true, so `useTranslation` + // never suspends — the contract init-sync.test.tsx pins. Other locales + // arrive through `addResourceBundle`, which does not change that. + resources: { [DEFAULT_LOCALE]: enResources() }, interpolation: { // React already escapes rendered output; double-escaping mangles copy. escapeValue: false, @@ -155,12 +259,37 @@ function ensureInitialized(locale: SupportedLocale) { } /** - * Activate a locale: switch i18next, reflect it on ``, and persist - * the cookie. Unknown locales coerce to `en`. Returns the locale activated. + * Activate a locale: fetch its catalogs, switch i18next, reflect it on + * ``, and persist the cookie. Unknown locales coerce to `en`. + * Returns the locale activated. + * + * The load happens BEFORE `changeLanguage`, not on the `languageChanged` event. + * `changeLanguage` re-renders the whole tree, so registering the catalog after + * it would paint one frame of English and then flip — the switch is atomic this + * way. The switcher's handler is fire-and-forget, so this never rejects. + * + * Catalogs are fetched, so two rapid selections race: pick `zh-cn` then `pt-pt` + * and the `zh-cn` fetch can settle last, switching the app back and persisting a + * cookie the user never chose. Eager bundling had no such window. Each call + * claims a sequence number and abandons everything after the await if a newer + * one has started — the catalog it already registered stays, which is harmless. + * + * A stale call reports the locale that ACTUALLY won, not the one it was asked + * for. The switcher does `setLocale(await activateLocale(value))`, so returning + * the abandoned locale would leave the dropdown reading "简体中文" over a + * Portuguese app — the same race, surviving in the one place the user looks to + * confirm it did not happen. Both orderings converge on the winner: if the newer + * call already finished, this returns its locale; if it has not, this returns the + * pre-switch locale and the newer call's own `setLocale` lands after. */ +let latestActivation = 0; + export async function activateLocale(locale: string): Promise { const normalized = normalizeLocale(locale); + const activation = ++latestActivation; ensureInitialized(normalized); + await loadLocaleOrFallback(normalized); + if (activation !== latestActivation) return normalizeLocale(i18next.language); if (i18next.language !== normalized) { await i18next.changeLanguage(normalized); } @@ -183,6 +312,27 @@ export function initI18n(locale: SupportedLocale): void { ensureInitialized(locale); } +/** + * Fetch the boot locale's catalogs and make it the active language. + * + * `main.tsx` awaits this before `createRoot().render()`, which is the whole + * first-paint story: the Go shell resolves the locale into the boot payload, so + * exactly ONE locale is fetched, and React does not mount until its messages are + * registered. Nothing ever renders against a half-loaded catalog, so there is no + * flash of English and — the failure `returnNull: false` would otherwise make + * silent — no frame of raw `namespace:key` strings. + * + * For `en` this is a resolved promise: the catalogs are already in the entry + * chunk, so the boot is byte-for-byte what it was before. + */ +export async function preloadLocale(locale: SupportedLocale): Promise { + ensureInitialized(locale); + await loadLocaleOrFallback(locale); + if (i18next.language !== locale) { + await i18next.changeLanguage(locale); + } +} + /** * Initialize i18next synchronously for unit tests. Tests never render the app * shell, so nothing else would set the instance up; react-i18next's @@ -193,6 +343,19 @@ export function initI18nForTests(locale: SupportedLocale = DEFAULT_LOCALE): void ensureInitialized(locale); } +/** + * Load EVERY locale up front, for unit tests only. + * + * Suites drive the shared instance with a bare `i18n.changeLanguage("pseudo")` + * and assert on the next line — a synchronous expectation that lazy catalogs + * would break in ~20 files. `vitest.setup.ts` awaits this once, restoring the + * "all catalogs in memory" world those tests were written against without + * putting it back in the browser bundle. + */ +export async function loadAllLocalesForTests(): Promise { + await Promise.all(SUPPORTED_LOCALES.map((locale) => loadLocale(locale))); +} + /** * Translate outside React (plain helpers, `.ts` modules). Inside components * prefer `useTranslation()` so the tree re-renders on a locale switch. diff --git a/apps/web/lib/i18n/init-sync.test.tsx b/apps/web/lib/i18n/init-sync.test.tsx index 88b16cc74c..83fbfa3ec3 100644 --- a/apps/web/lib/i18n/init-sync.test.tsx +++ b/apps/web/lib/i18n/init-sync.test.tsx @@ -15,8 +15,15 @@ import { describe, expect, it } from "vitest"; * This was raised in review on the grounds that i18next's `initAsync` defaults * to `true`. That option governs when a BACKEND's resource loading is scheduled; * with resources passed inline there is nothing to load. These tests pin the - * behaviour we actually depend on, so a dependency bump or a future switch to - * lazily-loaded catalogs fails here instead of shipping a blank app. + * behaviour we actually depend on, so a dependency bump fails here instead of + * shipping a blank app. + * + * Catalogs ARE lazy now — every locale but `en` is a fetched chunk — and this + * guarantee survives it precisely because that move kept `en` inline and added + * the rest through `addResourceBundle`. `resources` is still populated and there + * is still no backend, which is the shape asserted below. Registering a locale + * later does not make init asynchronous; passing catalogs through an i18next + * BACKEND would, and that is the change these tests exist to stop. * * A fresh instance is used deliberately: `vitest.setup.ts` pre-initializes the * shared one, so asserting against it would be vacuous. diff --git a/apps/web/lib/i18n/lazy-catalogs.test.ts b/apps/web/lib/i18n/lazy-catalogs.test.ts new file mode 100644 index 0000000000..ac01d9c01e --- /dev/null +++ b/apps/web/lib/i18n/lazy-catalogs.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The eager-`en` / lazy-everything-else split. + * + * These assertions cannot be made against the shared instance: `vitest.setup.ts` + * calls `loadAllLocalesForTests()`, so by the time any suite runs every catalog + * is already registered and "is pseudo lazy?" would answer yes vacuously. Each + * test therefore resets the module registry and imports a FRESH `lib/i18n`, + * which brings a fresh `i18next` singleton with nothing loaded. + * + * What is being pinned: + * - `en` is present the instant `init()` returns, with no await anywhere. It + * is `fallbackLng`, and `returnNull: false` renders a missing key as the key + * itself, so an un-awaited boot without `en` paints `settings:displayLanguage` + * on screen rather than throwing. That is the silent failure this split has + * to not introduce. + * - No other locale is in the bundle until something asks for it. + * - `knownNamespaces()` is still a synchronous, complete answer. It is derived + * from `import.meta.glob` keys, and dropping `eager` makes the module bodies + * lazy but NOT the file list — this is the test that says so out loud. + */ + +const SETTINGS_NS = "settings"; +const DISPLAY_LANGUAGE = "displayLanguage"; + +async function freshI18n() { + vi.resetModules(); + const module = await import("./index"); + module.initI18nForTests(); + return module; +} + +beforeEach(() => { + vi.resetModules(); +}); + +describe("catalog loading", () => { + it("has en in memory synchronously and no other locale", async () => { + const { i18n } = await freshI18n(); + + expect(i18n.isInitialized).toBe(true); + expect(i18n.hasResourceBundle("en", SETTINGS_NS)).toBe(true); + expect(i18n.t(`${SETTINGS_NS}:${DISPLAY_LANGUAGE}`)).toBe("Display language"); + + for (const locale of ["pt-pt", "zh-cn", "pseudo"]) { + expect( + i18n.hasResourceBundle(locale, SETTINGS_NS), + `${locale} must not be in the initial bundle`, + ).toBe(false); + } + }); + + it("lists every namespace before a single catalog has been fetched", async () => { + const { knownNamespaces } = await freshI18n(); + + const namespaces = knownNamespaces(); + // Sampled rather than snapshotted: the point is that the list is complete + // and available with no await, not which namespaces exist this week. + expect(namespaces).toContain("common"); + expect(namespaces).toContain(SETTINGS_NS); + expect(namespaces).toContain("kanban"); + expect(namespaces.length).toBeGreaterThan(20); + }); + + it("registers a locale's messages on demand", async () => { + const { i18n, loadLocale } = await freshI18n(); + + await loadLocale("pt-pt"); + + expect(i18n.hasResourceBundle("pt-pt", SETTINGS_NS)).toBe(true); + expect(i18n.getResource("pt-pt", SETTINGS_NS, DISPLAY_LANGUAGE)).toBe("Idioma de apresentação"); + // Loading a catalog must not switch the active language on its own. + expect(i18n.language).toBe("en"); + }); + + it("shares one load between concurrent callers and is idempotent after", async () => { + const { i18n, loadLocale } = await freshI18n(); + const register = vi.spyOn(i18n, "addResourceBundle"); + + // Two switches in a row, before the first has landed. + await Promise.all([loadLocale("zh-cn"), loadLocale("zh-cn")]); + const afterFirstLoad = register.mock.calls.length; + expect(afterFirstLoad).toBeGreaterThan(0); + expect(i18n.hasResourceBundle("zh-cn", SETTINGS_NS)).toBe(true); + + // A third call once it is registered fetches nothing at all. + await loadLocale("zh-cn"); + expect(register.mock.calls.length).toBe(afterFirstLoad); + + register.mockRestore(); + }); + + it("resolves immediately for en, which is bundled", async () => { + const { loadLocale } = await freshI18n(); + await expect(loadLocale("en")).resolves.toBeUndefined(); + }); +}); + +describe("preloadLocale", () => { + it("makes the boot locale active with its real messages", async () => { + const { i18n, preloadLocale } = await freshI18n(); + + await preloadLocale("zh-cn"); + + expect(i18n.language).toBe("zh-cn"); + expect(i18n.t(`${SETTINGS_NS}:${DISPLAY_LANGUAGE}`)).toBe("显示语言"); + }); + + /** + * The regression this whole sequencing exists to prevent: `main.tsx` awaits + * `preloadLocale` before `createRoot().render()`, so the first render must + * never see a key string. Asserting on the resolved state is the closest a + * unit test gets to "look at it" — the visual check is the pseudo-locale. + */ + it("never leaves a key rendering as its own name", async () => { + const { i18n, preloadLocale } = await freshI18n(); + + await preloadLocale("pt-pt"); + + const translated = i18n.t(`${SETTINGS_NS}:${DISPLAY_LANGUAGE}`); + expect(translated).not.toContain(":"); + expect(translated).toBe("Idioma de apresentação"); + }); + + it("falls back to English rather than blocking the boot on a missing namespace", async () => { + const { i18n, preloadLocale } = await freshI18n(); + + // zh-cn ships fewer namespaces than en; the ones it lacks must resolve + // through fallbackLng instead of rendering the key. + await preloadLocale("zh-cn"); + + expect(i18n.language).toBe("zh-cn"); + expect(i18n.t("common:save")).not.toBe("common:save"); + }); +}); + +/** + * Two rapid selections race, because activation now awaits a fetch. + * + * Eager bundling had no window here: the catalog was already in memory, so + * `activateLocale` reached `changeLanguage` before the next click could land. + * Lazy loading opens one — and the loser of the race is whichever chunk resolves + * LAST, not whichever the user picked last. Without a guard, choosing `zh-cn` + * then `pt-pt` can leave the app in `zh-cn` and persist a cookie the user never + * selected, so the wrong language survives a reload too. + */ +describe("concurrent activation", () => { + it("ignores a stale activation that resolves after a newer one", async () => { + // The race only exists because the catalog is fetched. Delaying zh-cn's + // chunk forces the ordering a fast local run never produces: the user's + // FIRST pick resolves LAST. Without the sequence guard the late zh-cn + // continuation wins and persists a cookie the user did not choose. + vi.doMock("../../src/locales/zh-cn/settings.json", async () => { + const actual = await vi.importActual>( + "../../src/locales/zh-cn/settings.json", + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + return actual; + }); + + const { i18n, activateLocale } = await freshI18n(); + + const stale = activateLocale("zh-cn"); + const current = activateLocale("pt-pt"); + const [staleResult] = await Promise.all([stale, current]); + + expect(i18n.language).toBe("pt-pt"); + // The abandoned call reports the winner, not what it was asked for. The + // switcher does `setLocale(await activateLocale(value))` and the stale call + // settles LAST, so returning "zh-cn" here would leave the dropdown reading + // 简体中文 over a Portuguese app — the race surviving in the one place the + // user looks to confirm it did not happen. + expect(staleResult).toBe("pt-pt"); + vi.doUnmock("../../src/locales/zh-cn/settings.json"); + }); + + it("still activates normally when calls do not overlap", async () => { + const { i18n, activateLocale } = await freshI18n(); + + await activateLocale("zh-cn"); + expect(i18n.language).toBe("zh-cn"); + + await activateLocale("pt-pt"); + expect(i18n.language).toBe("pt-pt"); + }); +}); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 92d4c3abf5..4094636417 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -5,6 +5,8 @@ import { setOnUnauthorized } from "@/lib/api/client"; import { PluginModalHost } from "@/components/plugins/plugin-modal-host"; import { useAppStoreApi, StateProvider } from "@/components/state-provider"; import { PluginBootBridge } from "@/lib/plugins/plugin-boot-bridge"; +import { preloadLocale } from "@/lib/i18n"; +import { resolveInitialLocale } from "@/lib/i18n/boot"; import { AppShell } from "./app-shell"; import { AuthGatedScreen, useAuthGateDecision } from "./auth-gate"; import { loadBootPayload } from "./boot-payload"; @@ -67,7 +69,14 @@ if (!root) { throw new Error("Missing #root element"); } -void loadBootPayload().then((payload) => { +void loadBootPayload().then(async (payload) => { + // Only `en` ships in the entry chunk, so a non-English boot has to fetch its + // catalogs. Awaited HERE, in the promise the mount already waited on, rather + // than after mounting: with `returnNull: false` a missing key renders as the + // key itself, so rendering first would paint a frame of raw `common:save` + // strings. Resolves immediately for `en` — the common case is unchanged. + await preloadLocale(resolveInitialLocale(payload)); + createRoot(root).render( diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ce612e9f32..5ee491e430 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,9 +1,39 @@ +import fs from "node:fs"; import path from "node:path"; import react from "@vitejs/plugin-react"; import { defineConfig, type Plugin } from "vite"; import { shouldBundlePseudoLocale } from "./lib/i18n/bundling"; +const LOCALES_DIR = path.resolve(__dirname, "src/locales"); + +/** + * One chunk per non-`en` locale, instead of one per locale-namespace file. + * + * `lib/i18n/index.ts` glob-imports the catalogs lazily, which by default emits a + * chunk per JSON file — 29 namespaces × 3 locales. i18next is seeded with every + * namespace and any surface may use any of them, so a locale is always loaded + * whole; per-namespace chunks buy no smaller download and cost a non-English + * boot 29 round trips before it can paint. Grouping makes that one request. + * + * Read from disk rather than mirroring `SUPPORTED_LOCALES`, so adding a locale + * directory needs no edit here. `en` is deliberately absent: it is statically + * imported into the entry chunk as the synchronous fallback. + */ +function localeChunkGroups() { + return fs + .readdirSync(LOCALES_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name !== "en") + .map((entry) => ({ + name: `i18n-${entry.name}`, + test: new RegExp(`/src/locales/${escapeRegExp(entry.name)}/[^/]+\\.json$`), + })); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + export default defineConfig({ plugins: [react(), pseudoLocaleBundling()], server: { @@ -30,6 +60,11 @@ export default defineConfig({ build: { outDir: "dist", emptyOutDir: true, + rolldownOptions: { + output: { + advancedChunks: { groups: localeChunkGroups() }, + }, + }, }, }); diff --git a/apps/web/vitest.setup.ts b/apps/web/vitest.setup.ts index 0bf36536eb..5b2800938f 100644 --- a/apps/web/vitest.setup.ts +++ b/apps/web/vitest.setup.ts @@ -1,6 +1,6 @@ import type { Window as HappyDOMWindow } from "happy-dom"; -import { initI18nForTests } from "./lib/i18n"; +import { initI18nForTests, loadAllLocalesForTests } from "./lib/i18n"; /** * i18n test bootstrap. @@ -13,8 +13,16 @@ import { initI18nForTests } from "./lib/i18n"; * * The real `en` catalog is loaded, so assertions on English copy pass exactly as * they did before the migration. + * + * Only `en` is bundled in the browser; the rest are lazy chunks. Suites drive + * the shared instance with a bare `i18n.changeLanguage("pseudo")` and assert on + * the next line, so the non-English catalogs are awaited here — top-level await + * in a setup file runs before any suite. That keeps unit tests in the + * "everything in memory" world they were written for without putting the other + * locales back into the bundle. */ initI18nForTests(); +await loadAllLocalesForTests(); function createLocalStorageMock(): Storage { const store = new Map(); diff --git a/docs/i18n.md b/docs/i18n.md index 9e6121ac15..b4c651327e 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -220,6 +220,16 @@ which `vite.config.ts` defines from `shouldBundlePseudoLocale` in modules into the graph. Filtering the merged catalog at runtime instead would still ship every byte, which is the entire problem. +**Lazy loading is not a substitute for this, and the two compose rather than +overlap.** Catalogs are fetched on demand (see +[Catalog loading](#catalog-loading-and-why-en-is-the-exception)), so pseudo would +be a chunk nobody requests in production — but an unrequested chunk is still +built, still deployed, and still reachable by anyone who can set the +`kandev_locale` cookie. The build-time constant is folded into the *lazy* glob +for exactly that reason: on a plain `vite build` no pseudo chunk is emitted at +all. Nothing asserts on that absence, so check it by hand when either mechanism +changes — `grep -rl "Ŕēţŕŷ" dist/assets/*.js` must return nothing. + | invocation | pseudo? | | ----------------------------------------------------------------------- | ------- | | `pnpm dev`, `vitest` (Vite `serve`) | yes | @@ -468,11 +478,64 @@ the completeness check. `` (mounted outermost in `app-shell.tsx`), `resolveInitialLocale` (payload → cookie → `en`), and `formats.ts` (locale-aware date/number/relative time; `timeAgo` is a deprecated alias for `formatRelative`). -- **Catalogs**: `src/locales//.json`, loaded eagerly through - Vite's glob import — no build plugin and no macro transform. +- **Catalogs**: `src/locales//.json`, discovered through + Vite's glob import — no macro transform. **`en` is bundled eagerly; every + other locale is a lazily-fetched chunk** (see below). The one build plugin is + `kandev:i18n-pseudo-locale-bundling` in `vite.config.ts`, which decides + whether the `pseudo` catalog is compiled in at all. - **Locale source of truth**: the `kandev_locale` cookie. The Go shell (`apps/backend/internal/webapp/shell.go`) reads it to set `` and seed the boot payload, so first paint matches with no flash. + +### Catalog loading, and why `en` is the exception + +Bundling every locale put all of them in the initial download — 1.4 MB raw / +389 KB gzipped, growing linearly with each locale added, so that a user could +read one language they had downloaded four of. Only `en` is eager now; the rest +are fetched per locale. + +Two things make that safe rather than a source of flashing keys: + +- **`en` must be synchronous.** It is `DEFAULT_LOCALE` and `fallbackLng`, and + `returnNull: false` means a missing key renders as _the key itself_. If `en` + were not in memory when `i18next.init()` returns, every key on screen would + paint as `common:save` until a chunk landed — silent and ugly rather than + loud. Keeping it eager also keeps `ensureInitialized` synchronous, which the + boot path depends on: react-i18next suspends on an uninitialized instance and + there is no Suspense boundary above the React root. +- **The active locale is known before React mounts.** The Go shell resolves it + into the boot payload, so `main.tsx` awaits exactly ONE locale + (`preloadLocale`) inside the promise it already waited on for the payload, + _before_ `createRoot().render()`. Nothing renders against a half-loaded + catalog. For `en` that await resolves immediately and the boot is unchanged. + +A runtime switch goes through `activateLocale`, which loads **before** +`changeLanguage` rather than on the `languageChanged` event — `changeLanguage` +re-renders the whole tree, so registering afterwards would show one frame of +English. A failed chunk never throws: the app degrades to English under the +requested ``, and `installVitePreloadRecovery` reloads once on +`vite:preloadError`. + +`vite.config.ts` groups each locale's namespaces into a single chunk. i18next is +seeded with every namespace and any surface may use any of them, so a locale is +always loaded whole; a chunk per namespace would cost a non-English boot 29 round +trips for the same bytes. + +Unit tests are the one place that still wants everything in memory — suites call +`i18n.changeLanguage("pseudo")` and assert on the next line — so +`vitest.setup.ts` awaits `loadAllLocalesForTests()` once. + +`pseudo-coverage.spec.ts` already tolerates this, and it is worth knowing why it +does not need a change here. Its `waitForScreen` anchors assert per screen that +the route rendered **and** that the anchor's copy went accented — the second half +was written as insurance against exactly this, a catalog arriving asynchronously, +back when it was still hypothetical. `` alone would not have done: +`shell.go` writes it server-side, so it is correct before any script runs. + +`e2e/tests/i18n/lazy-catalogs.spec.ts` is the gate. It asserts against a real +production bundle that an English boot fetches no other catalog, that activating +one fetches exactly its chunk, and that a cold `zh-cn` boot renders translated +rather than key names. - **Shared UI**: `@kandev/ui` primitives carry their own hardcoded accessibility labels ("Close", "Toggle Sidebar", "Loading", "Go to next page"). The package must not import the app's i18n runtime — it has to stay usable standalone — so diff --git a/docs/specs/platform/i18n.md b/docs/specs/platform/i18n.md index a346420609..025b9d55fe 100644 --- a/docs/specs/platform/i18n.md +++ b/docs/specs/platform/i18n.md @@ -102,7 +102,11 @@ i18n adds no server-side persistent entities. It introduces: rather than failing, and i18next falls back to `en` at runtime; `pseudo` is generated from `en` by `scripts/generate-pseudo-locale.mjs` and is never hand-edited. Catalogs load at - runtime via Vite glob import — no build plugin. + runtime via Vite glob import — no build plugin. Only `en` is bundled into the + initial download: it is the fallback locale, so it has to be in memory the + moment i18next initializes or every key paints as its own name. Every other + locale is one lazily-fetched chunk, and the boot awaits exactly the active + one — resolved by the Go shell into the boot payload — before React mounts. - **Locale preference** — the user's chosen locale, one value from the supported set. A dedicated `kandev_locale` cookie is the authoritative persisted value read by the Go shell so the initial `` and first paint match the @@ -190,6 +194,10 @@ Frontend module contract (new): `DEFAULT_LOCALE` (`en`) and does not throw. - **Malformed catalog JSON** → the Vite glob import fails the build (caught in CI), never shipped. +- **A locale's catalog chunk fails to fetch** → the app renders in `en` under the + requested ``; it never blanks, throws, or blocks the boot. `en` is + bundled, so the fallback is always available. `installVitePreloadRecovery` + reloads once on `vite:preloadError`, and the next boot retries. - **`@kandev/ui` used without an injected dictionary** → primitives use their English `defaultProps`; no runtime dependency on the app i18n instance.