From 8a22f243f863a7bfd82105c46083b97aa5c3c0fd Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 3 Jun 2025 18:40:28 +0200 Subject: [PATCH] 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 --- .../src/external/rxjs/index.ts | 28 +-- .../localization/localize.element.test.ts | 116 ++++++++++- .../registry/localization.registry.test.ts | 90 +++++++- .../registry/localization.registry.ts | 193 ++++++++++++------ 4 files changed, 344 insertions(+), 83 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/external/rxjs/index.ts b/src/Umbraco.Web.UI.Client/src/external/rxjs/index.ts index 24fa9d1a5a..36d1337320 100644 --- a/src/Umbraco.Web.UI.Client/src/external/rxjs/index.ts +++ b/src/Umbraco.Web.UI.Client/src/external/rxjs/index.ts @@ -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'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.test.ts index 66f5487a1d..74a09003e7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.test.ts @@ -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, 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); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.test.ts index 62e7959a9a..3aad872415 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.test.ts @@ -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 () => { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts b/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts index ef76a9af67..a51d3651b8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts @@ -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 = []; - /** * Get the current registered translations. * @returns {Map} Returns the registered translations */ - get localizations() { + get localizations(): Map { 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(); } }