diff --git a/webview-ui/playwright/AppProviders.tsx b/webview-ui/playwright/AppProviders.tsx index 906c34ec47..44ec76b3db 100644 --- a/webview-ui/playwright/AppProviders.tsx +++ b/webview-ui/playwright/AppProviders.tsx @@ -5,12 +5,10 @@ import { providerIdentifiers } from "@roo-code/types" import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext" import TranslationProvider from "@/i18n/TranslationContext" -import i18next, { loadTranslations } from "@/i18n/setup" +import i18next from "@/i18n/setup" import { TooltipProvider } from "@/components/ui/tooltip" import { TranslationContext as PlaywrightTranslationContext } from "@src/i18n/TranslationContext" -loadTranslations() - type InitialState = NonNullable["initialState"]> interface AppProvidersProps { diff --git a/webview-ui/src/components/chat/__tests__/IndexingStatusBadge.spec.tsx b/webview-ui/src/components/chat/__tests__/IndexingStatusBadge.spec.tsx index 25f92757f8..04ba9cc60e 100644 --- a/webview-ui/src/components/chat/__tests__/IndexingStatusBadge.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/IndexingStatusBadge.spec.tsx @@ -14,7 +14,6 @@ vi.mock("@/i18n/setup", () => ({ language: "en", changeLanguage: vi.fn(), }, - loadTranslations: vi.fn(), })) vi.mock("react-i18next", () => ({ diff --git a/webview-ui/src/i18n/TranslationContext.tsx b/webview-ui/src/i18n/TranslationContext.tsx index f2950ae2f8..978c77018a 100644 --- a/webview-ui/src/i18n/TranslationContext.tsx +++ b/webview-ui/src/i18n/TranslationContext.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, ReactNode, useEffect, useCallback } from "react" import { useTranslation } from "react-i18next" -import i18next, { loadTranslations } from "./setup" +import i18next, { FALLBACK_LANGUAGE, loadLanguage } from "./setup" import { useExtensionState } from "@/context/ExtensionStateContext" // Create context for translations @@ -18,19 +18,29 @@ const TranslationProvider: React.FC<{ children: ReactNode }> = ({ children }) => const { i18n } = useTranslation() // Get the extension state directly - it already contains all state properties const extensionState = useExtensionState() + const language = extensionState.language || FALLBACK_LANGUAGE - // Load translations once when the component mounts + // Only the fallback locale ships in the entry chunk, so any other language + // has to fetch its bundles before i18next can resolve a key. Switching after + // the fetch is deliberate: `changeLanguage` is what emits `languageChanged`, + // the event `useTranslation` subscribes to for re-renders. Until it fires the + // subtree keeps resolving keys through whichever language is still active: + // `fallbackLng` on a cold start, the previous language on a warm switch + // between two non-English locales. The swap-then-render shape is the same one + // this provider had when every locale was bundled. useEffect(() => { - try { - loadTranslations() - } catch (error) { - console.error("Failed to load translations:", error) - } - }, []) + let cancelled = false - useEffect(() => { - i18n.changeLanguage(extensionState.language) - }, [i18n, extensionState.language]) + void loadLanguage(language).then(() => { + if (!cancelled) { + void i18n.changeLanguage(language) + } + }) + + return () => { + cancelled = true + } + }, [i18n, language]) // Memoize the translation function to prevent unnecessary re-renders const translate = useCallback( diff --git a/webview-ui/src/i18n/__tests__/TranslationContext.locale-loading.spec.tsx b/webview-ui/src/i18n/__tests__/TranslationContext.locale-loading.spec.tsx new file mode 100644 index 0000000000..0717361f50 --- /dev/null +++ b/webview-ui/src/i18n/__tests__/TranslationContext.locale-loading.spec.tsx @@ -0,0 +1,150 @@ +import { act, render, screen, waitFor } from "@testing-library/react" +import React from "react" + +import i18next, { loadLanguage } from "../setup" +import TranslationProvider, { useAppTranslation } from "../TranslationContext" + +// The provider reads the active language from extension state. Tests drive it +// through a mutable holder so a rerender can simulate a settings change. +const extensionState: { language: string } = { language: "en" } + +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => extensionState, +})) + +const Probe = () => { + const { t, i18n } = useAppTranslation() + + return ( +
+ {t("common:errorBoundary.title")} + {i18n.language} +
+ ) +} + +const renderProbe = (language: string) => { + extensionState.language = language + + return render( + + + , + ) +} + +describe("TranslationProvider locale loading", () => { + beforeEach(async () => { + // i18next is a module-level singleton, so a language left active by an + // earlier test would mask the fallback path this suite is checking. + await i18next.changeLanguage("en") + }) + + it("renders translated strings for a non-English initial language", async () => { + renderProbe("ko") + + // Korean bundles are not in the entry chunk, so the first paint resolves + // through the English fallback and the switch lands after the fetch. + expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong") + + await waitFor(() => { + expect(screen.getByTestId("error-title")).toHaveTextContent("문제가 발생했습니다") + }) + expect(screen.getByTestId("active-language")).toHaveTextContent("ko") + }) + + it("loads a non-eager locale when the language changes at runtime", async () => { + const { rerender } = renderProbe("en") + + expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong") + + extensionState.language = "ja" + await act(async () => { + rerender( + + + , + ) + }) + + await waitFor(() => { + expect(screen.getByTestId("error-title")).toHaveTextContent("問題が発生しました") + }) + expect(screen.getByTestId("active-language")).toHaveTextContent("ja") + }) + + it("falls back to English when the requested locale has no resources", async () => { + renderProbe("xx-unsupported") + + await waitFor(() => { + expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong") + }) + }) + + it("keeps English available after switching away and back", async () => { + const { rerender } = renderProbe("de") + + await waitFor(() => { + expect(screen.getByTestId("error-title")).toHaveTextContent("Etwas ist schiefgelaufen") + }) + + extensionState.language = "en" + await act(async () => { + rerender( + + + , + ) + }) + + await waitFor(() => { + expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong") + }) + }) + + it("keeps the previous locale rendered while a warm switch is still loading", async () => { + const { rerender } = renderProbe("de") + + await waitFor(() => { + expect(screen.getByTestId("error-title")).toHaveTextContent("Etwas ist schiefgelaufen") + }) + + extensionState.language = "fr" + await act(async () => { + rerender( + + + , + ) + }) + + // French is not in the entry chunk and no other case loads it, so the + // switch cannot have landed yet. A `changeLanguage` fired before the fetch + // resolves would paint the English fallback here instead of German. + expect(screen.getByTestId("error-title")).toHaveTextContent("Etwas ist schiefgelaufen") + + await waitFor(() => { + expect(screen.getByTestId("error-title")).toHaveTextContent("Une erreur s'est produite") + }) + expect(screen.getByTestId("active-language")).toHaveTextContent("fr") + }) + + it("skips the language switch when the provider unmounts before the fetch resolves", async () => { + const changeLanguage = vi.spyOn(i18next, "changeLanguage") + + const { unmount } = renderProbe("vi") + // The locale chunk is still in flight here, so the effect cleanup runs + // before the fetch settles and `cancelled` decides the outcome. + unmount() + + // `loadLanguage` memoizes the in-flight promise, so awaiting the same + // locale here waits for the exact resolution the guard is deciding on. + // The provider attached its `.then` first, so its callback has already run + // by the time this returns: the switch was suppressed, not left pending. + await loadLanguage("vi") + + expect(i18next.hasResourceBundle("vi", "common")).toBe(true) + expect(changeLanguage).not.toHaveBeenCalledWith("vi") + expect(i18next.language).not.toBe("vi") + }) +}) diff --git a/webview-ui/src/i18n/__tests__/TranslationContext.spec.tsx b/webview-ui/src/i18n/__tests__/TranslationContext.spec.tsx index cdf0c21acf..953234ff6d 100644 --- a/webview-ui/src/i18n/__tests__/TranslationContext.spec.tsx +++ b/webview-ui/src/i18n/__tests__/TranslationContext.spec.tsx @@ -36,7 +36,8 @@ vi.mock("../setup", () => ({ }, changeLanguage: vi.fn(), }, - loadTranslations: vi.fn(), + FALLBACK_LANGUAGE: "en", + loadLanguage: vi.fn(() => Promise.resolve()), })) const TestComponent = () => { diff --git a/webview-ui/src/i18n/__tests__/setup.rejection.spec.ts b/webview-ui/src/i18n/__tests__/setup.rejection.spec.ts new file mode 100644 index 0000000000..d6812fc788 --- /dev/null +++ b/webview-ui/src/i18n/__tests__/setup.rejection.spec.ts @@ -0,0 +1,43 @@ +import i18next, { FALLBACK_LANGUAGE, loadLanguage } from "../setup" + +// A dynamic import that rejects is what a 404 or a chunk missing from the +// deployed manifest looks like at runtime. One namespace is enough: `loadLanguage` +// registers a language only after every namespace resolves. +vi.mock("../locales/ja/common.json", () => { + throw new Error("Failed to fetch dynamically imported module") +}) + +describe("i18n setup locale load failure", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it("resolves without throwing and keeps the English fallback", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined) + + await expect(loadLanguage("ja")).resolves.toBeUndefined() + + expect(warn).toHaveBeenCalledWith("Could not load ja translations:", expect.any(Error)) + // All-or-nothing per language: the namespaces that did resolve are not + // registered either, so no half-translated locale can be activated. + expect(i18next.hasResourceBundle("ja", "common")).toBe(false) + expect(i18next.hasResourceBundle("ja", "settings")).toBe(false) + expect(i18next.t("common:errorBoundary.title", { lng: "ja" })).toBe("Something went wrong") + }) + + it("does not memoize a failed load, so the next call re-attempts it", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined) + + await loadLanguage("ja") + await loadLanguage("ja") + + // `loadedLanguages` is only updated on success and `pendingLanguages` is + // cleared in `finally`, so a transient failure must not be memoized. + // This covers `loadLanguage`'s own bookkeeping only: engines that cache a + // failed module fetch in the module map (every Chromium below 156, so the + // VS Code webview) can still reject the second import from that cache + // without re-requesting the chunk. + expect(warn).toHaveBeenCalledTimes(2) + expect(i18next.language).toBe(FALLBACK_LANGUAGE) + }) +}) diff --git a/webview-ui/src/i18n/__tests__/setup.spec.ts b/webview-ui/src/i18n/__tests__/setup.spec.ts new file mode 100644 index 0000000000..867fc03f4e --- /dev/null +++ b/webview-ui/src/i18n/__tests__/setup.spec.ts @@ -0,0 +1,46 @@ +import i18next, { FALLBACK_LANGUAGE, loadLanguage } from "../setup" + +describe("i18n setup locale loading", () => { + it("ships the fallback locale eagerly", () => { + expect(i18next.hasResourceBundle(FALLBACK_LANGUAGE, "common")).toBe(true) + expect(i18next.hasResourceBundle(FALLBACK_LANGUAGE, "settings")).toBe(true) + }) + + it("does not ship non-fallback locales in the entry graph", () => { + // `hasResourceBundle` is false until `loadLanguage` fetches a chunk, which + // is what keeps the 17 other locales out of the first paint. + for (const language of ["ko", "ja", "de", "zh-CN"]) { + expect(i18next.hasResourceBundle(language, "common")).toBe(false) + } + }) + + it("registers every namespace of a lazily loaded locale", async () => { + await loadLanguage("ko") + + expect(i18next.hasResourceBundle("ko", "common")).toBe(true) + expect(i18next.hasResourceBundle("ko", "settings")).toBe(true) + expect(i18next.t("common:errorBoundary.title", { lng: "ko" })).toBe("문제가 발생했습니다") + }) + + it("shares one fetch across concurrent requests for the same locale", async () => { + const addResourceBundle = vi.spyOn(i18next, "addResourceBundle") + + // `loadLanguage` memoizes the in-flight promise, so concurrent callers get + // the very same promise back: one fetch, one registration pass. + const inFlight = [loadLanguage("ja"), loadLanguage("ja"), loadLanguage("ja")] + expect(inFlight[1]).toBe(inFlight[0]) + expect(inFlight[2]).toBe(inFlight[0]) + + const results = await Promise.all(inFlight) + + expect(results).toHaveLength(3) + // 9 namespaces registered once each, not 27. + expect(addResourceBundle).toHaveBeenCalledTimes(9) + expect(i18next.t("common:errorBoundary.title", { lng: "ja" })).toBe("問題が発生しました") + }) + + it("resolves without throwing for a locale that has no bundles", async () => { + await expect(loadLanguage("xx-unsupported")).resolves.toBeUndefined() + expect(i18next.t("common:errorBoundary.title", { lng: "xx-unsupported" })).toBe("Something went wrong") + }) +}) diff --git a/webview-ui/src/i18n/setup.ts b/webview-ui/src/i18n/setup.ts index 678cdc1d49..6010111e2a 100644 --- a/webview-ui/src/i18n/setup.ts +++ b/webview-ui/src/i18n/setup.ts @@ -1,54 +1,121 @@ import i18next from "i18next" import { initReactI18next } from "react-i18next" -// Build translations object -const translations: Record> = {} +/** + * The only locale bundled into the webview entry chunk. Every other locale is + * fetched on demand by `loadLanguage` so first paint does not pay for + * translations the user may never read. + */ +export const FALLBACK_LANGUAGE = "en" -// Dynamically load locale files -const localeFiles = import.meta.glob("./locales/**/*.json", { eager: true }) +/** + * A locale JSON file is a tree of translated strings, e.g. + * `{ "errorBoundary": { "title": "Something went wrong" } }`. Typing the leaves + * keeps the values checked on their way into `i18next.addResourceBundle`. + */ +type LocaleResources = { [key: string]: string | LocaleResources } +type LocaleModule = { default?: LocaleResources } & LocaleResources -// Process all locale files -Object.entries(localeFiles).forEach(([path, module]) => { - // Extract language and namespace from path - // Example path: './locales/en/common.json' -> language: 'en', namespace: 'common' - const match = path.match(/\.\/locales\/([^/]+)\/([^/]+)\.json/) +// Example path: './locales/en/common.json' -> language: 'en', namespace: 'common' +const LOCALE_PATH_PATTERN = /\.\/locales\/([^/]+)\/([^/]+)\.json$/ - if (match) { - const [, language, namespace] = match +const eagerLocaleFiles = import.meta.glob("./locales/en/*.json", { eager: true }) +const lazyLocaleFiles = import.meta.glob("./locales/**/*.json") - // Initialize language object if it doesn't exist - if (!translations[language]) { - translations[language] = {} +const pendingLanguages = new Map>() +const loadedLanguages = new Set([FALLBACK_LANGUAGE]) + +function parseLocalePath(path: string) { + const match = path.match(LOCALE_PATH_PATTERN) + + return match ? { language: match[1], namespace: match[2] } : undefined +} + +function collectLocaleResources(files: Record) { + const resources: Record> = {} + + Object.entries(files).forEach(([path, module]) => { + const parsed = parseLocalePath(path) + + if (!parsed) { + return } - // Add namespace resources to language - translations[language][namespace] = (module as any).default || module + const { language, namespace } = parsed + resources[language] = resources[language] || {} + resources[language][namespace] = module.default || module + }) + + return resources +} + +/** + * Register a locale's namespaces with i18next, fetching them on first use. + * + * Resolves even when the fetch fails so callers fall back to + * `FALLBACK_LANGUAGE` through i18next's `fallbackLng` instead of blocking the + * UI on a broken chunk. Concurrent calls for the same language share one fetch. + */ +export function loadLanguage(language: string): Promise { + if (loadedLanguages.has(language)) { + return Promise.resolve() } -}) -console.log("Dynamically loaded translations:", Object.keys(translations)) + const pending = pendingLanguages.get(language) + + if (pending) { + return pending + } + + const loaders = Object.entries(lazyLocaleFiles) + .filter(([path]) => parseLocalePath(path)?.language === language) + .map(([path, load]) => load().then((module) => ({ path, module }))) + + // Unknown language: leave the fallback bundles in place so i18next resolves + // every key through `fallbackLng`. + if (loaders.length === 0) { + loadedLanguages.add(language) + return Promise.resolve() + } + + const promise = Promise.all(loaders) + .then((modules) => { + modules.forEach(({ path, module }) => { + const parsed = parseLocalePath(path) + + if (!parsed) { + return + } + + i18next.addResourceBundle(parsed.language, parsed.namespace, module.default || module, true, true) + }) + + loadedLanguages.add(language) + }) + .catch((error) => { + console.warn(`Could not load ${language} translations:`, error) + }) + .finally(() => { + pendingLanguages.delete(language) + }) + + pendingLanguages.set(language, promise) + + return promise +} // Initialize i18next for React // This will be initialized with the VSCode language in TranslationProvider i18next.use(initReactI18next).init({ - lng: "en", // Default language (will be overridden) - fallbackLng: "en", + lng: FALLBACK_LANGUAGE, // Default language (will be overridden) + fallbackLng: FALLBACK_LANGUAGE, debug: false, + // Handed to `init` rather than added afterwards so the first render already + // resolves keys; `addResourceBundle` before init settles would be dropped. + resources: collectLocaleResources(eagerLocaleFiles), interpolation: { escapeValue: false, // React already escapes by default }, }) -export function loadTranslations() { - Object.entries(translations).forEach(([lang, namespaces]) => { - try { - Object.entries(namespaces).forEach(([namespace, resources]) => { - i18next.addResourceBundle(lang, namespace, resources, true, true) - }) - } catch (error) { - console.warn(`Could not load ${lang} translations:`, error) - } - }) -} - export default i18next