From dd7c5a2d85d6dcccaf642a0f99399d85f479ef02 Mon Sep 17 00:00:00 2001 From: Denis Valcke Date: Thu, 9 Jul 2026 08:49:05 +0200 Subject: [PATCH 1/5] fix(i18n): the multi-translation.loader will now prevent storing failed translation paths --- .../multi-translation.loader.spec.ts | 118 ++++++++++++++++++ .../multi-translation.loader.ts | 17 ++- 2 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 libs/angular/i18n/src/lib/loader/multi-translation/multi-translation.loader.spec.ts diff --git a/libs/angular/i18n/src/lib/loader/multi-translation/multi-translation.loader.spec.ts b/libs/angular/i18n/src/lib/loader/multi-translation/multi-translation.loader.spec.ts new file mode 100644 index 0000000..48a2818 --- /dev/null +++ b/libs/angular/i18n/src/lib/loader/multi-translation/multi-translation.loader.spec.ts @@ -0,0 +1,118 @@ +import { HttpBackend } from '@angular/common/http'; +import { Injector, runInInjectionContext } from '@angular/core'; +import { of, throwError } from 'rxjs'; + +import { NgxI18nLoadingService } from '../../services'; +import { + NgxI18nClientToken, + NgxI18nConfigurationToken, + NgxI18nTranslationPathsToken, +} from '../../tokens'; + +import { NgxI18nMultiTranslationHttpLoader } from './multi-translation.loader'; + +describe('NgxI18nMultiTranslationHttpLoader', () => { + const createLoader = ( + loadingService: NgxI18nLoadingService, + client: { getTranslations: jest.Mock }, + paths: string[] + ): NgxI18nMultiTranslationHttpLoader => { + const injector = Injector.create({ + providers: [ + { provide: NgxI18nLoadingService, useValue: loadingService }, + { provide: NgxI18nClientToken, useValue: client }, + { provide: NgxI18nConfigurationToken, useValue: {} }, + { provide: HttpBackend, useValue: {} }, + { provide: NgxI18nTranslationPathsToken, useValue: paths }, + ], + }); + + return runInInjectionContext(injector, () => new NgxI18nMultiTranslationHttpLoader()); + }; + + describe('getTranslation', () => { + it( + 'should not persist a failed fetch to the translations store, so it can be retried when the next loader is called that has overlap for that path.', + (done) => { + const failingPath = './assets/i18n/failing-path/'; + const translations = { foo: 'bar' }; + + /** + * Denis: NgxI18nLoadingService is providedIn: 'root'. So in an actual app, a single instance + * is shared between the root-scoped loader and every lazy route's own loader (created via + * provideWithTranslations), which is what makes this cross-loader issues possible. + */ + const loadingService = new NgxI18nLoadingService(); + + const failingClient = { + getTranslations: jest + .fn() + .mockReturnValue(throwError(() => new Error('network error'))), + }; + const firstLoader = createLoader(loadingService, failingClient, [failingPath]); + + firstLoader.getTranslation('nl').subscribe(() => { + // Denis: the failed path should not be cached as "loaded". + expect(loadingService.getTranslations()[failingPath]).toBeUndefined(); + + const recoveredClient = { + getTranslations: jest.fn().mockReturnValue(of(translations)), + }; + const secondLoader = createLoader(loadingService, recoveredClient, [ + failingPath, + './assets/i18n/other-path/', + ]); + + secondLoader.getTranslation('nl').subscribe((result) => { + expect(recoveredClient.getTranslations).toHaveBeenCalledWith( + failingPath, + 'nl', + undefined + ); + expect(result).toEqual(translations); + + done(); + }); + }); + } + ); + + it( + 'should persist a successful fetch to the translations store so it is reused by a later loader for an overlapping path.', + (done) => { + const path = './assets/i18n/working-path/'; + const translations = { foo: 'bar' }; + + const loadingService = new NgxI18nLoadingService(); + + const client = { + getTranslations: jest.fn().mockReturnValue(of(translations)), + }; + const firstLoader = createLoader(loadingService, client, [path]); + + firstLoader.getTranslation('nl').subscribe(() => { + expect(loadingService.getTranslations()[path]).toEqual(translations); + + const secondClient = { + getTranslations: jest.fn().mockReturnValue(of({})), + }; + const secondLoader = createLoader(loadingService, secondClient, [ + path, + './assets/i18n/other-path/', + ]); + + secondLoader.getTranslation('nl').subscribe(() => { + // Denis: the already-loaded path should come from the store, not a fresh fetch. + expect(secondClient.getTranslations).not.toHaveBeenCalledWith( + path, + 'nl', + undefined + ); + + done(); + }); + }); + } + ); + }); +}); diff --git a/libs/angular/i18n/src/lib/loader/multi-translation/multi-translation.loader.ts b/libs/angular/i18n/src/lib/loader/multi-translation/multi-translation.loader.ts index d9995e8..114101f 100644 --- a/libs/angular/i18n/src/lib/loader/multi-translation/multi-translation.loader.ts +++ b/libs/angular/i18n/src/lib/loader/multi-translation/multi-translation.loader.ts @@ -64,6 +64,7 @@ export class NgxI18nMultiTranslationHttpLoader implements TranslateLoader { translations: availableTranslations[path], fromStore, path, + failed: false, }); } else { // Iben: If the translations aren't available in the store, we fetch them from the server @@ -84,6 +85,7 @@ export class NgxI18nMultiTranslationHttpLoader implements TranslateLoader { translations, path, fromStore, + failed: false, }; }), // Iben: In case the translation is not loaded, we log an error @@ -99,11 +101,16 @@ export class NgxI18nMultiTranslationHttpLoader implements TranslateLoader { this.translationLoadingService.markTranslationsLoadedAsFailed(); } - // Iben: Return a translation loaded object so the translations service isn't broken + /** + * Denis (9/7/2026): Mark the result as 'failed' so it isn't persisted to the translations store. + * This will prevent it from being "permanently" treated as something that's already loaded. + * In doing so, a retry can still occur. + */ return of({ translations: {}, path, fromStore, + failed: true, }); }) ); @@ -115,10 +122,14 @@ export class NgxI18nMultiTranslationHttpLoader implements TranslateLoader { this.translationsPaths.toString(), forkJoin(requestedTranslations).pipe( tap((translations) => { - // Iben: Filter out the newly requested translations, and add them to the loaded translations store + /** + * Iben: Filter out the newly requested translations, and add them to the loaded translations store + * + * Denis (9/7/2026): Failed fetches are excluded so a retry can still happen for those translation paths. + */ this.translationLoadingService.addLoadedTranslations( translations - .filter((translation) => !translation.fromStore) + .filter((translation) => !translation.fromStore && !translation.failed) .reduce((previous, next) => { return { ...previous, From d81a200e2b6e37ea21906d2d4d7287ac66920a57 Mon Sep 17 00:00:00 2001 From: Denis Valcke Date: Thu, 9 Jul 2026 09:17:07 +0200 Subject: [PATCH 2/5] fix(i18n): the i18n.service will now swap translations when new one are actually fetched --- .../lib/services/i18n/i18n.service.spec.ts | 23 ++++++++++++++++--- .../src/lib/services/i18n/i18n.service.ts | 20 ++++++++++++++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/libs/angular/i18n/src/lib/services/i18n/i18n.service.spec.ts b/libs/angular/i18n/src/lib/services/i18n/i18n.service.spec.ts index 6abc1e5..21041cc 100644 --- a/libs/angular/i18n/src/lib/services/i18n/i18n.service.spec.ts +++ b/libs/angular/i18n/src/lib/services/i18n/i18n.service.spec.ts @@ -11,7 +11,10 @@ const translateService: any = { getLangs: jest.fn().mockReturnValue(['nl', 'en']), getFallbackLang: jest.fn().mockReturnValue('nl'), use: jest.fn(), - reloadLang: jest.fn().mockReturnValue(of('nl')), + setTranslation: jest.fn(), + currentLoader: { + getTranslation: jest.fn().mockReturnValue(of({ SOME_KEY: 'some value' })), + }, get: jest.fn().mockReturnValue(of('something')), instant: jest.fn().mockReturnValue('something'), }; @@ -67,11 +70,25 @@ describe('NgxI18nService', () => { }); describe('initI18n', () => { - it('should set the language to use in the translateService & reload', (done) => { + it('should set the language to use in the translateService & fetch the new translations', (done) => { subscriptions.push( service.initI18n('nl').subscribe(() => { expect(translateService.use).toHaveBeenCalledWith('nl'); - expect(translateService.reloadLang).toHaveBeenCalledWith('nl'); + expect(translateService.currentLoader.getTranslation).toHaveBeenCalledWith( + 'nl' + ); + + done(); + }) + ); + }); + + it('should only swap the translations, once they have been fetched', (done) => { + subscriptions.push( + service.initI18n('nl').subscribe(() => { + expect(translateService.setTranslation).toHaveBeenCalledWith('nl', { + SOME_KEY: 'some value', + }); done(); }) diff --git a/libs/angular/i18n/src/lib/services/i18n/i18n.service.ts b/libs/angular/i18n/src/lib/services/i18n/i18n.service.ts index a132833..0a19860 100644 --- a/libs/angular/i18n/src/lib/services/i18n/i18n.service.ts +++ b/libs/angular/i18n/src/lib/services/i18n/i18n.service.ts @@ -1,6 +1,7 @@ import { inject, Injectable } from '@angular/core'; -import { TranslateService } from '@ngx-translate/core'; +import { TranslateService, TranslationObject } from '@ngx-translate/core'; import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; import { NgxI18nAbstractService } from '../../abstracts'; import { NgxI18nRootService } from '../root-i18n/root-i18n.service'; @@ -54,7 +55,22 @@ export class NgxI18nService implements NgxI18nAbstractService { this.translateService.use(this.rootI18nService.currentLanguage); - return this.translateService.reloadLang(language); + /** + * Denis (9/7/2026) + * + * `TranslateService.reloadLang` will delete translations immediately + * when attempting to fetch new ones. Because the currently rendered components all share the same store, + * this creates a short instant where it is left without translations. + * + * By only switching out the translations when they are loaded, we close that gap. + */ + return this.translateService.currentLoader + .getTranslation(language) + .pipe( + tap((translations: TranslationObject) => + this.translateService.setTranslation(language, translations) + ) + ); } /** From 600dd572dc1caacec06c269ecff06e4051da5729 Mon Sep 17 00:00:00 2001 From: Denis Valcke Date: Thu, 9 Jul 2026 09:26:22 +0200 Subject: [PATCH 3/5] fix(i18n): fix formatting issues --- .../multi-translation.loader.spec.ts | 138 +++++++++--------- 1 file changed, 66 insertions(+), 72 deletions(-) diff --git a/libs/angular/i18n/src/lib/loader/multi-translation/multi-translation.loader.spec.ts b/libs/angular/i18n/src/lib/loader/multi-translation/multi-translation.loader.spec.ts index 48a2818..6a383ee 100644 --- a/libs/angular/i18n/src/lib/loader/multi-translation/multi-translation.loader.spec.ts +++ b/libs/angular/i18n/src/lib/loader/multi-translation/multi-translation.loader.spec.ts @@ -31,88 +31,82 @@ describe('NgxI18nMultiTranslationHttpLoader', () => { }; describe('getTranslation', () => { - it( - 'should not persist a failed fetch to the translations store, so it can be retried when the next loader is called that has overlap for that path.', - (done) => { - const failingPath = './assets/i18n/failing-path/'; - const translations = { foo: 'bar' }; - - /** - * Denis: NgxI18nLoadingService is providedIn: 'root'. So in an actual app, a single instance - * is shared between the root-scoped loader and every lazy route's own loader (created via - * provideWithTranslations), which is what makes this cross-loader issues possible. - */ - const loadingService = new NgxI18nLoadingService(); - - const failingClient = { - getTranslations: jest - .fn() - .mockReturnValue(throwError(() => new Error('network error'))), + it('should not persist a failed fetch to the translations store, so it can be retried when the next loader is called that has overlap for that path.', (done) => { + const failingPath = './assets/i18n/failing-path/'; + const translations = { foo: 'bar' }; + + /** + * Denis: NgxI18nLoadingService is providedIn: 'root'. So in an actual app, a single instance + * is shared between the root-scoped loader and every lazy route's own loader (created via + * provideWithTranslations), which is what makes this cross-loader issues possible. + */ + const loadingService = new NgxI18nLoadingService(); + + const failingClient = { + getTranslations: jest + .fn() + .mockReturnValue(throwError(() => new Error('network error'))), + }; + const firstLoader = createLoader(loadingService, failingClient, [failingPath]); + + firstLoader.getTranslation('nl').subscribe(() => { + // Denis: the failed path should not be cached as "loaded". + expect(loadingService.getTranslations()[failingPath]).toBeUndefined(); + + const recoveredClient = { + getTranslations: jest.fn().mockReturnValue(of(translations)), }; - const firstLoader = createLoader(loadingService, failingClient, [failingPath]); - - firstLoader.getTranslation('nl').subscribe(() => { - // Denis: the failed path should not be cached as "loaded". - expect(loadingService.getTranslations()[failingPath]).toBeUndefined(); + const secondLoader = createLoader(loadingService, recoveredClient, [ + failingPath, + './assets/i18n/other-path/', + ]); - const recoveredClient = { - getTranslations: jest.fn().mockReturnValue(of(translations)), - }; - const secondLoader = createLoader(loadingService, recoveredClient, [ + secondLoader.getTranslation('nl').subscribe((result) => { + expect(recoveredClient.getTranslations).toHaveBeenCalledWith( failingPath, - './assets/i18n/other-path/', - ]); - - secondLoader.getTranslation('nl').subscribe((result) => { - expect(recoveredClient.getTranslations).toHaveBeenCalledWith( - failingPath, - 'nl', - undefined - ); - expect(result).toEqual(translations); - - done(); - }); + 'nl', + undefined + ); + expect(result).toEqual(translations); + + done(); }); - } - ); + }); + }); - it( - 'should persist a successful fetch to the translations store so it is reused by a later loader for an overlapping path.', - (done) => { - const path = './assets/i18n/working-path/'; - const translations = { foo: 'bar' }; + it('should persist a successful fetch to the translations store so it is reused by a later loader for an overlapping path.', (done) => { + const path = './assets/i18n/working-path/'; + const translations = { foo: 'bar' }; - const loadingService = new NgxI18nLoadingService(); + const loadingService = new NgxI18nLoadingService(); - const client = { - getTranslations: jest.fn().mockReturnValue(of(translations)), - }; - const firstLoader = createLoader(loadingService, client, [path]); + const client = { + getTranslations: jest.fn().mockReturnValue(of(translations)), + }; + const firstLoader = createLoader(loadingService, client, [path]); - firstLoader.getTranslation('nl').subscribe(() => { - expect(loadingService.getTranslations()[path]).toEqual(translations); + firstLoader.getTranslation('nl').subscribe(() => { + expect(loadingService.getTranslations()[path]).toEqual(translations); - const secondClient = { - getTranslations: jest.fn().mockReturnValue(of({})), - }; - const secondLoader = createLoader(loadingService, secondClient, [ + const secondClient = { + getTranslations: jest.fn().mockReturnValue(of({})), + }; + const secondLoader = createLoader(loadingService, secondClient, [ + path, + './assets/i18n/other-path/', + ]); + + secondLoader.getTranslation('nl').subscribe(() => { + // Denis: the already-loaded path should come from the store, not a fresh fetch. + expect(secondClient.getTranslations).not.toHaveBeenCalledWith( path, - './assets/i18n/other-path/', - ]); - - secondLoader.getTranslation('nl').subscribe(() => { - // Denis: the already-loaded path should come from the store, not a fresh fetch. - expect(secondClient.getTranslations).not.toHaveBeenCalledWith( - path, - 'nl', - undefined - ); - - done(); - }); + 'nl', + undefined + ); + + done(); }); - } - ); + }); + }); }); }); From 473ae29bb68e8ed7ac9bf4b73999c82ff824b8d0 Mon Sep 17 00:00:00 2001 From: Denis Valcke Date: Tue, 14 Jul 2026 11:57:00 +0200 Subject: [PATCH 4/5] feat(apps/opensource): added 2 lazy-loaded libs with scoped translations and a resolver that mocks delay of the page (in lib-2) to test and demo ngx-i18n implementations --- apps/opensource/project.json | 10 ++ apps/opensource/src/app/app.html | 23 +++++ apps/opensource/src/app/app.routes.ts | 10 ++ apps/opensource/src/app/app.scss | 11 +++ apps/opensource/src/app/app.ts | 37 ++++++- .../src/lazy-loaded/lib-1/i18n/assets/en.json | 7 ++ .../src/lazy-loaded/lib-1/i18n/const/index.ts | 1 + .../src/lazy-loaded/lib-1/i18n/index.ts | 1 + .../opensource/src/lazy-loaded/lib-1/index.ts | 0 .../src/lazy-loaded/lib-1/lib-1.routes.ts | 16 ++++ .../src/lazy-loaded/lib-1/pages/index.ts | 1 + .../lib-1-start/lib-1-start.component.html | 1 + .../lib-1-start/lib-1-start.component.scss | 3 + .../lib-1-start/lib-1-start.component.ts | 10 ++ .../src/lazy-loaded/lib-2/i18n/assets/en.json | 7 ++ .../src/lazy-loaded/lib-2/i18n/const/index.ts | 1 + .../src/lazy-loaded/lib-2/i18n/index.ts | 1 + .../opensource/src/lazy-loaded/lib-2/index.ts | 0 .../src/lazy-loaded/lib-2/lib-2.routes.ts | 20 ++++ .../src/lazy-loaded/lib-2/pages/index.ts | 1 + .../lib-2-start/lib-2-start.component.html | 1 + .../lib-2-start/lib-2-start.component.scss | 3 + .../lib-2-start/lib-2-start.component.ts | 10 ++ .../src/lazy-loaded/lib-2/resolvers/index.ts | 1 + .../lib-2/resolvers/start/start.resolver.ts | 6 ++ package-lock.json | 96 +++++++++++++++++++ 26 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 apps/opensource/src/lazy-loaded/lib-1/i18n/assets/en.json create mode 100644 apps/opensource/src/lazy-loaded/lib-1/i18n/const/index.ts create mode 100644 apps/opensource/src/lazy-loaded/lib-1/i18n/index.ts create mode 100644 apps/opensource/src/lazy-loaded/lib-1/index.ts create mode 100644 apps/opensource/src/lazy-loaded/lib-1/lib-1.routes.ts create mode 100644 apps/opensource/src/lazy-loaded/lib-1/pages/index.ts create mode 100644 apps/opensource/src/lazy-loaded/lib-1/pages/lib-1-start/lib-1-start.component.html create mode 100644 apps/opensource/src/lazy-loaded/lib-1/pages/lib-1-start/lib-1-start.component.scss create mode 100644 apps/opensource/src/lazy-loaded/lib-1/pages/lib-1-start/lib-1-start.component.ts create mode 100644 apps/opensource/src/lazy-loaded/lib-2/i18n/assets/en.json create mode 100644 apps/opensource/src/lazy-loaded/lib-2/i18n/const/index.ts create mode 100644 apps/opensource/src/lazy-loaded/lib-2/i18n/index.ts create mode 100644 apps/opensource/src/lazy-loaded/lib-2/index.ts create mode 100644 apps/opensource/src/lazy-loaded/lib-2/lib-2.routes.ts create mode 100644 apps/opensource/src/lazy-loaded/lib-2/pages/index.ts create mode 100644 apps/opensource/src/lazy-loaded/lib-2/pages/lib-2-start/lib-2-start.component.html create mode 100644 apps/opensource/src/lazy-loaded/lib-2/pages/lib-2-start/lib-2-start.component.scss create mode 100644 apps/opensource/src/lazy-loaded/lib-2/pages/lib-2-start/lib-2-start.component.ts create mode 100644 apps/opensource/src/lazy-loaded/lib-2/resolvers/index.ts create mode 100644 apps/opensource/src/lazy-loaded/lib-2/resolvers/start/start.resolver.ts diff --git a/apps/opensource/project.json b/apps/opensource/project.json index abcb4a0..1e7cc15 100644 --- a/apps/opensource/project.json +++ b/apps/opensource/project.json @@ -17,6 +17,16 @@ { "glob": "**/*", "input": "apps/opensource/public" + }, + { + "input": "apps/opensource/src/lazy-loaded/lib-1/i18n/assets/", + "glob": "*.json", + "output": "assets/i18n/lazy-loaded/lib-1" + }, + { + "input": "apps/opensource/src/lazy-loaded/lib-2/i18n/assets/", + "glob": "*.json", + "output": "assets/i18n/lazy-loaded/lib-2" } ], "styles": ["apps/opensource/src/styles.scss"] diff --git a/apps/opensource/src/app/app.html b/apps/opensource/src/app/app.html index 044897b..1c1cd95 100644 --- a/apps/opensource/src/app/app.html +++ b/apps/opensource/src/app/app.html @@ -1,5 +1,9 @@ +@if (navigating()) { + +} +

{{'title' | translate}}

@@ -29,4 +33,23 @@

{{'title' | translate}}

{{['Desktop', 'DesktopWide', 'Tablet'] | ngxMatchesQuery: 'some'}} + + diff --git a/apps/opensource/src/app/app.routes.ts b/apps/opensource/src/app/app.routes.ts index 6027016..e14c723 100644 --- a/apps/opensource/src/app/app.routes.ts +++ b/apps/opensource/src/app/app.routes.ts @@ -76,6 +76,16 @@ export const appRoutes: Route[] = [ }, ['i18n/layout/'] ), + { + path: 'lib-1', + loadChildren: () => + import('../lazy-loaded/lib-1/lib-1.routes').then((m) => m.LazyLoadedLib1Routes), + }, + { + path: 'lib-2', + loadChildren: () => + import('../lazy-loaded/lib-2/lib-2.routes').then((m) => m.LazyLoadedLib2Routes), + }, ], }, ]; diff --git a/apps/opensource/src/app/app.scss b/apps/opensource/src/app/app.scss index e69de29..fffce15 100644 --- a/apps/opensource/src/app/app.scss +++ b/apps/opensource/src/app/app.scss @@ -0,0 +1,11 @@ +.nav-loading-indicator { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + color: #555; +} + +nav a[aria-disabled='true'] { + pointer-events: none; + opacity: 0.5; + cursor: default; +} diff --git a/apps/opensource/src/app/app.ts b/apps/opensource/src/app/app.ts index e0fec4e..045a817 100644 --- a/apps/opensource/src/app/app.ts +++ b/apps/opensource/src/app/app.ts @@ -1,8 +1,17 @@ -import { Component, inject, signal, WritableSignal } from '@angular/core'; -import { Router, RouterModule } from '@angular/router'; +import { Component, inject, Signal, signal, WritableSignal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { + NavigationCancel, + NavigationEnd, + NavigationError, + NavigationStart, + Router, + RouterModule, +} from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; -import { from, of, switchMap } from 'rxjs'; +import { filter, from, map, of, switchMap } from 'rxjs'; +import { NgxI18nService } from '@lib/ngx-i18n'; import { NgxModalService, NgxToastContainerComponent, @@ -42,10 +51,30 @@ export class App { private readonly modalService = inject(NgxModalService); private readonly router: Router = inject(Router); private readonly mediaQueryService = inject(NgxMediaQueryService); + protected readonly i18nService: NgxI18nService = inject(NgxI18nService); private toastAmount: number = 1; public loading: WritableSignal = signal(false); + /** + * Guards/resolvers on a route (e.g. the lib-2 start resolver) can take a while to settle. + * Angular keeps the previously activated route rendered in the meantime, so without this + * indicator that wait is invisible and a click during it just cancels the navigation. + */ + public readonly navigating: Signal = toSignal( + this.router.events.pipe( + filter( + (event) => + event instanceof NavigationStart || + event instanceof NavigationEnd || + event instanceof NavigationCancel || + event instanceof NavigationError + ), + map((event) => event instanceof NavigationStart) + ), + { initialValue: false } + ); + constructor() { this.mediaQueryService.currentQueryMatch$.subscribe(console.log); } @@ -69,7 +98,7 @@ export class App { content: 'This is where we have the display content directive!', tourItem: 'display-content', beforeVisible: () => { - return from(this.router.navigate(['../../en','layout'])); + return from(this.router.navigate(['../../en', 'layout'])); }, }, ]); diff --git a/apps/opensource/src/lazy-loaded/lib-1/i18n/assets/en.json b/apps/opensource/src/lazy-loaded/lib-1/i18n/assets/en.json new file mode 100644 index 0000000..b2d15df --- /dev/null +++ b/apps/opensource/src/lazy-loaded/lib-1/i18n/assets/en.json @@ -0,0 +1,7 @@ +{ + "lib-1": { + "start": { + "body": "

AngularJS (also known as Angular 1) is a discontinued free and open-source JavaScript-based web framework for developing single-page applications. It was maintained mainly by Google and a community of individuals and corporations. It aimed to simplify both the development and the testing of such applications by providing a framework for client-side model–view–controller (MVC) and model–view–viewmodel (MVVM) architectures, along with components commonly used in web applications and progressive web applications.

AngularJS was used as the frontend of the MEAN stack, that consisted of MongoDB database, Express.js web application server framework, AngularJS itself (or Angular), and Node.js server runtime environment.

As of January 1, 2022, Google no longer updates AngularJS to fix security, browser compatibility, or jQuery issues. The Angular team recommends upgrading to Angular (v2+) as the best path forward, but they also provided some other options. diff --git a/apps/opensource/src/lazy-loaded/lib-1/pages/lib-1-start/lib-1-start.component.scss b/apps/opensource/src/lazy-loaded/lib-1/pages/lib-1-start/lib-1-start.component.scss new file mode 100644 index 0000000..d0a69bc --- /dev/null +++ b/apps/opensource/src/lazy-loaded/lib-1/pages/lib-1-start/lib-1-start.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/apps/opensource/src/lazy-loaded/lib-1/pages/lib-1-start/lib-1-start.component.ts b/apps/opensource/src/lazy-loaded/lib-1/pages/lib-1-start/lib-1-start.component.ts new file mode 100644 index 0000000..aa3861a --- /dev/null +++ b/apps/opensource/src/lazy-loaded/lib-1/pages/lib-1-start/lib-1-start.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; +import { TranslatePipe } from '@ngx-translate/core'; + +@Component({ + selector: 'lib-1-start-page', + templateUrl: './lib-1-start.component.html', + styleUrl: './lib-1-start.component.scss', + imports: [TranslatePipe], +}) +export class Lib1StartComponent {} diff --git a/apps/opensource/src/lazy-loaded/lib-2/i18n/assets/en.json b/apps/opensource/src/lazy-loaded/lib-2/i18n/assets/en.json new file mode 100644 index 0000000..c79f344 --- /dev/null +++ b/apps/opensource/src/lazy-loaded/lib-2/i18n/assets/en.json @@ -0,0 +1,7 @@ +{ + "lib-2": { + "start": { + "body": "

Angular (also referred to as Angular 2+) is a TypeScript-based free and open-source single-page web application framework. It is developed by Google and by a community of individuals and corporations. Angular is a complete rewrite from the same team that built AngularJS. The Angular ecosystem consists of a diverse group of over 1.7 million developers, library authors, and content creators. According to the Stack Overflow Developer Survey, Angular is one of the most commonly used web frameworks.

Google designed Angular as a ground-up rewrite of AngularJS. Unlike AngularJS, Angular does not have a concept of \"scope\" or controllers; instead, it uses a hierarchy of components as its primary architectural characteristic.[8] Angular has a different expression syntax, focusing on \"[ ]\" for property binding, and \"( )\" for event binding. Angular recommends the use of Microsoft's TypeScript language, which introduces features such as static typing, generics, and type annotations.

" + } + } +} diff --git a/apps/opensource/src/lazy-loaded/lib-2/i18n/const/index.ts b/apps/opensource/src/lazy-loaded/lib-2/i18n/const/index.ts new file mode 100644 index 0000000..5ffd27c --- /dev/null +++ b/apps/opensource/src/lazy-loaded/lib-2/i18n/const/index.ts @@ -0,0 +1 @@ +export const TRANSLATION_PATHS = ['./assets/i18n/lazy-loaded/lib-2/']; diff --git a/apps/opensource/src/lazy-loaded/lib-2/i18n/index.ts b/apps/opensource/src/lazy-loaded/lib-2/i18n/index.ts new file mode 100644 index 0000000..e47ea3a --- /dev/null +++ b/apps/opensource/src/lazy-loaded/lib-2/i18n/index.ts @@ -0,0 +1 @@ +export * from './const'; diff --git a/apps/opensource/src/lazy-loaded/lib-2/index.ts b/apps/opensource/src/lazy-loaded/lib-2/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/opensource/src/lazy-loaded/lib-2/lib-2.routes.ts b/apps/opensource/src/lazy-loaded/lib-2/lib-2.routes.ts new file mode 100644 index 0000000..b001d7a --- /dev/null +++ b/apps/opensource/src/lazy-loaded/lib-2/lib-2.routes.ts @@ -0,0 +1,20 @@ +import { Routes } from '@angular/router'; + +import { provideWithTranslations } from '@lib/ngx-i18n'; + +import { TRANSLATION_PATHS } from './i18n'; +import { Lib2StartComponent } from './pages'; +import { startResolver } from './resolvers'; + +export const LazyLoadedLib2Routes: Routes = [ + provideWithTranslations( + { + path: '', + component: Lib2StartComponent, + resolve: { + data: startResolver, + }, + }, + TRANSLATION_PATHS + ), +]; diff --git a/apps/opensource/src/lazy-loaded/lib-2/pages/index.ts b/apps/opensource/src/lazy-loaded/lib-2/pages/index.ts new file mode 100644 index 0000000..fdbdc14 --- /dev/null +++ b/apps/opensource/src/lazy-loaded/lib-2/pages/index.ts @@ -0,0 +1 @@ +export { Lib2StartComponent } from './lib-2-start/lib-2-start.component'; diff --git a/apps/opensource/src/lazy-loaded/lib-2/pages/lib-2-start/lib-2-start.component.html b/apps/opensource/src/lazy-loaded/lib-2/pages/lib-2-start/lib-2-start.component.html new file mode 100644 index 0000000..2c1cb2d --- /dev/null +++ b/apps/opensource/src/lazy-loaded/lib-2/pages/lib-2-start/lib-2-start.component.html @@ -0,0 +1 @@ +
diff --git a/apps/opensource/src/lazy-loaded/lib-2/pages/lib-2-start/lib-2-start.component.scss b/apps/opensource/src/lazy-loaded/lib-2/pages/lib-2-start/lib-2-start.component.scss new file mode 100644 index 0000000..d0a69bc --- /dev/null +++ b/apps/opensource/src/lazy-loaded/lib-2/pages/lib-2-start/lib-2-start.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/apps/opensource/src/lazy-loaded/lib-2/pages/lib-2-start/lib-2-start.component.ts b/apps/opensource/src/lazy-loaded/lib-2/pages/lib-2-start/lib-2-start.component.ts new file mode 100644 index 0000000..1e2b399 --- /dev/null +++ b/apps/opensource/src/lazy-loaded/lib-2/pages/lib-2-start/lib-2-start.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; +import { TranslatePipe } from '@ngx-translate/core'; + +@Component({ + selector: 'lib-2-start-page', + templateUrl: './lib-2-start.component.html', + styleUrl: './lib-2-start.component.scss', + imports: [TranslatePipe], +}) +export class Lib2StartComponent {} diff --git a/apps/opensource/src/lazy-loaded/lib-2/resolvers/index.ts b/apps/opensource/src/lazy-loaded/lib-2/resolvers/index.ts new file mode 100644 index 0000000..4eaa7da --- /dev/null +++ b/apps/opensource/src/lazy-loaded/lib-2/resolvers/index.ts @@ -0,0 +1 @@ +export { startResolver } from './start/start.resolver'; diff --git a/apps/opensource/src/lazy-loaded/lib-2/resolvers/start/start.resolver.ts b/apps/opensource/src/lazy-loaded/lib-2/resolvers/start/start.resolver.ts new file mode 100644 index 0000000..8148ef7 --- /dev/null +++ b/apps/opensource/src/lazy-loaded/lib-2/resolvers/start/start.resolver.ts @@ -0,0 +1,6 @@ +import { ResolveFn } from '@angular/router'; +import { delay, Observable, of } from 'rxjs'; + +export const startResolver: ResolveFn> = (): Observable => { + return of(true).pipe(delay(1000)); +}; diff --git a/package-lock.json b/package-lock.json index 2bd7e21..4f1e176 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3356,6 +3356,33 @@ } } }, + "node_modules/@compodoc/compodoc/node_modules/@angular-devkit/schematics/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/@compodoc/compodoc/node_modules/@babel/core": { "version": "7.25.8", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.8.tgz", @@ -3716,6 +3743,36 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@compodoc/compodoc/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/@compodoc/compodoc/node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@compodoc/compodoc/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -37341,6 +37398,24 @@ "rxjs": "7.8.1", "source-map": "0.7.4" } + }, + "chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + } } } }, @@ -37597,6 +37672,27 @@ "minipass": "^7.1.2" } }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "picomatch": "^2.2.1" + }, + "dependencies": { + "picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "optional": true, + "peer": true + } + } + }, "rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", From 9b433971f34ccfd2febd258157e7ec7b7bb66edf Mon Sep 17 00:00:00 2001 From: Denis Valcke Date: Tue, 14 Jul 2026 12:04:29 +0200 Subject: [PATCH 5/5] feat(ngx-i18n): switched to `subscribeSpyTo` for the multi-translation.loader unit tests --- .../multi-translation.loader.spec.ts | 87 +++++++++---------- 1 file changed, 40 insertions(+), 47 deletions(-) diff --git a/libs/angular/i18n/src/lib/loader/multi-translation/multi-translation.loader.spec.ts b/libs/angular/i18n/src/lib/loader/multi-translation/multi-translation.loader.spec.ts index 6a383ee..2de65ce 100644 --- a/libs/angular/i18n/src/lib/loader/multi-translation/multi-translation.loader.spec.ts +++ b/libs/angular/i18n/src/lib/loader/multi-translation/multi-translation.loader.spec.ts @@ -1,5 +1,6 @@ import { HttpBackend } from '@angular/common/http'; import { Injector, runInInjectionContext } from '@angular/core'; +import { subscribeSpyTo } from '@hirez_io/observer-spy'; import { of, throwError } from 'rxjs'; import { NgxI18nLoadingService } from '../../services'; @@ -31,7 +32,7 @@ describe('NgxI18nMultiTranslationHttpLoader', () => { }; describe('getTranslation', () => { - it('should not persist a failed fetch to the translations store, so it can be retried when the next loader is called that has overlap for that path.', (done) => { + it('should not persist a failed fetch to the translations store, so it can be retried when the next loader is called that has overlap for that path.', () => { const failingPath = './assets/i18n/failing-path/'; const translations = { foo: 'bar' }; @@ -49,32 +50,30 @@ describe('NgxI18nMultiTranslationHttpLoader', () => { }; const firstLoader = createLoader(loadingService, failingClient, [failingPath]); - firstLoader.getTranslation('nl').subscribe(() => { - // Denis: the failed path should not be cached as "loaded". - expect(loadingService.getTranslations()[failingPath]).toBeUndefined(); - - const recoveredClient = { - getTranslations: jest.fn().mockReturnValue(of(translations)), - }; - const secondLoader = createLoader(loadingService, recoveredClient, [ - failingPath, - './assets/i18n/other-path/', - ]); - - secondLoader.getTranslation('nl').subscribe((result) => { - expect(recoveredClient.getTranslations).toHaveBeenCalledWith( - failingPath, - 'nl', - undefined - ); - expect(result).toEqual(translations); - - done(); - }); - }); + subscribeSpyTo(firstLoader.getTranslation('nl')); + + // Denis: the failed path should not be cached as "loaded". + expect(loadingService.getTranslations()[failingPath]).toBeUndefined(); + + const recoveredClient = { + getTranslations: jest.fn().mockReturnValue(of(translations)), + }; + const secondLoader = createLoader(loadingService, recoveredClient, [ + failingPath, + './assets/i18n/other-path/', + ]); + + const secondSpy = subscribeSpyTo(secondLoader.getTranslation('nl')); + + expect(recoveredClient.getTranslations).toHaveBeenCalledWith( + failingPath, + 'nl', + undefined + ); + expect(secondSpy.getFirstValue()).toEqual(translations); }); - it('should persist a successful fetch to the translations store so it is reused by a later loader for an overlapping path.', (done) => { + it('should persist a successful fetch to the translations store so it is reused by a later loader for an overlapping path.', () => { const path = './assets/i18n/working-path/'; const translations = { foo: 'bar' }; @@ -85,28 +84,22 @@ describe('NgxI18nMultiTranslationHttpLoader', () => { }; const firstLoader = createLoader(loadingService, client, [path]); - firstLoader.getTranslation('nl').subscribe(() => { - expect(loadingService.getTranslations()[path]).toEqual(translations); - - const secondClient = { - getTranslations: jest.fn().mockReturnValue(of({})), - }; - const secondLoader = createLoader(loadingService, secondClient, [ - path, - './assets/i18n/other-path/', - ]); - - secondLoader.getTranslation('nl').subscribe(() => { - // Denis: the already-loaded path should come from the store, not a fresh fetch. - expect(secondClient.getTranslations).not.toHaveBeenCalledWith( - path, - 'nl', - undefined - ); - - done(); - }); - }); + subscribeSpyTo(firstLoader.getTranslation('nl')); + + expect(loadingService.getTranslations()[path]).toEqual(translations); + + const secondClient = { + getTranslations: jest.fn().mockReturnValue(of({})), + }; + const secondLoader = createLoader(loadingService, secondClient, [ + path, + './assets/i18n/other-path/', + ]); + + subscribeSpyTo(secondLoader.getTranslation('nl')); + + // Denis: the already-loaded path should come from the store, not a fresh fetch. + expect(secondClient.getTranslations).not.toHaveBeenCalledWith(path, 'nl', undefined); }); }); });