Load translations when their language is selected (#7410)

* perf(app): load translations when their language is selected

Eager locale imports evaluated and registered all nine translation bundles on every cold start. Load bundled locales through a synchronous i18next backend so language selection keeps its before-render contract and offline behavior.

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>

* test(app): normalize translation entry paths across platforms

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
This commit is contained in:
paseo-bot[bot]
2026-10-19 21:03:24 +02:00
committed by GitHub
co-authored by Ivan Malison
parent 71e7cf6ff8
commit fc775b6db5
4 changed files with 177 additions and 32 deletions
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import type {
CheckoutPipelineJob,
CheckoutPrStatusResponse,
@@ -780,6 +780,11 @@ describe("formatAge", () => {
});
});
// Fixed translators do not select a language, so request their resources first.
beforeAll(async () => {
await i18n.loadLanguages("fr");
});
const english = i18n.getFixedT("en");
const french = i18n.getFixedT("fr");
@@ -0,0 +1,92 @@
import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { build } from "esbuild";
import { beforeAll, describe, expect, it } from "vitest";
let bundle: string;
// Exercise the bundled entry in fresh processes so the test runner's module cache
// cannot hide eager evaluation. The probe only records resource module execution.
beforeAll(async () => {
const result = await build({
entryPoints: [path.join(import.meta.dirname, "i18next.ts")],
bundle: true,
platform: "node",
format: "cjs",
external: ["i18next", "react-i18next"],
write: false,
plugins: [
{
name: "observe-translation-evaluation",
setup(builder) {
builder.onLoad(
{ filter: /[/\\]i18n[/\\]resources[/\\][a-z]{2}(?:-[A-Z]{2})?\.ts$/ },
async ({ path: file }) => ({
contents: `globalThis.evaluatedLocales.push(${JSON.stringify(path.basename(file, ".ts"))});\n${await readFile(file, "utf8")}`,
loader: "ts",
}),
);
},
},
],
});
bundle = result.outputFiles[0].text;
});
function runStartupProbe(actions = "") {
return JSON.parse(
execFileSync(process.execPath, ["-"], {
cwd: path.resolve(import.meta.dirname, "../.."),
encoding: "utf8",
input: `globalThis.evaluatedLocales = [];
(function () { ${bundle} })();
const { i18n } = module.exports;
${actions}
console.log(JSON.stringify({
evaluated: globalThis.evaluatedLocales,
registered: Object.keys(i18n.store.data),
language: i18n.language,
cancel: i18n.t("common.actions.cancel"),
fallback: i18n.t("startupProbeOnlyInEnglish"),
}));`,
}),
);
}
describe("bundled translation startup", () => {
it("evaluates and registers only English before a language is selected", () => {
expect(runStartupProbe()).toMatchObject({
evaluated: ["en"],
registered: ["en"],
language: "en",
cancel: "Cancel",
});
});
it.each([
["ar", "يلغي"],
["es", "Cancelar"],
["fr", "Annuler"],
["ja", "キャンセル"],
["ko", "취소"],
["pt-BR", "Cancelar"],
["ru", "Отмена"],
["zh-CN", "取消"],
])("loads %s synchronously on selection and reuses it", (locale, cancel) => {
const result = runStartupProbe(`
i18n.addResource("en", "translation", "startupProbeOnlyInEnglish", "English fallback");
i18n.changeLanguage(${JSON.stringify(locale)});
if (i18n.language !== ${JSON.stringify(locale)}) throw new Error("Language selection became asynchronous");
i18n.changeLanguage("en");
i18n.changeLanguage(${JSON.stringify(locale)});
`);
expect(result).toEqual({
evaluated: ["en", locale],
registered: ["en", locale],
language: locale,
cancel,
fallback: "English fallback",
});
});
});
+60 -31
View File
@@ -1,41 +1,70 @@
import { createInstance } from "i18next";
import { createInstance, type BackendModule } from "i18next";
import { initReactI18next } from "react-i18next";
import { observeI18nInit } from "./init";
import { ar } from "./resources/ar";
import { en } from "./resources/en";
import { es } from "./resources/es";
import { fr } from "./resources/fr";
import { ja } from "./resources/ja";
import { ko } from "./resources/ko";
import { ptBR } from "./resources/pt-BR";
import { ru } from "./resources/ru";
import { zhCN } from "./resources/zh-CN";
// Literal requires keep every locale bundled for offline use, while evaluating
// its resource module only when selected. Synchronous reads preserve the language
// provider's contract: translations are ready before it renders children.
const bundledTranslations: BackendModule = {
type: "backend",
init() {},
read(language, _namespace, callback) {
switch (language) {
case "en":
callback(null, en);
return;
case "ar":
callback(null, require("./resources/ar").ar);
return;
case "es":
callback(null, require("./resources/es").es);
return;
case "fr":
callback(null, require("./resources/fr").fr);
return;
case "ja":
callback(null, require("./resources/ja").ja);
return;
case "ko":
callback(null, require("./resources/ko").ko);
return;
case "pt-BR":
callback(null, require("./resources/pt-BR").ptBR);
return;
case "ru":
callback(null, require("./resources/ru").ru);
return;
case "zh-CN":
callback(null, require("./resources/zh-CN").zhCN);
return;
default:
callback(null, undefined);
}
},
};
const i18n = createInstance();
observeI18nInit(
i18n.use(initReactI18next).init({
compatibilityJSON: "v4",
fallbackLng: "en",
lng: "en",
resources: {
ar: { translation: ar },
en: { translation: en },
es: { translation: es },
fr: { translation: fr },
ja: { translation: ja },
ko: { translation: ko },
"pt-BR": { translation: ptBR },
ru: { translation: ru },
"zh-CN": { translation: zhCN },
},
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
}),
i18n
.use(bundledTranslations)
.use(initReactI18next)
.init({
compatibilityJSON: "v4",
fallbackLng: "en",
lng: "en",
partialBundledLanguages: true,
resources: {
en: { translation: en },
},
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
}),
);
export { i18n };
+19
View File
@@ -3,6 +3,7 @@ import { playwright } from "@vitest/browser-playwright";
import path from "path";
import fs from "fs";
import { transformSync } from "@babel/core";
import { build } from "esbuild";
// Set this before Vite resolves dependencies and Vitest starts its workers.
Object.assign(process.env, { NODE_ENV: "test" });
@@ -35,6 +36,24 @@ export default defineConfig({
if (result?.code) return { code: result.code, map: result.map };
},
},
{
name: "bundle-i18n-resources",
// Metro resolves literal requires lazily. Vite's TS transform leaves them
// to Node, which cannot resolve the resource modules. Bundle this boundary
// so unit and browser consumers exercise the same deferred module loading.
async transform(_source, id) {
if (path.resolve(id) !== path.resolve(__dirname, "src/i18n/i18next.ts")) return;
const result = await build({
entryPoints: [id],
bundle: true,
format: "esm",
platform: "neutral",
external: ["i18next", "react-i18next"],
write: false,
});
return { code: result.outputFiles[0].text, map: null };
},
},
],
test: {
environment: "node",