mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
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:
co-authored by
Ivan Malison
parent
71e7cf6ff8
commit
fc775b6db5
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user