V16: Localization extensions load unordered (#19474)

* chore: export useful rxjs functions

* fix: use switchMap to ensure correct loading of localization extensions

also added filter() and distinctUntilChanged() to ensure the logic is not run more often than what is needed

* test: adds tests for async localization extensions and weights

* chore: apply simpler sorting syntax

* chore: adds catchError to ensure the whole stream is not stopped because of an error

* chore: lowest weight should win

* chore: move catchError so it catches everything

* chore: returns an observable to not break the stream

* chore: reverse weight as the previous was correct

* chore: adds a true comparer function that is more efficient

* Import order sorting

* Export order sorting

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
This commit is contained in:
Jacob Overgaard
2025-06-03 18:40:28 +02:00
committed by GitHub
parent f70d1c07c1
commit 8a22f243f8
4 changed files with 344 additions and 83 deletions

View File

@@ -1,22 +1,24 @@
export {
BehaviorSubject,
Observable,
ReplaySubject,
Subject,
Observable,
BehaviorSubject,
Subscription,
map,
distinctUntilChanged,
catchError,
combineLatest,
shareReplay,
takeUntil,
debounceTime,
tap,
of,
lastValueFrom,
firstValueFrom,
switchMap,
distinctUntilChanged,
filter,
startWith,
skip,
first,
firstValueFrom,
from,
lastValueFrom,
map,
of,
shareReplay,
skip,
startWith,
switchMap,
takeUntil,
tap,
} from 'rxjs';

View File

@@ -3,11 +3,13 @@ import { aTimeout, elementUpdated, expect, fixture, html } from '@open-wc/testin
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api';
import { umbLocalizationRegistry } from './registry/localization.registry.js';
import type { ManifestLocalization } from './extensions/localization.extension.js';
const english = {
const english: ManifestLocalization = {
type: 'localization',
alias: 'test.en',
name: 'Test English',
weight: 100,
meta: {
culture: 'en',
localizations: {
@@ -27,7 +29,75 @@ const english = {
},
};
const danish = {
const englishUs: ManifestLocalization = {
type: 'localization',
alias: 'test.en-us',
name: 'Test English (US)',
weight: 100,
meta: {
culture: 'en-us',
localizations: {
general: {
close: 'Close US',
overridden: 'Overridden',
},
},
},
};
// This is a factory function that returns the localization object.
const asyncFactory = async (localizations: Record<string, any>, delay: number) => {
await aTimeout(delay); // Simulate async loading
return {
// Simulate a JS module that exports a localization object.
default: localizations,
};
};
// This is an async localization that overrides the previous one.
const englishAsyncOverride: ManifestLocalization = {
type: 'localization',
alias: 'test.en.async-override',
name: 'Test English Async Override',
weight: -100,
meta: {
culture: 'en-us',
},
js: () =>
asyncFactory(
{
general: {
close: 'Close Async',
overridden: 'Overridden Async',
},
},
100,
),
};
// This is another async localization that loads later than the previous one and overrides it because of a lower weight.
const english2AsyncOverride: ManifestLocalization = {
type: 'localization',
alias: 'test.en.async-override-2',
name: 'Test English Async Override 2',
weight: -200,
meta: {
culture: 'en-us',
},
js: () =>
asyncFactory(
{
general: {
close: 'Another Async Close',
},
},
200, // This will load after the first async override
// so it should override the close translation.
// The overridden translation should not be overridden.
),
};
const danish: ManifestLocalization = {
type: 'localization',
alias: 'test.da',
name: 'Test Danish',
@@ -53,8 +123,7 @@ describe('umb-localize', () => {
});
describe('localization', () => {
umbExtensionsRegistry.register(english);
umbExtensionsRegistry.register(danish);
umbExtensionsRegistry.registerMany([english, englishUs, danish]);
beforeEach(async () => {
umbLocalizationRegistry.loadLanguage(english.meta.culture);
@@ -123,13 +192,50 @@ describe('umb-localize', () => {
it('should change the value if the language is changed', async () => {
expect(element.shadowRoot?.innerHTML).to.contain('Close');
// Change to Danish
umbLocalizationRegistry.loadLanguage(danish.meta.culture);
await aTimeout(0);
await elementUpdated(element);
expect(element.shadowRoot?.innerHTML).to.contain('Luk');
});
it('should fall back to the fallback language if the key is not found', async () => {
expect(element.shadowRoot?.innerHTML).to.contain('Close');
// Change to US English
umbLocalizationRegistry.loadLanguage(englishUs.meta.culture);
await aTimeout(0);
await elementUpdated(element);
expect(element.shadowRoot?.innerHTML).to.contain('Close US');
element.key = 'general_overridden';
await elementUpdated(element);
expect(element.shadowRoot?.innerHTML).to.contain('Overridden');
element.key = 'general_logout';
await elementUpdated(element);
expect(element.shadowRoot?.innerHTML).to.contain('Log out');
});
it('should accept a lazy loaded localization', async () => {
umbExtensionsRegistry.registerMany([englishAsyncOverride, english2AsyncOverride]);
umbLocalizationRegistry.loadLanguage(englishAsyncOverride.meta.culture);
await aTimeout(200); // Wait for the async override to load
await elementUpdated(element);
expect(element.shadowRoot?.innerHTML).to.contain(
'Another Async Close',
'(async) Should have overridden the close (from first language)',
);
element.key = 'general_overridden';
await elementUpdated(element);
expect(element.shadowRoot?.innerHTML).to.contain(
'Overridden Async',
'(async) Should not have overridden the overridden (from first language)',
);
});
it('should use the slot if translation is not found', async () => {
element.key = 'non-existing-key';
await elementUpdated(element);

View File

@@ -8,8 +8,10 @@ const englishUk: ManifestLocalization = {
type: 'localization',
alias: 'test.en',
name: 'Test English (UK)',
weight: 100,
meta: {
culture: 'en',
direction: 'ltr',
localizations: {
general: {
color: 'Colour',
@@ -22,6 +24,7 @@ const english: ManifestLocalization = {
type: 'localization',
alias: 'test.en-us',
name: 'Test English (US)',
weight: 100,
meta: {
culture: 'en-us',
direction: 'ltr',
@@ -46,16 +49,43 @@ const englishOverride: ManifestLocalization = {
type: 'localization',
alias: 'test.en.override',
name: 'Test English',
weight: 0,
meta: {
culture: 'en-us',
localizations: {
general: {
close: 'Close 2',
overridden: 'Overridden',
},
},
},
};
// This is a factory function that returns the localization object.
const englishAsyncFactory = async () => {
await aTimeout(100); // Simulate async loading
return {
// Simulate a JS module that exports a localization object.
default: {
general: {
close: 'Close Async',
overridden: 'Overridden Async',
},
},
};
};
const englishAsyncOverride: ManifestLocalization = {
type: 'localization',
alias: 'test.en.async-override',
name: 'Test English Async Override',
weight: -100,
meta: {
culture: 'en-us',
},
js: englishAsyncFactory,
};
const danish: ManifestLocalization = {
type: 'localization',
alias: 'test.da',
@@ -87,10 +117,7 @@ const danishRegional: ManifestLocalization = {
//#endregion
describe('UmbLocalizeController', () => {
umbExtensionsRegistry.register(englishUk);
umbExtensionsRegistry.register(english);
umbExtensionsRegistry.register(danish);
umbExtensionsRegistry.register(danishRegional);
umbExtensionsRegistry.registerMany([englishUk, english, danish, danishRegional]);
let registry: UmbLocalizationRegistry;
@@ -102,6 +129,16 @@ describe('UmbLocalizeController', () => {
afterEach(() => {
registry.localizations.clear();
registry.destroy();
});
it('should register into the localization manager', async () => {
expect(registry.localizations.size).to.equal(2, 'Should have registered the 2 original iso codes (en, en-us)');
// Register an additional language to test the registry.
registry.loadLanguage(danish.meta.culture);
await aTimeout(0);
expect(registry.localizations.size).to.equal(3, 'Should have registered the 3rd language (da)');
});
it('should set the document language and direction', async () => {
@@ -122,9 +159,48 @@ describe('UmbLocalizeController', () => {
await aTimeout(0);
const current = registry.localizations.get(english.meta.culture);
expect(current).to.have.property('general_close', 'Close 2');
expect(current).to.have.property('general_logout', 'Log out');
const current = registry.localizations.get(englishOverride.meta.culture);
expect(current).to.have.property(
'general_close',
'Close 2',
'Should have overridden the close (from first language)',
);
expect(current).to.have.property('general_logout', 'Log out', 'Should not have overridden the logout');
umbExtensionsRegistry.unregister(englishOverride.alias);
});
it('should load translations based on weight (lowest weight overrides)', async () => {
// set weight to 200, so it will not override the existing translation
const englishOverrideLowWeight = { ...englishOverride, weight: 200 } satisfies ManifestLocalization;
umbExtensionsRegistry.register(englishOverrideLowWeight);
await aTimeout(0);
let current = registry.localizations.get(englishOverrideLowWeight.meta.culture);
expect(current).to.have.property(
'general_close',
'Close',
'Should not have overridden the close (from first language)',
);
expect(current).to.have.property('general_overridden', 'Overridden', 'Should be able to register its own keys');
// Now register a new async override with a lower weight
umbExtensionsRegistry.register(englishAsyncOverride);
await aTimeout(200); // Wait for the async override to load
current = registry.localizations.get(englishOverrideLowWeight.meta.culture);
expect(current).to.have.property(
'general_close',
'Close Async',
'(async) Should have overridden the close (from first language)',
);
expect(current).to.have.property(
'general_overridden',
'Overridden Async',
'(async) Should have overridden the overridden',
);
umbExtensionsRegistry.unregister(englishOverrideLowWeight.alias);
umbExtensionsRegistry.unregister(englishAsyncOverride.alias);
});
it('should be able to switch to the fallback language', async () => {

View File

@@ -1,22 +1,30 @@
import type { ManifestLocalization } from '../extensions/localization.extension.js';
import {
type UmbLocalizationSetBase,
type UmbLocalizationDictionary,
type UmbLocalizationFlatDictionary,
UMB_DEFAULT_LOCALIZATION_CULTURE,
} from '@umbraco-cms/backoffice/localization-api';
import { umbLocalizationManager } from '@umbraco-cms/backoffice/localization-api';
import type { UmbBackofficeExtensionRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import { combineLatest } from '@umbraco-cms/backoffice/external/rxjs';
catchError,
distinctUntilChanged,
filter,
from,
map,
of,
switchMap,
} from '@umbraco-cms/backoffice/external/rxjs';
import { hasDefaultExport, loadManifestPlainJs } from '@umbraco-cms/backoffice/extension-api';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { umbLocalizationManager, UMB_DEFAULT_LOCALIZATION_CULTURE } from '@umbraco-cms/backoffice/localization-api';
import { UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import type { UmbBackofficeExtensionRegistry } from '@umbraco-cms/backoffice/extension-registry';
import type {
UmbLocalizationSetBase,
UmbLocalizationDictionary,
UmbLocalizationFlatDictionary,
} from '@umbraco-cms/backoffice/localization-api';
import type { Subscription } from '@umbraco-cms/backoffice/external/rxjs';
/**
*
* @param innerDictionary
* @param dictionaryName
* @param dictionary
* Adds or updates a dictionary in the inner dictionary.
* @param {UmbLocalizationFlatDictionary} innerDictionary The inner dictionary to add or update the dictionary in.
* @param {string} dictionaryName The name of the dictionary to add or update.
* @param {UmbLocalizationDictionary['value']} dictionary The dictionary to add or update.
*/
function addOrUpdateDictionary(
innerDictionary: UmbLocalizationFlatDictionary,
@@ -34,63 +42,85 @@ export class UmbLocalizationRegistry {
);
readonly currentLanguage = this.#currentLanguage.asObservable();
#loadedExtAliases: Array<string> = [];
/**
* Get the current registered translations.
* @returns {Map<string, UmbLocalizationSetBase>} Returns the registered translations
*/
get localizations() {
get localizations(): Map<string, UmbLocalizationSetBase> {
return umbLocalizationManager.localizations;
}
#subscription: Subscription;
constructor(extensionRegistry: UmbBackofficeExtensionRegistry) {
combineLatest([this.currentLanguage, extensionRegistry.byType('localization')]).subscribe(
async ([currentLanguage, extensions]) => {
const locale = new Intl.Locale(currentLanguage);
const currentLanguageExtensions = extensions.filter(
(ext) =>
ext.meta.culture.toLowerCase() === locale.baseName.toLowerCase() ||
ext.meta.culture.toLowerCase() === locale.language.toLowerCase(),
);
// Store the locale in a variable to use when setting the document language and direction
let locale: Intl.Locale | undefined = undefined;
// If there are no extensions for the current language, return early
if (!currentLanguageExtensions.length) return;
// Register the new translations only if they have not been registered before
const diff = currentLanguageExtensions.filter((ext) => !this.#loadedExtAliases.includes(ext.alias));
// Load all localizations
const translations = await Promise.all(currentLanguageExtensions.map(this.#loadExtension));
// If there are no translations, return early
if (!translations.length) return;
if (diff.length) {
const filteredTranslations = translations.filter((t) =>
diff.some((ext) => ext.meta.culture.toLowerCase() === t.$code),
this.#subscription = this.currentLanguage
.pipe(
// Ensure the current language is not empty
filter((currentLanguage) => !!currentLanguage),
// Use distinctUntilChanged to avoid unnecessary re-renders when the language hasn't changed
distinctUntilChanged(),
// Switch to the extensions registry to get the current language and the extensions for that language
// Note: This also cancels the previous subscription if the language changes
switchMap((currentLanguage) => {
return extensionRegistry.byType('localization').pipe(
// Filter the extensions to only those that match the current language
map((extensions) => {
locale = new Intl.Locale(currentLanguage);
return extensions.filter(
(ext) =>
ext.meta.culture.toLowerCase() === locale!.baseName.toLowerCase() ||
ext.meta.culture.toLowerCase() === locale!.language.toLowerCase(),
);
}),
);
umbLocalizationManager.registerManyLocalizations(filteredTranslations);
}
}),
// Ensure we only process extensions that are registered
filter((extensions) => extensions.length > 0),
// Ensure we only process extensions that have not been loaded before
distinctUntilChanged((prev, curr) => {
const prevAliases = prev.map((ext) => ext.alias).sort();
const currAliases = curr.map((ext) => ext.alias).sort();
return this.#arraysEqual(prevAliases, currAliases);
}),
// With switchMap, if a new language is selected before the previous translations finish loading,
// the previous promise is canceled (unsubscribed), and only the latest one is processed.
// This prevents race conditions and stale state.
switchMap((extensions) =>
from(
(async () => {
// Load all localizations
const translations = await Promise.all(extensions.map(this.#loadExtension));
// Set the document language
const newLang = locale.baseName.toLowerCase();
if (document.documentElement.lang.toLowerCase() !== newLang) {
document.documentElement.lang = newLang;
}
// If there are no translations, return early
if (!translations.length) return;
// Set the document direction to the direction of the primary language
const newDir = translations[0].$dir ?? 'ltr';
if (document.documentElement.dir !== newDir) {
document.documentElement.dir = newDir;
}
},
);
// Sort translations by their original extension weight (highest-to-lowest)
// This ensures that the translations with the lowest weight override the others
translations.sort((a, b) => b.$weight - a.$weight);
// Load the translations into the localization manager
umbLocalizationManager.registerManyLocalizations(translations);
// Set the browser language and direction based on the translations
this.#setBrowserLanguage(locale!, translations);
})(),
),
),
// Catch any errors that occur while loading the translations
// This is important to ensure that the observable does not error out and stop the subscription
catchError((error) => {
console.error('Error loading translations:', error);
return of([]);
}),
)
// Subscribe to the observable to trigger the loading of translations
.subscribe();
}
#loadExtension = async (extension: ManifestLocalization) => {
this.#loadedExtAliases.push(extension.alias);
const innerDictionary: UmbLocalizationFlatDictionary = {};
// If extension contains a dictionary, add it to the inner dictionary.
@@ -115,16 +145,63 @@ export class UmbLocalizationRegistry {
return {
$code: extension.meta.culture.toLowerCase(),
$dir: extension.meta.direction ?? 'ltr',
$weight: extension.weight ?? 100,
...innerDictionary,
} satisfies UmbLocalizationSetBase;
} satisfies UmbLocalizationSetBase & { $weight: number };
};
#setBrowserLanguage(locale: Intl.Locale, translations: UmbLocalizationSetBase[]) {
// Set the document language
const newLang = locale.baseName.toLowerCase();
if (document.documentElement.lang.toLowerCase() !== newLang) {
document.documentElement.lang = newLang;
}
// We need to find the direction of the new language, so we look for the best match
// If the new language is not found, we default to 'ltr'
const reverseTranslations = translations.slice().reverse();
// Look for a direct match first
const directMatch = reverseTranslations.find((t) => t.$code.toLowerCase() === newLang);
if (directMatch) {
document.documentElement.dir = directMatch.$dir;
return;
}
// If no direct match, look for a match with the language code only
const langOnlyDirectMatch = reverseTranslations.find(
(t) => t.$code.toLowerCase() === locale.language.toLowerCase(),
);
if (langOnlyDirectMatch) {
document.documentElement.dir = langOnlyDirectMatch.$dir;
return;
}
// If no match is found, default to 'ltr'
if (document.documentElement.dir !== 'ltr') {
document.documentElement.dir = 'ltr';
}
}
#arraysEqual(a: string[], b: string[]) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
/**
* Load a language from the extension registry.
* @param {string} locale The locale to load.
*/
loadLanguage(locale: string) {
this.#currentLanguage.setValue(locale.toLowerCase());
const canonicalLocale = Intl.getCanonicalLocales(locale)[0];
this.#currentLanguage.setValue(canonicalLocale);
}
destroy() {
this.#subscription.unsubscribe();
}
}