diff --git a/src/renderer/src/i18n/i18n.ts b/src/renderer/src/i18n/i18n.ts index 725a40e9aaa..e315be8ab05 100644 --- a/src/renderer/src/i18n/i18n.ts +++ b/src/renderer/src/i18n/i18n.ts @@ -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 diff --git a/src/renderer/src/i18n/localized-catalog.test.ts b/src/renderer/src/i18n/localized-catalog.test.ts new file mode 100644 index 00000000000..0dfea2dab39 --- /dev/null +++ b/src/renderer/src/i18n/localized-catalog.test.ts @@ -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()) + }) +}) diff --git a/src/renderer/src/i18n/localized-catalog.ts b/src/renderer/src/i18n/localized-catalog.ts index eb35ebfd53a..fc37dcab081 100644 --- a/src/renderer/src/i18n/localized-catalog.ts +++ b/src/renderer/src/i18n/localized-catalog.ts @@ -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(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 } diff --git a/src/renderer/src/i18n/translation-resource-version.ts b/src/renderer/src/i18n/translation-resource-version.ts new file mode 100644 index 00000000000..26d608f7ce3 --- /dev/null +++ b/src/renderer/src/i18n/translation-resource-version.ts @@ -0,0 +1,9 @@ +let version = 0 + +export function getTranslationResourceVersion(): number { + return version +} + +export function advanceTranslationResourceVersion(): void { + version += 1 +}