From e149123fa35b26c1f36735653010d9bf372d312b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 14:40:58 +0200 Subject: [PATCH] core-app-api: initial AppTranslation refactor with failing concurrency test Signed-off-by: Patrik Oldsberg --- .../AppTranslationImpl.test.ts | 357 +++++++++++++----- .../AppTranslationApi/AppTranslationImpl.ts | 353 +++++++++++------ 2 files changed, 495 insertions(+), 215 deletions(-) diff --git a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.test.ts b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.test.ts index 5b7304d36b..4ea1f26329 100644 --- a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.test.ts +++ b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.test.ts @@ -19,123 +19,286 @@ import { createTranslationRef, createTranslationResource, } from '@backstage/core-plugin-api/alpha'; -import { AppTranslationApiImpl } from './AppTranslationImpl'; -import i18next from 'i18next'; +import { Observable } from '@backstage/types'; +import { + AppTranslationApiImpl, + TranslationSnapshot, +} from './AppTranslationImpl'; -jest.mock('i18next', () => ({ - createInstance: jest.fn(() => ({ - use: jest.fn(() => ({ - init: jest.fn(), - use: jest.fn(), - addResourceBundle: jest.fn(), - reloadResources: jest.fn(), - emit: jest.fn(), - services: { - languageUtils: { - getFallbackCodes: jest.fn().mockReturnValue(['en']), - }, +const plainRef = createTranslationRef({ + id: 'plain', + messages: { foo: 'Foo' }, +}); + +const resourceRef = createTranslationRef({ + id: 'resource', + messages: { + foo: 'Foo', + bar: 'Bar', + }, + translations: { + sv: () => Promise.resolve({ default: { foo: 'Föö', bar: null } }), + }, +}); + +function waitForNext( + observable: Observable, + predicate?: (result: T) => boolean, +): Promise { + return new Promise((resolve, reject) => { + const sub = observable.subscribe({ + next(next) { + if (!predicate || predicate(next)) { + resolve(next); + sub.unsubscribe(); + } }, - options: { - fallbackLng: 'en', - supportedLngs: ['zh', 'en'], + error(err) { + reject(err); + sub.unsubscribe(); }, - })), - })), -})); + complete() { + reject(new Error('Observable completed without emitting')); + sub.unsubscribe(); + }, + }); + }); +} + +function assertReady( + snapshot: TranslationSnapshot, +) { + if (!snapshot.ready) { + throw new Error('snapshot not ready'); + } + return snapshot; +} describe('AppTranslationApiImpl', () => { afterEach(() => { jest.clearAllMocks(); }); - it('should create i18n instance and init with options', () => { - const i18nMock = i18next.createInstance() as any; - const useReturnMock = i18nMock.use(); - jest.spyOn(i18nMock, 'use').mockReturnValue(useReturnMock); - jest.spyOn(i18next, 'createInstance').mockReturnValue(i18nMock); + it('should get a translation snapshot', () => { + const translationApi = AppTranslationApiImpl.create(); + expect(translationApi.getAvailableLanguages()).toEqual(['en']); - const instance = AppTranslationApiImpl.create({ - supportedLanguages: ['en', 'zh'], - }); - - expect(i18next.createInstance).toHaveBeenCalled(); - expect(i18nMock.use).toHaveBeenCalled(); - expect(useReturnMock.init).toHaveBeenCalledWith({ - fallbackLng: 'en', - supportedLngs: ['en', 'zh'], - interpolation: { - escapeValue: false, - }, - react: { - bindI18n: 'loaded languageChanged', - }, - }); - expect(instance).toBeInstanceOf(AppTranslationApiImpl); + const snapshot = assertReady(translationApi.getTranslation(plainRef)); + expect(snapshot.t('foo')).toBe('Foo'); }); - it('should init messages correctly', () => { - const addMessagesMock = jest.spyOn( - AppTranslationApiImpl.prototype, - 'addMessages', - ); - const addLazyResourcesMock = jest.spyOn( - AppTranslationApiImpl.prototype, - 'addLazyResources', - ); - const ref = createTranslationRef({ - id: 'ref-id', - messages: { - key: '', - }, + it('should get a translation snapshot for ref with translations', async () => { + const translationApi = AppTranslationApiImpl.create({ + supportedLanguages: ['en', 'sv'], }); + expect(translationApi.getAvailableLanguages()).toEqual(['en', 'sv']); - const overrides = createTranslationMessages({ - ref, - messages: { key: 'value1' }, - }); - const resource = createTranslationResource({ - ref, - translations: { - en: () => - Promise.resolve({ - default: { - key: 'value2', - }, - }), - }, - }); - - AppTranslationApiImpl.create({ - supportedLanguages: ['en'], - resources: [overrides, resource], - }); - - expect(addMessagesMock).toHaveBeenCalledWith(overrides); - expect(addLazyResourcesMock).toHaveBeenCalledWith(resource); + expect(translationApi.getTranslation(resourceRef).ready).toBe(true); + await translationApi.changeLanguage('sv'); + expect(translationApi.getTranslation(resourceRef).ready).toBe(false); }); - it('should useResources correctly', () => { - const addLazyResourcesMock = jest.spyOn( - AppTranslationApiImpl.prototype, - 'addLazyResources', + it('should wait for translations to be loaded', async () => { + const translationApi = AppTranslationApiImpl.create({ + supportedLanguages: ['en', 'sv'], + }); + expect(translationApi.getTranslation(resourceRef).ready).toBe(true); + await translationApi.changeLanguage('sv'); + expect(translationApi.getTranslation(resourceRef).ready).toBe(false); + + const snapshot = assertReady( + await waitForNext(translationApi.translation$(resourceRef), s => s.ready), ); + expect(snapshot.t('foo')).toBe('Föö'); + }); - const ref = createTranslationRef({ - id: 'ref-id', - messages: { - key1: 'value1', - }, - translations: { - en: () => Promise.resolve({ default: { key1: 'value2' } }), - }, + it('should create an instance with message overrides', () => { + const translationApi = AppTranslationApiImpl.create({ + resources: [ + createTranslationMessages({ + ref: plainRef, + messages: { foo: 'Bar' }, + }), + ], + }); + const snapshot = assertReady(translationApi.getTranslation(plainRef)); + expect(snapshot.t('foo')).toBe('Bar'); + }); + + it('should create an instance and ignore null overrides', () => { + const translationApi = AppTranslationApiImpl.create({ + resources: [ + createTranslationMessages({ + ref: plainRef, + messages: { foo: null }, + }), + ], }); - const instance = AppTranslationApiImpl.create({ - supportedLanguages: ['en'], - }); - instance.addResource(ref); + const snapshot = assertReady(translationApi.getTranslation(plainRef)); + expect(snapshot.t('foo')).toBe('Foo'); + }); - expect(addLazyResourcesMock).toHaveBeenCalledTimes(1); - expect(addLazyResourcesMock.mock.calls[0][0].id).toBe('ref-id'); + it('should create an instance with translation resources', async () => { + const translationApi = AppTranslationApiImpl.create({ + supportedLanguages: ['en', 'sv'], + resources: [ + createTranslationResource({ + ref: plainRef, + translations: { + sv: () => Promise.resolve({ default: { foo: 'Föö' } }), + }, + }), + ], + }); + + await translationApi.changeLanguage('sv'); + + expect(translationApi.getTranslation(plainRef).ready).toBe(false); + + const snapshot = assertReady( + await waitForNext(translationApi.translation$(resourceRef), s => s.ready), + ); + expect(snapshot.t('foo')).toBe('Föö'); + }); + + it('should wait for default language translations to be loaded', async () => { + const translationApi = AppTranslationApiImpl.create({ + resources: [ + createTranslationResource({ + ref: plainRef, + translations: { + en: () => Promise.resolve({ default: { foo: 'OtherFoo' } }), + }, + }), + ], + }); + + const snapshot = assertReady( + await waitForNext(translationApi.translation$(plainRef), s => s.ready), + ); + expect(snapshot.t('foo')).toBe('OtherFoo'); + }); + + it('should prefer the last loaded resource', async () => { + const translationApi = AppTranslationApiImpl.create({ + supportedLanguages: ['en', 'sv'], + resources: [ + createTranslationResource({ + ref: resourceRef, + translations: { + // Duplicate translations fully override previous entries, so the foo value here is ignored + sv: () => Promise.resolve({ default: { foo: 'Föö', bar: 'Bår' } }), + }, + }), + createTranslationResource({ + ref: resourceRef, + translations: { + sv: () => + Promise.resolve({ + default: createTranslationMessages({ + ref: resourceRef, + messages: { foo: null, bar: 'Bär' }, + }), + }), + }, + }), + ], + }); + + await translationApi.changeLanguage('sv'); + + const snapshot = assertReady( + await waitForNext(translationApi.translation$(resourceRef), s => s.ready), + ); + expect(snapshot.t('foo')).toBe('Foo'); + expect(snapshot.t('bar')).toBe('Bär'); + }); + + it('should refuse switch on unsupported languages', async () => { + const translationApi = AppTranslationApiImpl.create({ + supportedLanguages: ['en', 'sv'], + }); + expect(translationApi.getAvailableLanguages()).toEqual(['en', 'sv']); + await translationApi.changeLanguage('sv'); + await expect(translationApi.changeLanguage('de')).rejects.toThrow( + "Failed to change language to 'de', available languages are 'en', 'sv", + ); + }); + + it('should forward loading errors', async () => { + const translationApi = AppTranslationApiImpl.create({ + resources: [ + createTranslationResource({ + ref: plainRef, + translations: { en: () => Promise.reject(new Error('NOPE')) }, + }), + ], + }); + + await expect( + waitForNext(translationApi.translation$(plainRef), s => s.ready), + ).rejects.toThrow('NOPE'); + }); + + it('should only call the loader once', async () => { + const loader = jest + .fn() + .mockResolvedValue({ default: { foo: 'OtherFoo' } }); + const translationApi = AppTranslationApiImpl.create({ + resources: [ + createTranslationResource({ + ref: plainRef, + translations: { en: loader }, + }), + ], + }); + + const observable = translationApi.translation$(plainRef); + + const snapshots = await Promise.all([ + waitForNext(observable, s => s.ready), + waitForNext(observable, s => s.ready), + waitForNext(translationApi.translation$(plainRef), s => s.ready), + ]); + const [snapshot1, snapshot2, snapshot3] = snapshots.map(assertReady); + expect(snapshot1.t('foo')).toBe('OtherFoo'); + expect(snapshot2.t('foo')).toBe('OtherFoo'); + expect(snapshot3.t('foo')).toBe('OtherFoo'); + + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('should handle interrupted loads gracefully', async () => { + const delayedLoader = (msg: string) => () => + new Promise<{ default: { foo: string } }>(resolve => + setTimeout(() => resolve({ default: { foo: msg } }), 100), + ); + const translationApi = AppTranslationApiImpl.create({ + supportedLanguages: ['en', 'sv', 'no'], + resources: [ + createTranslationResource({ + ref: plainRef, + translations: { + en: delayedLoader('foo'), + sv: delayedLoader('Föö'), + no: delayedLoader('Føø'), + }, + }), + ], + }); + + // Wait for i18n to be initialized first + const enSnapshot = assertReady( + await waitForNext(translationApi.translation$(plainRef), s => s.ready), + ); + expect(enSnapshot.t('foo')).toBe('foo'); + + translationApi.changeLanguage('sv'); + const nextPromise = waitForNext(translationApi.translation$(plainRef)); + translationApi.changeLanguage('no'); + + const snapshot = assertReady(await nextPromise); + expect(snapshot.t('foo')).toBe('Føø'); }); }); diff --git a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts index c55c8bd7e8..0a7d1b8983 100644 --- a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts +++ b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts @@ -20,21 +20,30 @@ import { TranslationRef, TranslationResource, } from '@backstage/core-plugin-api/alpha'; -import i18next, { type i18n } from 'i18next'; -import { initReactI18next } from 'react-i18next'; -import LanguageDetector from 'i18next-browser-languagedetector'; +import { + BackendModule, + createInstance as createI18n, + type i18n, +} from 'i18next'; +import ObservableImpl from 'zen-observable'; // Internal import to avoid code duplication, this will lead to duplication in build output // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { toInternalTranslationResource } from '../../../../../core-plugin-api/src/translation/TranslationResource'; +import { + toInternalTranslationResource, + InternalTranslationResourceLoader, +} from '../../../../../core-plugin-api/src/translation/TranslationResource'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { toInternalTranslationRef } from '../../../../../core-plugin-api/src/translation/TranslationRef'; +import { + toInternalTranslationRef, + InternalTranslationRef, +} from '../../../../../core-plugin-api/src/translation/TranslationRef'; +import { Observable } from '@backstage/types'; const DEFAULT_LANGUAGE = 'en'; /** @alpha */ export type ExperimentalI18n = { - fallbackLanguage?: string | string[]; supportedLanguages?: string[]; resources?: Array; }; @@ -49,140 +58,248 @@ function removeNulls( ); } +/** + * A wrapper around a i18next plugin that helps us lazy load resources that have been + * registered in the app or provided through translation refs. + * + * Since all resources are registered before use it is safe to just look at the + * existing resources when loading a namespace + language tuple. + */ +class LazyResources { + #seen = new Set(); + #loaders = new Map(); + + addResource(resource: TranslationResource) { + if (this.#seen.has(resource)) { + return; + } + this.#seen.add(resource); + const internalResource = toInternalTranslationResource(resource); + for (const entry of internalResource.resources) { + const key = this.#getLoaderKey(entry.language, internalResource.id); + + // First loader to register wins, this means that resources registered in the app + // have priority over default resource from translation refs + if (!this.#loaders.has(key)) { + this.#loaders.set(key, entry.loader); + } + } + } + + hasResource(lng: string, ns: string) { + return this.#loaders.has(this.#getLoaderKey(lng, ns)); + } + + async #loadResource(lng: string, ns: string) { + const loader = this.#loaders.get(this.#getLoaderKey(lng, ns)); + if (!loader) { + return undefined; + } + + return loader().then(result => removeNulls(result.messages)); + } + + #getLoaderKey(lng: string, ns: string) { + return `${lng}/${ns}`; + } + + plugin: BackendModule = { + type: 'backend', + init() {}, + read: (lng, ns) => this.#loadResource(lng, ns), + save() {}, + create() {}, + }; +} + +/** @alpha */ +export interface TranslationOptions { + /* no options supported for now */ +} + +export type TranslationSnapshot = + + | { ready: false } + | { + ready: true; + t( + key: TKey, + options?: TranslationOptions, + ): TMessages[TKey]; + }; + /** @alpha */ export class AppTranslationApiImpl implements AppTranslationApi { static create(options?: ExperimentalI18n) { - const i18n = i18next.createInstance().use(initReactI18next); - - i18n.use(LanguageDetector); - - i18n.init({ - fallbackLng: options?.fallbackLanguage || DEFAULT_LANGUAGE, - supportedLngs: options?.supportedLanguages || [DEFAULT_LANGUAGE], + const languages = options?.supportedLanguages || [DEFAULT_LANGUAGE]; + if (!languages.includes(DEFAULT_LANGUAGE)) { + throw new Error(`Supported languages must include '${DEFAULT_LANGUAGE}'`); + } + const lazyResources = new LazyResources(); + const i18n = createI18n({ + fallbackLng: DEFAULT_LANGUAGE, + supportedLngs: languages, interpolation: { escapeValue: false, }, - react: { - bindI18n: 'loaded languageChanged', - }, - }); + ns: [], + defaultNS: false, + fallbackNS: false, + }).use(lazyResources.plugin); - return new AppTranslationApiImpl(i18n, options); - } + i18n.init(); - private readonly cache = new Set(); - private readonly lazyCache = new Map>(); - - getI18n() { - return this.i18n; - } - - initMessages(options?: ExperimentalI18n) { - for (const resource of options?.resources || []) { + const resources = options?.resources || []; + // Iterate in reverse, giving higher priority to resources registered later + for (let i = resources.length - 1; i >= 0; i--) { + const resource = resources[i]; if (resource.$$type === '@backstage/TranslationResource') { - this.addLazyResources(resource); + lazyResources.addResource(resource); } else if (resource.$$type === '@backstage/TranslationMessages') { // Overrides for default messages, created with createTranslationMessages and installed via app - this.addMessages(resource); + i18n.addResourceBundle( + DEFAULT_LANGUAGE, + resource.id, + removeNulls(resource.messages), + true, + false, + ); } } + + return new AppTranslationApiImpl(i18n, lazyResources, languages); } - addResource(translationRef: TranslationRef): void { - const internalRef = toInternalTranslationRef(translationRef); - const defaultResource = internalRef.getDefaultResource(); - if (defaultResource) { - this.addLazyResources(defaultResource); - } + #i18n: i18n; + #lazyResources: LazyResources; + #language: string; + #languages: string[]; + + private constructor( + i18n: i18n, + lazyResources: LazyResources, + languages: string[], + ) { + this.#i18n = i18n; + this.#lazyResources = lazyResources; + this.#language = DEFAULT_LANGUAGE; + this.#languages = languages; } - addMessages(messages: TranslationMessages) { - if (this.cache.has(messages.id)) { - return; - } - this.cache.add(messages.id); - this.i18n.addResourceBundle( - DEFAULT_LANGUAGE, - messages.id, - removeNulls(messages.messages), - true, - false, - ); + getAvailableLanguages(): string[] { + return this.#languages.slice(); } - addLazyResources(resource: TranslationResource) { - let cache = this.lazyCache.get(resource.id); - - if (!cache) { - cache = new Set(); - this.lazyCache.set(resource.id, cache); - } - - const { - language: currentLanguage, - services, - options, - addResourceBundle, - reloadResources, - } = this.i18n; - - if (cache.has(currentLanguage)) { - return; - } - - const internalResource = toInternalTranslationResource(resource); - const namespace = internalResource.id; - - Promise.allSettled((options.supportedLngs || []).map(addLanguage)).then( - results => { - if (results.some(result => result.status === 'fulfilled')) { - this.i18n.emit('loaded'); - } - }, - ); - - async function addLanguage(language: string) { - if (cache!.has(language)) { - return; - } - - cache!.add(language); - - let loadBackend: Promise | undefined; - - if (services.backendConnector?.backend) { - loadBackend = reloadResources([language], [namespace]); - } - - const loadLazyResources = internalResource.resources.find( - entry => entry.language === language, - )?.loader; - - if (!loadLazyResources) { - await loadBackend; - return; - } - - const [result] = await Promise.allSettled([ - loadLazyResources(), - loadBackend, - ]); - - if (result.status === 'rejected') { - throw result.reason; - } - - addResourceBundle( - language, - namespace, - result.value.messages, - true, - false, + async changeLanguage(language?: string): Promise { + const lng = language ?? DEFAULT_LANGUAGE; + if (lng && !this.#languages.includes(lng)) { + throw new Error( + `Failed to change language to '${lng}', available languages are '${this.#languages.join( + "', '", + )}'`, ); } + this.#language = lng; + await this.#i18n.changeLanguage(lng); } - private constructor(private readonly i18n: i18n, options?: ExperimentalI18n) { - this.initMessages(options); + getTranslation( + translationRef: TranslationRef, + ): TranslationSnapshot { + const internalRef = toInternalTranslationRef(translationRef); + + this.#registerDefaultResource(internalRef); + + return this.#createSnapshot(internalRef); + } + + translation$( + translationRef: TranslationRef, + ): Observable> { + const internalRef = toInternalTranslationRef(translationRef); + + this.#registerDefaultResource(internalRef); + + return new ObservableImpl>(subscriber => { + let loadTicket = {}; // To check for stale loads + let lastSnapshotWasReady = false; + + const loadResource = () => { + loadTicket = {}; + const ticket = loadTicket; + this.#i18n.loadNamespaces(internalRef.id, error => { + if (ticket !== loadTicket) { + return; + } + if (error) { + subscriber.error(Array.isArray(error) ? error[0] : error); + } else { + const snapshot = this.#createSnapshot(internalRef); + if (snapshot.ready || lastSnapshotWasReady) { + lastSnapshotWasReady = snapshot.ready; + subscriber.next(snapshot); + } + } + }); + }; + + const onChange = () => { + const snapshot = this.#createSnapshot(internalRef); + if (lastSnapshotWasReady && !snapshot.ready) { + subscriber.next(snapshot); + } + + if (!snapshot.ready) { + loadResource(); + } + }; + + this.#i18n.on('initialized', onChange); + this.#i18n.on('languageChanged', onChange); + + if (this.#needsToLoadResource(internalRef)) { + loadResource(); + } + + return () => { + this.#i18n.off('initialized', onChange); + this.#i18n.off('languageChanged', onChange); + }; + }); + } + + #createSnapshot( + internalRef: InternalTranslationRef, + ): TranslationSnapshot { + if (this.#needsToLoadResource(internalRef)) { + return { ready: false }; + } + + const t = this.#i18n.getFixedT(null, internalRef.id); + const defaultMessages = internalRef.getDefaultMessages() as TMessages; + + return { + ready: true, + t: (key, options) => { + return t(key as string, { + ...options, + defaultValue: defaultMessages[key], + }); + }, + }; + } + + #needsToLoadResource({ id }: InternalTranslationRef): boolean { + if (!this.#lazyResources.hasResource(this.#language, id)) { + return false; + } + return !this.#i18n.hasResourceBundle(this.#language, id); + } + + #registerDefaultResource(internalRef: InternalTranslationRef): void { + const defaultResource = internalRef.getDefaultResource(); + if (defaultResource) { + this.#lazyResources.addResource(defaultResource); + } } }