Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix: preserve custom nodes i18n data when locales are lazily loaded
  • Loading branch information
jtydhr88 committed Dec 7, 2025
commit 6ea7358b87ef84e4aac132d88b9b3763dcd82e11
6 changes: 2 additions & 4 deletions src/components/graph/GraphCanvas.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}
Expand Down
210 changes: 210 additions & 0 deletions src/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

// 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 () => {
const { i18n, mergeCustomNodesI18n } = await import('./i18n')

// 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 () => {
const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')

// 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 () => {
const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')

// 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 () => {
const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')

// 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 () => {
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 () => {
const { loadLocale } = await import('./i18n')

await loadLocale('zh')
await loadLocale('zh')

// Should complete without error (second call returns early)
})

it('should warn for unsupported locale', async () => {
const { loadLocale } = await import('./i18n')
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 () => {
const { loadLocale } = await import('./i18n')

// Start multiple loads concurrently
const promises = [loadLocale('zh'), loadLocale('zh'), loadLocale('zh')]

await Promise.all(promises)
})
})
})
23 changes: 23 additions & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ const loadedLocales = new Set<string>(['en'])
// Track locales currently being loaded to prevent race conditions
const loadingLocales = new Map<string, Promise<void>>()

// Store custom nodes i18n data for merging when locales are lazily loaded
let customNodesI18nData: Record<string, Record<string, unknown>> = {}

/**
* Dynamically load a locale and its associated files (nodeDefs, commands, settings)
*/
Expand Down Expand Up @@ -137,6 +140,10 @@ export async function loadLocale(locale: string): Promise<void> {

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
Expand All @@ -150,6 +157,22 @@ export async function loadLocale(locale: string): Promise<void> {
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<string, Record<string, unknown>>
): void {
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)
Expand Down
Loading