mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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') })
|
||||
})
|
||||
Reference in New Issue
Block a user