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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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() },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user