mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
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:
co-authored by
lorenzozanee
Chen
parent
94ebd4a5c0
commit
c2cbdbdf33
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user