Skip to content

Commit 8c5584c

Browse files
authored
fix: preserve custom nodes i18n data when locales are lazily loaded (#7214)
## Summary Custom nodes can provide localized translations via their locales folder. After the switch to lazy-loading locales (only English pre-loaded), custom node i18n data was being lost because: 1. loadCustomNodesI18n() merges data before locale is loaded 2. loadLocale() uses setLocaleMessage() which overwrites everything Solution: Store custom nodes i18n data and re-merge it after each lazy-loaded locale completes loading. - Add mergeCustomNodesI18n() function to store and merge custom data - Modify loadLocale() to re-merge stored data after loading - Add unit tests for the i18n merging behavior fix #7025 ## Screenshots (if applicable) <img width="706" height="1335" alt="image" src="https://github.com/user-attachments/assets/41e4ba2c-b4c0-4d0d-a104-1ede8939ff92" /> <img width="672" height="1319" alt="image" src="https://github.com/user-attachments/assets/6c3e55e5-20d2-4093-a86a-7496db3dfe94" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7214-fix-preserve-custom-nodes-i18n-data-when-locales-are-lazily-loaded-2c26d73d3650812fab31edd71cf51a19) by [Unito](https://www.unito.io)
1 parent f80654a commit 8c5584c

File tree

3 files changed

+227
-4
lines changed

3 files changed

+227
-4
lines changed

src/components/graph/GraphCanvas.vue

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ import { useCopy } from '@/composables/useCopy'
132132
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
133133
import { usePaste } from '@/composables/usePaste'
134134
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
135-
import { i18n, t } from '@/i18n'
135+
import { mergeCustomNodesI18n, t } from '@/i18n'
136136
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
137137
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
138138
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
@@ -391,9 +391,7 @@ useEventListener(
391391
const loadCustomNodesI18n = async () => {
392392
try {
393393
const i18nData = await api.getCustomNodesI18n()
394-
Object.entries(i18nData).forEach(([locale, message]) => {
395-
i18n.global.mergeLocaleMessage(locale, message)
396-
})
394+
mergeCustomNodesI18n(i18nData)
397395
} catch (error) {
398396
console.error('Failed to load custom nodes i18n', error)
399397
}

src/i18n.test.ts

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')
3+
4+
// Mock the JSON imports before importing i18n module
5+
vi.mock('./locales/en/main.json', () => ({ default: { welcome: 'Welcome' } }))
6+
vi.mock('./locales/en/nodeDefs.json', () => ({
7+
default: { testNode: 'Test Node' }
8+
}))
9+
vi.mock('./locales/en/commands.json', () => ({
10+
default: { save: 'Save' }
11+
}))
12+
vi.mock('./locales/en/settings.json', () => ({
13+
default: { theme: 'Theme' }
14+
}))
15+
16+
// Mock lazy-loaded locales
17+
vi.mock('./locales/zh/main.json', () => ({ default: { welcome: '欢迎' } }))
18+
vi.mock('./locales/zh/nodeDefs.json', () => ({
19+
default: { testNode: '测试节点' }
20+
}))
21+
vi.mock('./locales/zh/commands.json', () => ({ default: { save: '保存' } }))
22+
vi.mock('./locales/zh/settings.json', () => ({ default: { theme: '主题' } }))
23+
24+
describe('i18n', () => {
25+
beforeEach(async () => {
26+
vi.resetModules()
27+
})
28+
29+
describe('mergeCustomNodesI18n', () => {
30+
it('should immediately merge data for already loaded locales (en)', async () => {
31+
// English is pre-loaded, so merge should work immediately
32+
mergeCustomNodesI18n({
33+
en: {
34+
customNode: {
35+
title: 'Custom Node Title'
36+
}
37+
}
38+
})
39+
40+
// Verify the custom node data was merged
41+
const messages = i18n.global.getLocaleMessage('en') as Record<
42+
string,
43+
unknown
44+
>
45+
expect(messages.customNode).toEqual({ title: 'Custom Node Title' })
46+
})
47+
48+
it('should store data for not-yet-loaded locales', async () => {
49+
const { i18n, mergeCustomNodesI18n } = await import('./i18n')
50+
51+
// Chinese is not pre-loaded, data should be stored but not merged yet
52+
mergeCustomNodesI18n({
53+
zh: {
54+
customNode: {
55+
title: '自定义节点标题'
56+
}
57+
}
58+
})
59+
60+
// zh locale should not exist yet (not loaded)
61+
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
62+
string,
63+
unknown
64+
>
65+
// Either empty or doesn't have our custom data merged directly
66+
// (since zh wasn't loaded yet, mergeLocaleMessage on non-existent locale
67+
// may create an empty locale or do nothing useful)
68+
expect(zhMessages.customNode).toBeUndefined()
69+
})
70+
71+
it('should merge stored data when locale is lazily loaded', async () => {
72+
// First, store custom nodes i18n data (before locale is loaded)
73+
mergeCustomNodesI18n({
74+
zh: {
75+
customNode: {
76+
title: '自定义节点标题'
77+
}
78+
}
79+
})
80+
81+
await loadLocale('zh')
82+
83+
// Verify both the base locale data and custom node data are present
84+
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
85+
string,
86+
unknown
87+
>
88+
expect(zhMessages.welcome).toBe('欢迎')
89+
expect(zhMessages.customNode).toEqual({ title: '自定义节点标题' })
90+
})
91+
92+
it('should preserve custom node data when locale is loaded after merge', async () => {
93+
// Simulate the real scenario:
94+
// 1. Custom nodes i18n is loaded first
95+
mergeCustomNodesI18n({
96+
zh: {
97+
customNode: {
98+
title: '自定义节点标题'
99+
},
100+
settingsCategories: {
101+
Hotkeys: '快捷键'
102+
}
103+
}
104+
})
105+
106+
// 2. Then locale is lazily loaded (this would previously overwrite custom data)
107+
await loadLocale('zh')
108+
109+
// 3. Verify custom node data is still present
110+
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
111+
string,
112+
unknown
113+
>
114+
expect(zhMessages.customNode).toEqual({ title: '自定义节点标题' })
115+
expect(zhMessages.settingsCategories).toEqual({ Hotkeys: '快捷键' })
116+
117+
// 4. Also verify base locale data is present
118+
expect(zhMessages.welcome).toBe('欢迎')
119+
expect(zhMessages.nodeDefs).toEqual({ testNode: '测试节点' })
120+
})
121+
122+
it('should handle multiple locales in custom nodes i18n data', async () => {
123+
// Merge data for multiple locales
124+
mergeCustomNodesI18n({
125+
en: {
126+
customPlugin: { name: 'Easy Use' }
127+
},
128+
zh: {
129+
customPlugin: { name: '简单使用' }
130+
}
131+
})
132+
133+
// English should be merged immediately (pre-loaded)
134+
const enMessages = i18n.global.getLocaleMessage('en') as Record<
135+
string,
136+
unknown
137+
>
138+
expect(enMessages.customPlugin).toEqual({ name: 'Easy Use' })
139+
140+
await loadLocale('zh')
141+
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
142+
string,
143+
unknown
144+
>
145+
expect(zhMessages.customPlugin).toEqual({ name: '简单使用' })
146+
})
147+
148+
it('should handle calling mergeCustomNodesI18n multiple times', async () => {
149+
// Use fresh module instance to ensure clean state
150+
vi.resetModules()
151+
const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')
152+
153+
mergeCustomNodesI18n({
154+
zh: { plugin1: { name: '插件1' } }
155+
})
156+
157+
mergeCustomNodesI18n({
158+
zh: { plugin2: { name: '插件2' } }
159+
})
160+
161+
await loadLocale('zh')
162+
163+
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
164+
string,
165+
unknown
166+
>
167+
// Only the second call's data should be present
168+
expect(zhMessages.plugin2).toEqual({ name: '插件2' })
169+
// First call's data is overwritten
170+
expect(zhMessages.plugin1).toBeUndefined()
171+
})
172+
})
173+
174+
describe('loadLocale', () => {
175+
it('should not reload already loaded locale', async () => {
176+
await loadLocale('zh')
177+
await loadLocale('zh')
178+
179+
// Should complete without error (second call returns early)
180+
})
181+
182+
it('should warn for unsupported locale', async () => {
183+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
184+
185+
await loadLocale('unsupported-locale')
186+
187+
expect(consoleSpy).toHaveBeenCalledWith(
188+
'Locale "unsupported-locale" is not supported'
189+
)
190+
consoleSpy.mockRestore()
191+
})
192+
193+
it('should handle concurrent load requests for same locale', async () => {
194+
// Start multiple loads concurrently
195+
const promises = [loadLocale('zh'), loadLocale('zh'), loadLocale('zh')]
196+
197+
await Promise.all(promises)
198+
})
199+
})
200+
})

src/i18n.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ const loadedLocales = new Set<string>(['en'])
9494
// Track locales currently being loaded to prevent race conditions
9595
const loadingLocales = new Map<string, Promise<void>>()
9696

97+
// Store custom nodes i18n data for merging when locales are lazily loaded
98+
const customNodesI18nData: Record<string, unknown> = {}
99+
97100
/**
98101
* Dynamically load a locale and its associated files (nodeDefs, commands, settings)
99102
*/
@@ -137,6 +140,10 @@ export async function loadLocale(locale: string): Promise<void> {
137140

138141
i18n.global.setLocaleMessage(locale, messages as LocaleMessages)
139142
loadedLocales.add(locale)
143+
144+
if (customNodesI18nData[locale]) {
145+
i18n.global.mergeLocaleMessage(locale, customNodesI18nData[locale])
146+
}
140147
} catch (error) {
141148
console.error(`Failed to load locale "${locale}":`, error)
142149
throw error
@@ -150,6 +157,24 @@ export async function loadLocale(locale: string): Promise<void> {
150157
return loadPromise
151158
}
152159

160+
/**
161+
* Stores the data for later use when locales are lazily loaded,
162+
* and immediately merges data for already-loaded locales.
163+
*/
164+
export function mergeCustomNodesI18n(i18nData: Record<string, unknown>): void {
165+
// Clear existing data and replace with new data
166+
for (const key of Object.keys(customNodesI18nData)) {
167+
delete customNodesI18nData[key]
168+
}
169+
Object.assign(customNodesI18nData, i18nData)
170+
171+
for (const [locale, message] of Object.entries(i18nData)) {
172+
if (loadedLocales.has(locale)) {
173+
i18n.global.mergeLocaleMessage(locale, message)
174+
}
175+
}
176+
}
177+
153178
// Only include English in the initial bundle
154179
const messages = {
155180
en: buildLocale(en, enNodes, enCommands, enSettings)

0 commit comments

Comments
 (0)