diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 3d10328e62..35f2290340 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -132,7 +132,7 @@ import { useCopy } from '@/composables/useCopy' import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph' import { usePaste } from '@/composables/usePaste' import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags' -import { i18n, t } from '@/i18n' +import { mergeCustomNodesI18n, t } from '@/i18n' import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings' import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings' @@ -391,9 +391,7 @@ useEventListener( const loadCustomNodesI18n = async () => { try { const i18nData = await api.getCustomNodesI18n() - Object.entries(i18nData).forEach(([locale, message]) => { - i18n.global.mergeLocaleMessage(locale, message) - }) + mergeCustomNodesI18n(i18nData) } catch (error) { console.error('Failed to load custom nodes i18n', error) } diff --git a/src/i18n.test.ts b/src/i18n.test.ts new file mode 100644 index 0000000000..1cb2b673b0 --- /dev/null +++ b/src/i18n.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n') + +// Mock the JSON imports before importing i18n module +vi.mock('./locales/en/main.json', () => ({ default: { welcome: 'Welcome' } })) +vi.mock('./locales/en/nodeDefs.json', () => ({ + default: { testNode: 'Test Node' } +})) +vi.mock('./locales/en/commands.json', () => ({ + default: { save: 'Save' } +})) +vi.mock('./locales/en/settings.json', () => ({ + default: { theme: 'Theme' } +})) + +// Mock lazy-loaded locales +vi.mock('./locales/zh/main.json', () => ({ default: { welcome: '欢迎' } })) +vi.mock('./locales/zh/nodeDefs.json', () => ({ + default: { testNode: '测试节点' } +})) +vi.mock('./locales/zh/commands.json', () => ({ default: { save: '保存' } })) +vi.mock('./locales/zh/settings.json', () => ({ default: { theme: '主题' } })) + +describe('i18n', () => { + beforeEach(async () => { + vi.resetModules() + }) + + describe('mergeCustomNodesI18n', () => { + it('should immediately merge data for already loaded locales (en)', async () => { + // English is pre-loaded, so merge should work immediately + mergeCustomNodesI18n({ + en: { + customNode: { + title: 'Custom Node Title' + } + } + }) + + // Verify the custom node data was merged + const messages = i18n.global.getLocaleMessage('en') as Record< + string, + unknown + > + expect(messages.customNode).toEqual({ title: 'Custom Node Title' }) + }) + + it('should store data for not-yet-loaded locales', async () => { + const { i18n, mergeCustomNodesI18n } = await import('./i18n') + + // Chinese is not pre-loaded, data should be stored but not merged yet + mergeCustomNodesI18n({ + zh: { + customNode: { + title: '自定义节点标题' + } + } + }) + + // zh locale should not exist yet (not loaded) + const zhMessages = i18n.global.getLocaleMessage('zh') as Record< + string, + unknown + > + // Either empty or doesn't have our custom data merged directly + // (since zh wasn't loaded yet, mergeLocaleMessage on non-existent locale + // may create an empty locale or do nothing useful) + expect(zhMessages.customNode).toBeUndefined() + }) + + it('should merge stored data when locale is lazily loaded', async () => { + // First, store custom nodes i18n data (before locale is loaded) + mergeCustomNodesI18n({ + zh: { + customNode: { + title: '自定义节点标题' + } + } + }) + + await loadLocale('zh') + + // Verify both the base locale data and custom node data are present + const zhMessages = i18n.global.getLocaleMessage('zh') as Record< + string, + unknown + > + expect(zhMessages.welcome).toBe('欢迎') + expect(zhMessages.customNode).toEqual({ title: '自定义节点标题' }) + }) + + it('should preserve custom node data when locale is loaded after merge', async () => { + // Simulate the real scenario: + // 1. Custom nodes i18n is loaded first + mergeCustomNodesI18n({ + zh: { + customNode: { + title: '自定义节点标题' + }, + settingsCategories: { + Hotkeys: '快捷键' + } + } + }) + + // 2. Then locale is lazily loaded (this would previously overwrite custom data) + await loadLocale('zh') + + // 3. Verify custom node data is still present + const zhMessages = i18n.global.getLocaleMessage('zh') as Record< + string, + unknown + > + expect(zhMessages.customNode).toEqual({ title: '自定义节点标题' }) + expect(zhMessages.settingsCategories).toEqual({ Hotkeys: '快捷键' }) + + // 4. Also verify base locale data is present + expect(zhMessages.welcome).toBe('欢迎') + expect(zhMessages.nodeDefs).toEqual({ testNode: '测试节点' }) + }) + + it('should handle multiple locales in custom nodes i18n data', async () => { + // Merge data for multiple locales + mergeCustomNodesI18n({ + en: { + customPlugin: { name: 'Easy Use' } + }, + zh: { + customPlugin: { name: '简单使用' } + } + }) + + // English should be merged immediately (pre-loaded) + const enMessages = i18n.global.getLocaleMessage('en') as Record< + string, + unknown + > + expect(enMessages.customPlugin).toEqual({ name: 'Easy Use' }) + + await loadLocale('zh') + const zhMessages = i18n.global.getLocaleMessage('zh') as Record< + string, + unknown + > + expect(zhMessages.customPlugin).toEqual({ name: '简单使用' }) + }) + + it('should handle calling mergeCustomNodesI18n multiple times', async () => { + // Use fresh module instance to ensure clean state + vi.resetModules() + const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n') + + mergeCustomNodesI18n({ + zh: { plugin1: { name: '插件1' } } + }) + + mergeCustomNodesI18n({ + zh: { plugin2: { name: '插件2' } } + }) + + await loadLocale('zh') + + const zhMessages = i18n.global.getLocaleMessage('zh') as Record< + string, + unknown + > + // Only the second call's data should be present + expect(zhMessages.plugin2).toEqual({ name: '插件2' }) + // First call's data is overwritten + expect(zhMessages.plugin1).toBeUndefined() + }) + }) + + describe('loadLocale', () => { + it('should not reload already loaded locale', async () => { + await loadLocale('zh') + await loadLocale('zh') + + // Should complete without error (second call returns early) + }) + + it('should warn for unsupported locale', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await loadLocale('unsupported-locale') + + expect(consoleSpy).toHaveBeenCalledWith( + 'Locale "unsupported-locale" is not supported' + ) + consoleSpy.mockRestore() + }) + + it('should handle concurrent load requests for same locale', async () => { + // Start multiple loads concurrently + const promises = [loadLocale('zh'), loadLocale('zh'), loadLocale('zh')] + + await Promise.all(promises) + }) + }) +}) diff --git a/src/i18n.ts b/src/i18n.ts index 0e26c655c7..3ef845ed16 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -94,6 +94,9 @@ const loadedLocales = new Set(['en']) // Track locales currently being loaded to prevent race conditions const loadingLocales = new Map>() +// Store custom nodes i18n data for merging when locales are lazily loaded +const customNodesI18nData: Record = {} + /** * Dynamically load a locale and its associated files (nodeDefs, commands, settings) */ @@ -137,6 +140,10 @@ export async function loadLocale(locale: string): Promise { i18n.global.setLocaleMessage(locale, messages as LocaleMessages) loadedLocales.add(locale) + + if (customNodesI18nData[locale]) { + i18n.global.mergeLocaleMessage(locale, customNodesI18nData[locale]) + } } catch (error) { console.error(`Failed to load locale "${locale}":`, error) throw error @@ -150,6 +157,24 @@ export async function loadLocale(locale: string): Promise { return loadPromise } +/** + * Stores the data for later use when locales are lazily loaded, + * and immediately merges data for already-loaded locales. + */ +export function mergeCustomNodesI18n(i18nData: Record): void { + // Clear existing data and replace with new data + for (const key of Object.keys(customNodesI18nData)) { + delete customNodesI18nData[key] + } + Object.assign(customNodesI18nData, i18nData) + + for (const [locale, message] of Object.entries(i18nData)) { + if (loadedLocales.has(locale)) { + i18n.global.mergeLocaleMessage(locale, message) + } + } +} + // Only include English in the initial bundle const messages = { en: buildLocale(en, enNodes, enCommands, enSettings)