From f9e78ea9bcdf774264590e4235674d827eadf401 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Mon, 15 Sep 2025 10:18:19 +0100 Subject: [PATCH] fix(editor): Fix i18n package locale files hot reloading (no-changelog) (#19385) Co-authored-by: Csaba Tuncsik --- packages/frontend/@n8n/i18n/src/index.test.ts | 350 +++++++++++++++++- packages/frontend/@n8n/i18n/src/index.ts | 72 ++-- packages/frontend/@n8n/i18n/src/types.ts | 1 + packages/frontend/editor-ui/src/App.vue | 36 +- .../frontend/editor-ui/src/__tests__/setup.ts | 4 + .../frontend/editor-ui/src/dev/i18nHmr.ts | 69 ++++ .../components/InsightsDateRangeSelect.vue | 7 +- .../insights/components/InsightsSummary.vue | 10 +- .../features/insights/insights.constants.ts | 11 - .../src/features/insights/insights.utils.ts | 15 + .../editor-ui/src/i18n/loadDefaultEn.ts | 7 + packages/frontend/editor-ui/src/main.ts | 14 +- packages/frontend/editor-ui/src/shims.d.ts | 1 + packages/frontend/editor-ui/vite.config.mts | 26 +- .../vite/i18n-locales-hmr-helpers.ts | 19 + 15 files changed, 585 insertions(+), 57 deletions(-) create mode 100644 packages/frontend/editor-ui/src/dev/i18nHmr.ts create mode 100644 packages/frontend/editor-ui/src/i18n/loadDefaultEn.ts create mode 100644 packages/frontend/editor-ui/vite/i18n-locales-hmr-helpers.ts diff --git a/packages/frontend/@n8n/i18n/src/index.test.ts b/packages/frontend/@n8n/i18n/src/index.test.ts index 82b2eabfd9..ab18715e18 100644 --- a/packages/frontend/@n8n/i18n/src/index.test.ts +++ b/packages/frontend/@n8n/i18n/src/index.test.ts @@ -1,6 +1,32 @@ -import { I18nClass } from './index'; +/* eslint-disable id-denylist */ +import { I18nClass, loadLanguage, i18nInstance } from './index'; + +// Store original state for cleanup +let originalLocale: string; +let originalHtmlLang: string; +const testLocales = new Set(); describe(I18nClass, () => { + beforeAll(() => { + // Set up basic i18n messages for displayTimer tests + i18nInstance.global.setLocaleMessage('en', { + genericHelpers: { + millis: 'ms', + secShort: 's', + minShort: 'm', + hrsShort: 'h', + }, + }); + originalLocale = i18nInstance.global.locale.value; + originalHtmlLang = document.querySelector('html')?.getAttribute('lang') ?? 'en'; + }); + + afterAll(() => { + // Restore original locale and html lang + i18nInstance.global.locale.value = originalLocale as 'en'; + document.querySelector('html')?.setAttribute('lang', originalHtmlLang); + }); + describe('displayTimer', () => { it('should format duration with hours, minutes and seconds', () => { expect(new I18nClass().displayTimer(-1)).toBe('-1s'); @@ -33,3 +59,325 @@ describe(I18nClass, () => { }); }); }); + +describe('loadLanguage', () => { + beforeEach(() => { + // Reset to English before each test + i18nInstance.global.locale.value = 'en'; + document.querySelector('html')?.setAttribute('lang', 'en'); + }); + + afterEach(() => { + // Clean up test locales (we can't easily remove them, but we can reset to English) + i18nInstance.global.locale.value = 'en'; + document.querySelector('html')?.setAttribute('lang', 'en'); + }); + + it('should load a new language and set it as current locale', () => { + const locale = 'de'; + const messages = { + hello: 'Hallo', + world: 'Welt', + }; + + const result = loadLanguage(locale, messages); + + expect(result).toBe(locale); + expect(i18nInstance.global.locale.value).toBe(locale); + expect(i18nInstance.global.t('hello')).toBe('Hallo'); + expect(i18nInstance.global.t('world')).toBe('Welt'); + testLocales.add(locale); + }); + + it('should set the HTML lang attribute when loading a language', () => { + const locale = 'fr'; + const messages = { greeting: 'Bonjour' }; + + loadLanguage(locale, messages); + + expect(document.querySelector('html')?.getAttribute('lang')).toBe(locale); + testLocales.add(locale); + }); + + it('should handle number formats when provided in messages', () => { + const locale = 'de-num'; + const messages = { + hello: 'Hallo', + numberFormats: { + currency: { + style: 'currency', + currency: 'EUR', + }, + }, + }; + + loadLanguage(locale, messages); + + expect(i18nInstance.global.locale.value).toBe(locale); + expect(i18nInstance.global.t('hello')).toBe('Hallo'); + // Verify that numberFormats were set + expect(i18nInstance.global.getNumberFormat(locale)).toBeDefined(); + testLocales.add(locale); + }); + + it('should not reload a language if it has already been loaded', () => { + const locale = 'es'; + const originalMessages = { hello: 'Hola' }; + const newMessages = { hello: 'Buenos días' }; + + // Load the language for the first time + loadLanguage(locale, originalMessages); + expect(i18nInstance.global.t('hello')).toBe('Hola'); + + // Switch to English temporarily + i18nInstance.global.locale.value = 'en'; + + // Try to load the same language again with different messages + const result = loadLanguage(locale, newMessages); + + // Should return the locale but not update the messages + expect(result).toBe(locale); + expect(i18nInstance.global.locale.value).toBe(locale); + expect(i18nInstance.global.t('hello')).toBe('Hola'); // Should still be the original message + testLocales.add(locale); + }); + + it('should handle empty messages object', () => { + const locale = 'it'; + const messages = {}; + + const result = loadLanguage(locale, messages); + + expect(result).toBe(locale); + expect(i18nInstance.global.locale.value).toBe(locale); + testLocales.add(locale); + }); + + it('should handle messages with nested objects', () => { + const locale = 'pt'; + const messages = { + nested: { + deep: { + message: 'Mensagem aninhada', + }, + }, + simple: 'Simples', + }; + + loadLanguage(locale, messages); + + expect(i18nInstance.global.locale.value).toBe(locale); + expect(i18nInstance.global.t('nested.deep.message')).toBe('Mensagem aninhada'); + expect(i18nInstance.global.t('simple')).toBe('Simples'); + testLocales.add(locale); + }); + + it('should properly separate numberFormats from other messages', () => { + const locale = 'ja'; + const messages = { + greeting: 'こんにちは', + farewell: 'さようなら', + numberFormats: { + currency: { + style: 'currency', + currency: 'JPY', + }, + decimal: { + style: 'decimal', + minimumFractionDigits: 2, + }, + }, + }; + + loadLanguage(locale, messages); + + expect(i18nInstance.global.locale.value).toBe(locale); + expect(i18nInstance.global.t('greeting')).toBe('こんにちは'); + expect(i18nInstance.global.t('farewell')).toBe('さようなら'); + // numberFormats should not be accessible as regular translation messages + expect(i18nInstance.global.te('numberFormats')).toBe(false); + expect(i18nInstance.global.te('numberFormats.currency')).toBe(false); + testLocales.add(locale); + }); + + it('should switch between already loaded languages', () => { + const locale1 = 'fr-switch'; + const locale2 = 'de-switch'; + + // Load first language + loadLanguage(locale1, { hello: 'Bonjour' }); + expect(i18nInstance.global.locale.value).toBe(locale1); + expect(i18nInstance.global.t('hello')).toBe('Bonjour'); + + // Load second language + loadLanguage(locale2, { hello: 'Hallo' }); + expect(i18nInstance.global.locale.value).toBe(locale2); + expect(i18nInstance.global.t('hello')).toBe('Hallo'); + + // Switch back to first language (should not reload messages) + loadLanguage(locale1, { hello: 'Salut' }); // Different message + expect(i18nInstance.global.locale.value).toBe(locale1); + expect(i18nInstance.global.t('hello')).toBe('Bonjour'); // Should be original message + testLocales.add(locale1); + testLocales.add(locale2); + }); + + it('should return the locale that was set', () => { + const locale = 'nl'; + const messages = { test: 'test' }; + + const result = loadLanguage(locale, messages); + + expect(result).toBe(locale); + expect(typeof result).toBe('string'); + testLocales.add(locale); + }); + + it('should handle messages with special characters', () => { + const locale = 'zh'; + const messages = { + special: '特殊字符测试', + emoji: '🚀 测试 🎉', + mixed: 'Mixed 混合 content', + }; + + loadLanguage(locale, messages); + + expect(i18nInstance.global.locale.value).toBe(locale); + expect(i18nInstance.global.t('special')).toBe('特殊字符测试'); + expect(i18nInstance.global.t('emoji')).toBe('🚀 测试 🎉'); + expect(i18nInstance.global.t('mixed')).toBe('Mixed 混合 content'); + testLocales.add(locale); + }); + + it('should handle messages with undefined and null values', () => { + const locale = 'test-null'; + const messages = { + defined: 'Valid message', + undefined, + null: null, + }; + + loadLanguage(locale, messages); + + expect(i18nInstance.global.locale.value).toBe(locale); + expect(i18nInstance.global.t('defined')).toBe('Valid message'); + // Undefined and null values should fallback to the key + expect(i18nInstance.global.t('undefined')).toBe('undefined'); + expect(i18nInstance.global.t('null')).toBe('null'); + testLocales.add(locale); + }); + + it('should handle messages with array values', () => { + const locale = 'test-array'; + const messages = { + items: ['item1', 'item2', 'item3'], + nested: { + list: ['a', 'b', 'c'], + }, + }; + + loadLanguage(locale, messages); + + expect(i18nInstance.global.locale.value).toBe(locale); + // Vue i18n handles arrays, but accessing them directly might not work as expected + // This tests that the function doesn't crash with array values + expect(() => i18nInstance.global.t('items')).not.toThrow(); + testLocales.add(locale); + }); + + it('should handle loading the same locale as current locale', () => { + const currentLocale = 'en'; + const messages = { + newMessage: 'This is a new message', + }; + + // Ensure we're starting with English + i18nInstance.global.locale.value = currentLocale; + + const result = loadLanguage(currentLocale, messages); + + expect(result).toBe(currentLocale); + expect(i18nInstance.global.locale.value).toBe(currentLocale); + expect(i18nInstance.global.t('newMessage')).toBe('This is a new message'); + }); + + it('should handle numberFormats with complex structure', () => { + const locale = 'complex-number'; + const messages = { + greeting: 'Hello', + numberFormats: { + currency: { + style: 'currency', + currency: 'USD', + currencyDisplay: 'symbol', + }, + percent: { + style: 'percent', + minimumFractionDigits: 2, + }, + decimal: { + style: 'decimal', + minimumFractionDigits: 0, + maximumFractionDigits: 3, + }, + }, + }; + + loadLanguage(locale, messages); + + expect(i18nInstance.global.locale.value).toBe(locale); + expect(i18nInstance.global.t('greeting')).toBe('Hello'); + + const numberFormats = i18nInstance.global.getNumberFormat( + locale, + ) as typeof messages.numberFormats; + expect(numberFormats).toBeDefined(); + expect(numberFormats.currency).toBeDefined(); + expect(numberFormats.percent).toBeDefined(); + expect(numberFormats.decimal).toBeDefined(); + testLocales.add(locale); + }); + + it('should handle messages with boolean and numeric values', () => { + const locale = 'test-types'; + const messages = { + boolTrue: true, + boolFalse: false, + number: 42, + zero: 0, + string: 'actual string', + }; + + loadLanguage(locale, messages); + + expect(i18nInstance.global.locale.value).toBe(locale); + expect(i18nInstance.global.t('string')).toBe('actual string'); + // Boolean and numeric values that are not strings will fallback to the key name + // This is the expected behavior of vue-i18n when values are not strings + expect(i18nInstance.global.t('boolTrue')).toBe('boolTrue'); + expect(i18nInstance.global.t('boolFalse')).toBe('boolFalse'); + expect(i18nInstance.global.t('number')).toBe('number'); + expect(i18nInstance.global.t('zero')).toBe('zero'); + testLocales.add(locale); + }); + + it('should preserve HTML document language attribute correctly', () => { + const html = document.querySelector('html'); + const originalLang = html?.getAttribute('lang') ?? 'en'; + + const locale1 = 'preserve-1'; + const locale2 = 'preserve-2'; + + loadLanguage(locale1, { test: 'test1' }); + expect(html?.getAttribute('lang')).toBe(locale1); + + loadLanguage(locale2, { test: 'test2' }); + expect(html?.getAttribute('lang')).toBe(locale2); + + // Restore original + html?.setAttribute('lang', originalLang); + testLocales.add(locale1); + testLocales.add(locale2); + }); +}); diff --git a/packages/frontend/@n8n/i18n/src/index.ts b/packages/frontend/@n8n/i18n/src/index.ts index d90c3f5819..da117993c8 100644 --- a/packages/frontend/@n8n/i18n/src/index.ts +++ b/packages/frontend/@n8n/i18n/src/index.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-this-alias */ import type { INodeProperties, INodePropertyCollection, INodePropertyOptions } from 'n8n-workflow'; +import { ref } from 'vue'; import { createI18n } from 'vue-i18n'; -import englishBaseText from './locales/en.json'; import type { BaseTextKey, INodeTranslationHeaders } from './types'; import { deriveMiddleKey, @@ -17,10 +17,13 @@ export const i18nInstance = createI18n({ legacy: false, locale: 'en', fallbackLocale: 'en', - messages: { en: englishBaseText }, + messages: { en: {} }, warnHtmlMessage: false, }); +// Reactive version to signal i18n message updates to Vue computations +export const i18nVersion = ref(0); + type BaseTextOptions = { adjustToNumber?: number; interpolate?: Record; @@ -57,8 +60,12 @@ export class I18nClass { * Render a string of base text, i.e. a string with a fixed path to the localized value. Optionally allows for [interpolation](https://kazupon.github.io/vue-i18n/guide/formatting.html#named-formatting) when the localized value contains a string between curly braces. */ baseText(key: BaseTextKey, options?: BaseTextOptions): string { - // Create a unique cache key - const cacheKey = `${key}-${JSON.stringify(options)}`; + // Track reactive version so computed properties re-evaluate when messages change + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + i18nVersion.value; + + // Create a unique cache key, scoped by version + const cacheKey = `${i18nVersion.value}|${key}-${JSON.stringify(options)}`; // Check if the result is already cached if (this.baseTextCache.has(cacheKey)) { @@ -79,6 +86,14 @@ export class I18nClass { return result; } + /** + * Clear cached baseText results. Useful when locale messages are updated at runtime (e.g. HMR) or locale changes. + */ + clearCache() { + this.baseTextCache.clear(); + i18nVersion.value++; + } + /** * Render a string of dynamic text, i.e. a string with a constructed path to the localized value. */ @@ -375,35 +390,34 @@ export class I18nClass { }; } -const loadedLanguages = ['en']; +const loadedLanguages: string[] = []; -async function setLanguage(language: string) { - i18nInstance.global.locale.value = language as 'en'; - document.querySelector('html')!.setAttribute('lang', language); +export function setLanguage(locale: string) { + i18nInstance.global.locale.value = locale as 'en'; + document.querySelector('html')!.setAttribute('lang', locale); - return language; + // Invalidate cached baseText results on locale change + i18n.clearCache(); + + return locale; } -export async function loadLanguage(language: string) { - if (i18nInstance.global.locale.value === language) { - return await setLanguage(language); +export function loadLanguage(locale: string, messages: Record) { + if (loadedLanguages.includes(locale)) { + return setLanguage(locale); } - if (loadedLanguages.includes(language)) { - return await setLanguage(language); - } + const { numberFormats, ...rest } = messages; - const { numberFormats, ...rest } = (await import(`@n8n/i18n/locales/${language}.json`)).default; - - i18nInstance.global.setLocaleMessage(language, rest); + i18nInstance.global.setLocaleMessage(locale, rest); if (numberFormats) { - i18nInstance.global.setNumberFormat(language, numberFormats); + i18nInstance.global.setNumberFormat(locale, numberFormats); } - loadedLanguages.push(language); + loadedLanguages.push(locale); - return await setLanguage(language); + return setLanguage(locale); } /** @@ -422,6 +436,22 @@ export function addNodeTranslation( i18nInstance.global.mergeLocaleMessage(language, newMessages); } +/** + * Dev/runtime helper to replace messages for a locale without import side-effects. + * Used by editor UI HMR to apply updated translation JSON. + */ +export function updateLocaleMessages(locale: string, messages: Record) { + const { numberFormats, ...rest } = messages as Record & { + numberFormats?: Record; + }; + + i18nInstance.global.setLocaleMessage(locale, rest); + if (numberFormats) i18nInstance.global.setNumberFormat(locale, numberFormats); + + // Ensure subsequent reads recompute + i18n.clearCache(); +} + /** * Add a credential translation to the i18n instance's `messages` object. */ diff --git a/packages/frontend/@n8n/i18n/src/types.ts b/packages/frontend/@n8n/i18n/src/types.ts index 4c2e6687e3..7dff20f101 100644 --- a/packages/frontend/@n8n/i18n/src/types.ts +++ b/packages/frontend/@n8n/i18n/src/types.ts @@ -3,6 +3,7 @@ import type englishBaseText from './locales/en.json'; export type GetBaseTextKey = T extends `_${string}` ? never : T; export type BaseTextKey = GetBaseTextKey; +export type LocaleMessages = typeof englishBaseText; export interface INodeTranslationHeaders { data: { diff --git a/packages/frontend/editor-ui/src/App.vue b/packages/frontend/editor-ui/src/App.vue index 4c10bc6ae1..f81972267f 100644 --- a/packages/frontend/editor-ui/src/App.vue +++ b/packages/frontend/editor-ui/src/App.vue @@ -1,34 +1,35 @@