perf(i18n): lazy-load locale catalogs instead of bundling every language (#2362)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-07 19:43:42 +02:00
committed by GitHub
parent 224206e6d8
commit d855d615f8
10 changed files with 675 additions and 44 deletions
@@ -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-<locale>` 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-<hash>.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-<hash>.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.
});
});
@@ -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();
// `<html lang>` 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.
+195 -32
View File
@@ -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/<locale>/<namespace>.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/<locale>/<namespace>.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<string, unknown>;
// Vite resolves these at build time; each entry is `../../src/locales/<locale>/<ns>.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<string, CatalogModule> = {
/**
* `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<CatalogModule>("../../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<string, () => Promise<CatalogModule>> = {
...import.meta.glob<CatalogModule>(
["../../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<CatalogModule>("../../src/locales/pseudo/*.json", {
eager: true,
import: "default",
})
? import.meta.glob<CatalogModule>("../../src/locales/pseudo/*.json", { import: "default" })
: {}),
};
function localeResources(locale: SupportedLocale): Record<string, CatalogModule> {
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<string, CatalogModule> {
const resources: Record<string, CatalogModule> = {};
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<string, CatalogModule>
/** Namespaces present in the source catalog; used to seed i18next. */
export function knownNamespaces(): string[] {
const names = new Set<string>([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<SupportedLocale>([DEFAULT_LOCALE]);
/** In-flight loads, so two switches in a row share one fetch. */
const pendingLoads = new Map<SupportedLocale, Promise<void>>();
async function fetchLocale(locale: SupportedLocale): Promise<void> {
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<void> {
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 `<html lang>`
* — 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<void> {
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 `<html lang>`, and persist
* the cookie. Unknown locales coerce to `en`. Returns the locale activated.
* Activate a locale: fetch its catalogs, switch i18next, reflect it on
* `<html lang>`, 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<SupportedLocale> {
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<void> {
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<void> {
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.
+9 -2
View File
@@ -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.
+187
View File
@@ -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<Record<string, unknown>>(
"../../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");
});
});
+10 -1
View File
@@ -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(
<StrictMode>
<RootErrorBoundary>
+35
View File
@@ -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() },
},
},
},
});
+9 -1
View File
@@ -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<string, string>();
+65 -2
View File
@@ -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.
`<I18nProvider>` (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/<locale>/<namespace>.json`, loaded eagerly through
Vite's glob import — no build plugin and no macro transform.
- **Catalogs**: `src/locales/<locale>/<namespace>.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 `<html lang>` 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 `<html lang>`, 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. `<html lang>` 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
+9 -1
View File
@@ -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 `<html lang>` 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 `<html lang>`; 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.