diff --git a/packages/app/src/git/pull-request-panel/data.test.ts b/packages/app/src/git/pull-request-panel/data.test.ts index 837433417e..1f72cb1d95 100644 --- a/packages/app/src/git/pull-request-panel/data.test.ts +++ b/packages/app/src/git/pull-request-panel/data.test.ts @@ -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"); diff --git a/packages/app/src/i18n/i18next.startup.test.ts b/packages/app/src/i18n/i18next.startup.test.ts new file mode 100644 index 0000000000..f439a5bb10 --- /dev/null +++ b/packages/app/src/i18n/i18next.startup.test.ts @@ -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", + }); + }); +}); diff --git a/packages/app/src/i18n/i18next.ts b/packages/app/src/i18n/i18next.ts index 05ceeee6ee..ccbfb8da46 100644 --- a/packages/app/src/i18n/i18next.ts +++ b/packages/app/src/i18n/i18next.ts @@ -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 }; diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 7a45d4a771..6274691582 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -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",