fix(i18n): recover language selection after rejected catalog loads (#31822)

* fix(i18n): allow retrying rejected language catalog loads

* fix(test): return null for unhandled locale asset imports
This commit is contained in:
Jinjing
2026-10-21 21:46:29 -07:00
committed by GitHub
parent dfa8788533
commit b0778082cb
5 changed files with 149 additions and 36 deletions
+15
View File
@@ -1,3 +1,4 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { resolve } from 'node:path'
import { defaultExclude, defineConfig } from 'vitest/config'
@@ -15,6 +16,20 @@ const paletteMeasurementFile =
'src/renderer/src/lib/palette-match/palette-match-performance.test.ts'
const measurementFiles = [measurementFile, paletteMeasurementFile]
const transforms = {
plugins: [
{
// Unit tests have no asset server; retain real catalog parsing through fetch(data:).
name: 'locale-catalog-test-assets',
enforce: 'pre' as const,
load(id: string) {
if (/\/i18n\/locales\/[a-z]+\.json\?url$/.test(id)) {
const catalog = readFileSync(id.slice(0, -4)).toString('base64')
return `export default ${JSON.stringify(`data:application/json;base64,${catalog}`)}`
}
return null
}
}
],
define: {
ORCA_FEATURE_WALL_ENABLED: 'true',
// Why: tests only interpolate it; they must not depend on the checkout's HEAD.
+34 -24
View File
@@ -7,6 +7,12 @@ import i18next, {
import { initReactI18next } from 'react-i18next'
import enRuntimeRequired from './en-runtime-required.json'
import esCatalogUrl from './locales/es.json?url'
import frCatalogUrl from './locales/fr.json?url'
import jaCatalogUrl from './locales/ja.json?url'
import koCatalogUrl from './locales/ko.json?url'
import ptCatalogUrl from './locales/pt.json?url'
import zhCatalogUrl from './locales/zh.json?url'
import { isPseudoLocalizationLocale, pseudoLocalizeString } from './pseudo-localization'
import { advanceTranslationResourceVersion } from './translation-resource-version'
import { DEFAULT_LOCALE, resolveUiLocale } from './supported-languages'
@@ -17,37 +23,41 @@ import { withTimeout } from '../../../shared/promise-timeout-fallback'
export const i18n: I18nInstance = i18next.createInstance()
// Why: only the English catalog is bundled eagerly. The other four locales add
// ~2MB to the renderer's startup chunk (parsed on every launch) even though the
// app always boots in English and only switches after the persisted UI language
// loads. A lazy backend fetches each non-English catalog on demand, so any
// changeLanguage() call (UI switch or test) transparently loads its bundle
// instead of paying the parse cost at cold start.
const NON_DEFAULT_LOCALE_LOADERS: Record<
Exclude<SupportedUiLocale, 'en'>,
() => Promise<{ default: Record<string, unknown> }>
> = {
es: () => import('./locales/es.json'),
fr: () => import('./locales/fr.json'),
ja: () => import('./locales/ja.json'),
ko: () => import('./locales/ko.json'),
pt: () => import('./locales/pt.json'),
zh: () => import('./locales/zh.json')
// Fetch catalogs as data: Chromium caches rejected module imports for the document's lifetime.
const NON_DEFAULT_LOCALE_URLS: Record<Exclude<SupportedUiLocale, 'en'>, string> = {
es: esCatalogUrl,
fr: frCatalogUrl,
ja: jaCatalogUrl,
ko: koCatalogUrl,
pt: ptCatalogUrl,
zh: zhCatalogUrl
}
function localeCatalogUrl(language: string): string | undefined {
return Object.entries(NON_DEFAULT_LOCALE_URLS).find(([name]) => name === language)?.[1]
}
async function loadLocaleCatalog(url: string): Promise<Record<string, unknown>> {
const response = await fetch(url, { cache: 'no-cache' })
if (!response.ok) {
throw new Error(`Language catalog request failed: ${response.status}`)
}
return response.json()
}
const lazyLocaleBackend: BackendModule = {
type: 'backend',
init: () => {},
read: (language: string, _namespace: string, callback: ReadCallback) => {
const loader = NON_DEFAULT_LOCALE_LOADERS[language as Exclude<SupportedUiLocale, 'en'>]
if (!loader) {
const url = localeCatalogUrl(language)
if (!url) {
// English (and unknown locales) are served from bundled resources; signal
// "nothing to load" so i18next falls back to the in-memory catalog.
callback(null, false)
return
}
loader().then(
(mod) => callback(null, mod.default),
loadLocaleCatalog(url).then(
(catalog) => callback(null, catalog),
(error) => callback(error instanceof Error ? error : new Error(String(error)), false)
)
}
@@ -101,13 +111,13 @@ export async function loadRendererUiLanguage(language: UiLanguage): Promise<stri
isPluginUiLanguage(language) && resourceLanguage === resolved
? DEFAULT_LOCALE
: resourceLanguage
const loader = Object.entries(NON_DEFAULT_LOCALE_LOADERS).find(([name]) => name === locale)?.[1]
if (loader && !i18n.hasResourceBundle(locale, 'translation')) {
const catalog = await withTimeout(loader(), 5000, null)
const url = localeCatalogUrl(locale)
if (url && !i18n.hasResourceBundle(locale, 'translation')) {
const catalog = await withTimeout(loadLocaleCatalog(url), 5000, null)
if (catalog === null) {
throw new Error('Language catalog unavailable')
}
i18n.addResourceBundle(locale, 'translation', catalog.default, true, true)
i18n.addResourceBundle(locale, 'translation', catalog, true, true)
}
return locale
}
@@ -10,15 +10,6 @@ import { I18nProvider } from './I18nProvider'
import { i18n } from './i18n'
import { useLanguageReadiness } from './language-readiness'
const { catalog } = vi.hoisted(() => {
let finish: (value: { default: Record<string, unknown> }) => void = () => {}
const promise = new Promise<{ default: Record<string, unknown> }>((resolve) => {
finish = resolve
})
return { catalog: { promise, finish } }
})
vi.mock('./locales/fr.json', () => catalog.promise)
afterEach(() => {
cleanup()
useAppStore.setState(useAppStore.getInitialState(), true)
@@ -30,6 +21,19 @@ it('releases onboarding in English when a catalog stalls, ignores its late resul
vi.useFakeTimers()
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
Object.defineProperty(window, 'api', { configurable: true, value: {} })
let finishCatalog: (response: Response) => void = () => {}
const catalog = new Promise<Response>((resolve) => {
finishCatalog = resolve
})
vi.stubGlobal(
'fetch',
vi
.fn()
.mockReturnValueOnce(catalog)
.mockImplementation(() =>
Promise.resolve(new Response(JSON.stringify({ menu: { file: 'Fichier' } })))
)
)
usePluginLanguagePackStore.setState({ entries: [], loaded: true })
useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'fr' } })
useLanguageReadiness.setState({ ready: false, error: false })
@@ -53,8 +57,8 @@ it('releases onboarding in English when a catalog stalls, ignores its late resul
expect(useAppStore.getState().settings?.uiLanguage).toBe('fr')
expect(i18n.language).toBe('en')
await act(async () => {
catalog.finish({ default: { menu: { file: 'Fichier' } } })
await import('./locales/fr.json')
finishCatalog(new Response(JSON.stringify({ menu: { file: 'Fichier' } })))
await catalog
await vi.advanceTimersByTimeAsync(0)
})
expect(i18n.language).toBe('en')
+28 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
UI_LANGUAGE_CHINESE,
@@ -44,6 +44,33 @@ describe('renderer i18n lazy locale loading', () => {
expect(i18n.t('menu.file', { defaultValue: 'File' })).not.toBe('File')
})
it.each(['network', 'http', 'invalid-json'] as const)(
'retries a rejected %s catalog load with a fresh request',
async (failure) => {
i18n.removeResourceBundle('ja', 'translation')
const request = vi.spyOn(globalThis, 'fetch')
if (failure === 'network') {
request.mockRejectedValueOnce(new TypeError('Failed to fetch'))
} else {
request.mockResolvedValueOnce(
failure === 'http' ? new Response('', { status: 503 }) : new Response('{')
)
}
try {
await expect(setRendererUiLanguage('ja')).rejects.toThrow('Language catalog unavailable')
expect(i18n.language).toBe('en')
expect(i18n.hasResourceBundle('ja', 'translation')).toBe(false)
await setRendererUiLanguage('ja')
expect(i18n.language).toBe('ja')
expect(i18n.t('menu.file', { defaultValue: 'File' })).toBe('ファイル')
expect(request).toHaveBeenCalledTimes(2)
} finally {
request.mockRestore()
}
}
)
it('uses the inline English default when a target catalog omits a key', async () => {
await setRendererUiLanguage(UI_LANGUAGE_SPANISH)
expect(i18n.t('missing.renderer.feature', { defaultValue: 'English fallback' })).toBe(
@@ -0,0 +1,57 @@
import { test, expect } from './helpers/orca-app'
test.use({ dismissOnboarding: false, seedTestRepo: false })
test('Retry recovers a rejected language catalog request without reloading', async ({
electronApp,
orcaPage
}, testInfo) => {
await orcaPage.evaluate(async () => {
const store = window.__store
if (!store) {
throw new Error('Renderer store unavailable')
}
const settings = await window.api.settings.get()
if (!settings.telemetry) {
throw new Error('Telemetry settings unavailable')
}
await store.getState().updateSettingsOrThrow({
uiLanguage: 'en',
telemetry: { ...settings.telemetry, onboardingLanguageCohort: 'treatment' }
})
})
await orcaPage.getByRole('button', { name: 'Switch language' }).click()
await expect(orcaPage.getByRole('radio', { name: '日本語', exact: true })).toBeVisible()
await electronApp.evaluate(({ session }) => {
session.defaultSession.webRequest.onBeforeRequest((details, callback) => {
callback({ cancel: /\/ja-[^/]+\.(?:json|js)(?:\?|$)/.test(details.url) })
})
})
const timeOrigin = await orcaPage.evaluate(() => performance.timeOrigin)
const rejected = orcaPage.waitForEvent('requestfailed', {
predicate: (request) => /\/ja-[^/]+\.(?:json|js)(?:\?|$)/.test(request.url())
})
await orcaPage.getByRole('radio', { name: '日本語', exact: true }).click()
const failedRequest = await rejected
await expect(orcaPage.getByRole('alert')).toContainText('Could not load this language')
await expect(orcaPage.getByRole('radio', { name: 'English', exact: true })).toBeChecked()
await orcaPage.screenshot({ path: testInfo.outputPath('catalog-request-rejected.png') })
await electronApp.evaluate(({ session }) => {
session.defaultSession.webRequest.onBeforeRequest(null)
})
expect(
await orcaPage.evaluate(async (url) => (await fetch(url)).status, failedRequest.url())
).toBe(200)
await orcaPage.getByRole('button', { name: 'Retry', exact: true }).click()
await expect(orcaPage.getByRole('radio', { name: '日本語', exact: true })).toBeChecked()
await expect(orcaPage.getByRole('radiogroup', { name: '表示言語', exact: true })).toBeVisible()
await expect(orcaPage.getByRole('alert')).toHaveCount(0)
await expect
.poll(() => orcaPage.evaluate(() => window.api.settings.get().then((s) => s.uiLanguage)))
.toBe('ja')
expect(await orcaPage.evaluate(() => performance.timeOrigin)).toBe(timeOrigin)
await orcaPage.screenshot({ path: testInfo.outputPath('catalog-retry-recovered.png') })
})