fix(i18n): prevent Experimental settings crash after language-pack updates (#31202)

Invalidate cached catalogs on i18next resource additions and removals so plugin language-pack updates keep Experimental settings usable without restarting. Preserve cached values only after a successful rebuild.

Fixes #30781.

Contributor approaches reviewed in #30893 and #31190.

Co-authored-by: lorenzozanee <wyz0707@proton.me>
Co-authored-by: Chen <zwq19980411@gmail.com>
This commit is contained in:
Neil
2026-10-20 13:16:52 -07:00
committed by GitHub
co-authored by lorenzozanee Chen
parent 94ebd4a5c0
commit c2cbdbdf33
4 changed files with 138 additions and 4 deletions
+5
View File
@@ -8,6 +8,7 @@ import { initReactI18next } from 'react-i18next'
import enRuntimeRequired from './en-runtime-required.json'
import { isPseudoLocalizationLocale, pseudoLocalizeString } from './pseudo-localization'
import { advanceTranslationResourceVersion } from './translation-resource-version'
import { DEFAULT_LOCALE, resolveUiLocale } from './supported-languages'
import type { SupportedUiLocale } from '../../../shared/ui-locale'
import { isPluginUiLanguage, type UiLanguage } from '../../../shared/ui-language'
@@ -82,6 +83,10 @@ void i18n
}
})
// Plugin packs can replace translations without changing the active language.
i18n.store.on('added', advanceTranslationResourceVersion)
i18n.store.on('removed', advanceTranslationResourceVersion)
export function translate(key: string, fallback: string, options?: TOptions): string {
const value = i18n.t(key, { defaultValue: fallback, ...options })
return isPseudoLocalizationLocale(i18n.language) ? pseudoLocalizeString(value) : value
@@ -0,0 +1,114 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { pluginLanguageResourceId } from '../../../shared/plugins/plugin-language-pack-artifact'
import {
getExperimentalPaneSearchEntries,
getExperimentalSearchEntry
} from '../components/settings/experimental-search'
import { i18n, setRendererPluginLanguagePacks, setRendererUiLanguage, translate } from './i18n'
import { createLocalizedCatalog } from './localized-catalog'
const PACK_ID = 'plugin:orca-test.traditional-chinese/zh-TW' as const
const PACK = {
id: PACK_ID,
resourceLanguage: pluginLanguageResourceId(PACK_ID),
pluginKey: 'orca-test.traditional-chinese',
locale: 'zh-TW',
catalog: {}
}
afterEach(async () => {
await i18n.changeLanguage('en')
setRendererPluginLanguagePacks([])
})
describe('localized catalog caching', () => {
it('reuses a catalog while the language and resources are unchanged', () => {
const builder = vi.fn(() => ({ title: translate('menu.file', 'File') }))
const getCatalog = createLocalizedCatalog(builder)
expect(getCatalog()).toBe(getCatalog())
expect(builder).toHaveBeenCalledTimes(1)
})
it('rebuilds after switching languages', async () => {
const getCatalog = createLocalizedCatalog(() => ({ title: translate('menu.file', 'File') }))
const english = getCatalog()
await i18n.changeLanguage('es')
expect(getCatalog().title).toBe('Archivo')
await i18n.changeLanguage('en')
expect(getCatalog()).toEqual(english)
})
it('retries a failed rebuild instead of returning the previous language', async () => {
const builder = vi.fn(() => ({ title: translate('menu.file', 'File') }))
const getCatalog = createLocalizedCatalog(builder)
expect(getCatalog().title).toBe('File')
await i18n.changeLanguage('es')
builder.mockImplementationOnce(() => {
throw new Error('Catalog build failed')
})
expect(getCatalog).toThrow('Catalog build failed')
expect(getCatalog().title).toBe('Archivo')
})
it('refreshes a cached fallback when resources are added to the active language', async () => {
setRendererPluginLanguagePacks([PACK])
await setRendererUiLanguage(PACK_ID)
const getCatalog = createLocalizedCatalog(() => ({ title: translate('menu.file', 'File') }))
expect(getCatalog().title).toBe('File')
i18n.addResourceBundle(PACK.resourceLanguage, 'translation', { menu: { file: '檔案' } })
expect(i18n.language).toBe(PACK.resourceLanguage)
expect(getCatalog().title).toBe('檔案')
expect(getCatalog()).toBe(getCatalog())
})
it('refreshes to the fallback when the active plugin resources are removed', async () => {
setRendererPluginLanguagePacks([{ ...PACK, catalog: { menu: { file: '檔案' } } }])
await setRendererUiLanguage(PACK_ID)
const getCatalog = createLocalizedCatalog(() => ({ title: translate('menu.file', 'File') }))
expect(getCatalog().title).toBe('檔案')
setRendererPluginLanguagePacks([])
expect(i18n.language).toBe(PACK.resourceLanguage)
expect(getCatalog().title).toBe('File')
})
it('keeps Experimental lookups usable after an in-place plugin language pack update', async () => {
const packWithTitles = (terminalAttention: string, newWorktreeCardStyle: string) => ({
...PACK,
catalog: {
auto: {
components: {
settings: {
experimental: {
search: {
'9e4ddf776d': terminalAttention,
newWorktreeCardStyle: { title: newWorktreeCardStyle }
}
}
}
}
}
}
})
setRendererPluginLanguagePacks([packWithTitles('終端關注', '舊卡片樣式')])
await setRendererUiLanguage(PACK_ID)
const original = getExperimentalPaneSearchEntries()
expect(getExperimentalSearchEntry().terminalAttention.title).toBe('終端關注')
expect(getExperimentalSearchEntry().newWorktreeCardStyle.title).toBe('舊卡片樣式')
setRendererPluginLanguagePacks([packWithTitles('終端機關注', '新卡片樣式')])
expect(i18n.language).toBe(PACK.resourceLanguage)
expect(getExperimentalSearchEntry().terminalAttention.title).toBe('終端機關注')
expect(getExperimentalSearchEntry().newWorktreeCardStyle.title).toBe('新卡片樣式')
expect(getExperimentalPaneSearchEntries()).not.toBe(original)
expect(getExperimentalPaneSearchEntries()).toBe(getExperimentalPaneSearchEntries())
})
})
+10 -4
View File
@@ -1,15 +1,21 @@
import { i18n } from './i18n'
import { getTranslationResourceVersion } from './translation-resource-version'
// Why: search metadata and catalogs call translate() during build. Caching per
// active locale keeps lookups cheap while still refreshing after language changes.
export function createLocalizedCatalog<T>(builder: () => T): () => T {
let cachedLocale: string | undefined
let cachedResourceVersion = -1
let cachedValue: T | undefined
return () => {
if (cachedLocale !== i18n.language || cachedValue === undefined) {
cachedLocale = i18n.language
const resourceVersion = getTranslationResourceVersion()
if (
cachedLocale !== i18n.language ||
cachedResourceVersion !== resourceVersion ||
cachedValue === undefined
) {
cachedValue = builder()
cachedLocale = i18n.language
cachedResourceVersion = resourceVersion
}
return cachedValue
}
@@ -0,0 +1,9 @@
let version = 0
export function getTranslationResourceVersion(): number {
return version
}
export function advanceTranslationResourceVersion(): void {
version += 1
}