fix(editor): Fix i18n package locale files hot reloading (no-changelog) (#19385)

Co-authored-by: Csaba Tuncsik <csaba.tuncsik@gmail.com>
This commit is contained in:
Alex Grozav
2025-09-15 10:18:19 +01:00
committed by GitHub
parent 9aeb000453
commit f9e78ea9bc
15 changed files with 585 additions and 57 deletions

View File

@@ -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<string>();
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);
});
});

View File

@@ -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<string, string | number>;
@@ -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<string, unknown>) {
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<string, unknown>) {
const { numberFormats, ...rest } = messages as Record<string, unknown> & {
numberFormats?: Record<string, unknown>;
};
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.
*/

View File

@@ -3,6 +3,7 @@ import type englishBaseText from './locales/en.json';
export type GetBaseTextKey<T> = T extends `_${string}` ? never : T;
export type BaseTextKey = GetBaseTextKey<keyof typeof englishBaseText>;
export type LocaleMessages = typeof englishBaseText;
export interface INodeTranslationHeaders {
data: {