Skip to content
4 changes: 1 addition & 3 deletions webview-ui/playwright/AppProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<React.ComponentProps<typeof ExtensionStateContextProvider>["initialState"]>

interface AppProvidersProps {
Expand Down
8 changes: 2 additions & 6 deletions webview-ui/playwright/gallery/stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -420,12 +420,8 @@ export const stories: Record<string, Story> = {
return <UISettingsStory />
},
"ui-settings-long-locale": async () => {
const [{ UISettingsStory }, { default: i18next }] = await Promise.all([
import("@/components/settings/__tests__/UISettings.visual.fixture"),
import("@/i18n/setup"),
])
await i18next.changeLanguage("ru")
return <UISettingsStory />
const { UISettingsStory } = await import("@/components/settings/__tests__/UISettings.visual.fixture")
return <UISettingsStory initialState={{ language: "ru" }} />
},
welcome: async () => {
const [{ AppProviders }, { WelcomeLanding }] = await Promise.all([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ vi.mock("@/i18n/setup", () => ({
language: "en",
changeLanguage: vi.fn(),
},
loadTranslations: vi.fn(),
}))

vi.mock("react-i18next", () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ interface UIState {
autoCloseZooOpenedNewFiles?: boolean
}

export function UISettingsStory() {
export function UISettingsStory({ initialState }: Pick<React.ComponentProps<typeof AppProviders>, "initialState">) {
const [state, setState] = useState<UIState>({
reasoningBlockCollapsed: true,
enterBehavior: "send",
Expand Down Expand Up @@ -42,7 +42,7 @@ export function UISettingsStory() {
}

return (
<AppProviders>
<AppProviders initialState={initialState}>
<div
data-testid="ui-settings-story"
className="w-[488px] max-w-full rounded-lg border border-vscode-panel-border bg-vscode-editor-background p-4 text-vscode-editor-foreground">
Expand Down
32 changes: 21 additions & 11 deletions webview-ui/src/i18n/TranslationContext.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,19 +18,29 @@
const { i18n } = useTranslation()
// Get the extension state directly - it already contains all state properties
const extensionState = useExtensionState()
const language = extensionState.language || FALLBACK_LANGUAGE

Check warning on line 21 in webview-ui/src/i18n/TranslationContext.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/i18n/TranslationContext.tsx:21: 3 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: false). See the job summary for the complete list and resolution guidance.

// 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

Check warning on line 32 in webview-ui/src/i18n/TranslationContext.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/i18n/TranslationContext.tsx:32: Survived BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.

useEffect(() => {
i18n.changeLanguage(extensionState.language)
}, [i18n, extensionState.language])
void loadLanguage(language).then(() => {

Check warning on line 34 in webview-ui/src/i18n/TranslationContext.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/i18n/TranslationContext.tsx:34: Survived BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
if (!cancelled) {
void i18n.changeLanguage(language)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
})

return () => {
cancelled = true
}
}, [i18n, language])

// Memoize the translation function to prevent unnecessary re-renders
const translate = useCallback(
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<span data-testid="error-title">{t("common:errorBoundary.title")}</span>
<span data-testid="active-language">{i18n.language}</span>
</div>
)
}

const renderProbe = (language: string) => {
extensionState.language = language

return render(
<TranslationProvider>
<Probe />
</TranslationProvider>,
)
}

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")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

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(
<TranslationProvider>
<Probe />
</TranslationProvider>,
)
})

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(
<TranslationProvider>
<Probe />
</TranslationProvider>,
)
})

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(
<TranslationProvider>
<Probe />
</TranslationProvider>,
)
})

// 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")
})
})
3 changes: 2 additions & 1 deletion webview-ui/src/i18n/__tests__/TranslationContext.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ vi.mock("../setup", () => ({
},
changeLanguage: vi.fn(),
},
loadTranslations: vi.fn(),
FALLBACK_LANGUAGE: "en",
loadLanguage: vi.fn(() => Promise.resolve()),
}))

const TestComponent = () => {
Expand Down
70 changes: 70 additions & 0 deletions webview-ui/src/i18n/__tests__/setup.rejection.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import i18next, { 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.
const loader = vi.hoisted(() => ({ calls: 0, fail: true }))

vi.mock("../locales/ja/common.json", () => {
loader.calls++

if (loader.fail) {
throw new Error("Failed to fetch dynamically imported module")
}

return { default: { errorBoundary: { title: "問題が発生しました" } } }
})

describe("i18n setup locale load failure", () => {
beforeEach(() => {
loader.calls = 0
loader.fail = true
})

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")
expect(loader.calls).toBe(1)

await loadLanguage("ja")
expect(loader.calls).toBe(2)

// `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)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

it("registers the bundles when a later attempt succeeds", async () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined)

await loadLanguage("ja")
expect(i18next.hasResourceBundle("ja", "common")).toBe(false)

loader.fail = false
await loadLanguage("ja")

expect(i18next.t("common:errorBoundary.title", { lng: "ja" })).toBe("問題が発生しました")
})
})
46 changes: 46 additions & 0 deletions webview-ui/src/i18n/__tests__/setup.spec.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
Loading
Loading