From b81fad2c8c9100e5e0a99ec56be87f76f46a5061 Mon Sep 17 00:00:00 2001 From: Enea Jahollari Date: Sat, 14 Oct 2023 16:44:49 +0200 Subject: [PATCH] feat: added inject-lazy --- .../docs/utilities/Injectors/inject-lazy.md | 117 ++++++++++ libs/ngxtension/inject-lazy/README.md | 3 + libs/ngxtension/inject-lazy/ng-package.json | 5 + libs/ngxtension/inject-lazy/project.json | 33 +++ libs/ngxtension/inject-lazy/src/index.ts | 2 + .../inject-lazy/src/inject-lazy-impl.ts | 96 ++++++++ .../inject-lazy/src/inject-lazy.spec.ts | 213 ++++++++++++++++++ .../ngxtension/inject-lazy/src/inject-lazy.ts | 28 +++ tsconfig.base.json | 1 + 9 files changed, 498 insertions(+) create mode 100644 docs/src/content/docs/utilities/Injectors/inject-lazy.md create mode 100644 libs/ngxtension/inject-lazy/README.md create mode 100644 libs/ngxtension/inject-lazy/ng-package.json create mode 100644 libs/ngxtension/inject-lazy/project.json create mode 100644 libs/ngxtension/inject-lazy/src/index.ts create mode 100644 libs/ngxtension/inject-lazy/src/inject-lazy-impl.ts create mode 100644 libs/ngxtension/inject-lazy/src/inject-lazy.spec.ts create mode 100644 libs/ngxtension/inject-lazy/src/inject-lazy.ts diff --git a/docs/src/content/docs/utilities/Injectors/inject-lazy.md b/docs/src/content/docs/utilities/Injectors/inject-lazy.md new file mode 100644 index 00000000..5159a0a9 --- /dev/null +++ b/docs/src/content/docs/utilities/Injectors/inject-lazy.md @@ -0,0 +1,117 @@ +--- +title: injectLazy +description: ngxtension/inject-lazy +--- + +`injectLazy` is a helper function that allows us to lazily load a service or any kind of Angular provider. + +Lazy loading services is useful when we want to shrink the bundle size by loading services only when they are needed. + +```ts +import { injectLazy } from 'ngxtension/inject-lazy'; +``` + +:::tip[Inside story of the function] +Initial implementation inspiration: [Lazy loading services in Angular. What?! Yes, we can.](https://itnext.io/lazy-loading-services-in-angular-what-yes-we-can-cfbaf586d54e) +Enhanced usage + testing: [Lazy loading your services in Angular with tests in mind](https://riegler.fr/blog/2023-09-30-lazy-loading-mockable) +::: + +## Usage + +`injectLazy` accepts a function that returns a `Promise` of the service. The function will be called only when the service is needed. + +It can be a normal dynamic import or a default dynamic import from a module. + +```ts +const DataServiceImport = () => import('./data-service').then((m) => m.MyService); +// or +const DataServiceImport = () => import('./data-service'); +``` + +Then, we can use `injectLazy` to lazily load the service. + +```ts data.service.ts +@Injectable({ providedIn: 'root' }) +export class MyService { + data$ = of(1); +} +``` + +```ts test.component.ts +const DataServiceImport = () => import('./data-service').then((m) => m.MyService); + +@Component({ + standalone: true, + imports: [AsyncPipe], + template: '
{{data$ | async}}
', +}) +class TestComponent { + private dataService$ = injectLazy(DataServiceImport); + + data$ = this.dataService$.pipe(switchMap((s) => s.data$)); +} +``` + +We can also use `injectLazy` not in an injection context, by passing an injector to it. + +```ts test.component.ts +const DataServiceImport = () => import('./data-service'); + +@Component({ + standalone: true, + template: '
{{data}}
', +}) +class TestComponent implements OnInit { + private injector = inject(Injector); + + data = 0; + + ngOnInit() { + injectLazy(DataServiceImport, this.injector) // 👈 + .pipe(switchMap((s) => s.data$)) + .subscribe((value) => { + this.data = value; + }); + } +} +``` + +## Testing + +In order to test the lazy injected service we can mock them using `mockLazyProvider`. + +### Testing Example + +Let's test the below component + +```ts +const MyDataServiceImport = () => import('./my-data.service.ts').then((x) => x.MyDataService); + +@Component({}) +class TestComponent { + myLazyService$ = injectLazy(MyDataServiceImport); +} +``` + +In our test file we can do this: + +```ts +import { mockLazyProvider } from 'ngxtension/inject-lazy'; + +@Injectable() +class MyDataServiceMock { + hello = 'world'; +} + +beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + // 👇 here we provide mocked service + mockLazyProvider(MyDataService, MyDataServiceMock), + ], + }); + fixture = TestBed.createComponent(TestComponent); +}); +``` + +Now the component will use the mocked version of the service. diff --git a/libs/ngxtension/inject-lazy/README.md b/libs/ngxtension/inject-lazy/README.md new file mode 100644 index 00000000..c899c679 --- /dev/null +++ b/libs/ngxtension/inject-lazy/README.md @@ -0,0 +1,3 @@ +# ngxtension/inject-lazy + +Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/inject-lazy`. diff --git a/libs/ngxtension/inject-lazy/ng-package.json b/libs/ngxtension/inject-lazy/ng-package.json new file mode 100644 index 00000000..b3e53d69 --- /dev/null +++ b/libs/ngxtension/inject-lazy/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/ngxtension/inject-lazy/project.json b/libs/ngxtension/inject-lazy/project.json new file mode 100644 index 00000000..101af936 --- /dev/null +++ b/libs/ngxtension/inject-lazy/project.json @@ -0,0 +1,33 @@ +{ + "name": "ngxtension/inject-lazy", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/ngxtension/inject-lazy/src", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["inject-lazy"], + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/ngxtension/inject-lazy/**/*.ts", + "libs/ngxtension/inject-lazy/**/*.html" + ] + } + } + } +} diff --git a/libs/ngxtension/inject-lazy/src/index.ts b/libs/ngxtension/inject-lazy/src/index.ts new file mode 100644 index 00000000..50769c50 --- /dev/null +++ b/libs/ngxtension/inject-lazy/src/index.ts @@ -0,0 +1,2 @@ +export * from './inject-lazy'; +export * from './inject-lazy-impl'; diff --git a/libs/ngxtension/inject-lazy/src/inject-lazy-impl.ts b/libs/ngxtension/inject-lazy/src/inject-lazy-impl.ts new file mode 100644 index 00000000..df3b983d --- /dev/null +++ b/libs/ngxtension/inject-lazy/src/inject-lazy-impl.ts @@ -0,0 +1,96 @@ +import { + DestroyRef, + ENVIRONMENT_INITIALIZER, + EnvironmentInjector, + Injectable, + Injector, + Type, + createEnvironmentInjector, + inject, + type Provider, + type ProviderToken, +} from '@angular/core'; +import type { Observable } from 'rxjs'; +import { defer } from 'rxjs'; + +/** + * Lazy import type that includes default and normal imports + */ +export type LazyImportLoaderFn = () => + | Promise> + | Promise<{ default: ProviderToken }>; + +@Injectable({ providedIn: 'root' }) +export class InjectLazyImpl { + private overrides = new WeakMap(); // no need to clean up + override(type: Type, mock: Type) { + this.overrides.set(type, mock); + } + + get(injector: Injector, loader: LazyImportLoaderFn): Observable { + return defer(() => + loader().then((serviceOrDefault) => { + const type = + 'default' in serviceOrDefault + ? serviceOrDefault.default + : serviceOrDefault; + + // Check if we have overrides, O(1), low overhead + if (this.overrides.has(type)) { + const module = this.overrides.get(type); + return new module(); + } + + // If the service uses DestroyRef.onDestroy() it will never be called. + // Even if injector is a NodeInjector, this works only with providedIn: root. + // So it's the root injector that will provide the DestroyRef (and thus never call OnDestroy). + // The solution would be to create an EnvironmentInjector that provides the class we just lazy-loaded. + if (!(injector instanceof EnvironmentInjector)) { + // We're passing a node injector to the function + + // This is the DestroyRef of the component + const destroyRef = injector.get(DestroyRef); + + // This is the parent injector of the environmentInjector we're creating + const environmentInjector = injector.get(EnvironmentInjector); + + // Creating an environment injector to destroy it afterward + const newInjector = createEnvironmentInjector( + [type as Provider], + environmentInjector + ); + + // Destroy the injector to trigger DestroyRef.onDestroy on our service + destroyRef.onDestroy(() => newInjector.destroy()); + + // We want to create the new instance of our service with our new injector + injector = newInjector; + } + + return injector.get(type)!; + }) + ); + } +} + +/** + * Helper function to mock the lazy-loaded module in `injectAsync` + * + * @usage + * TestBed.configureTestingModule({ + * providers: [ + * mockLazyProvider(SandboxService, fakeSandboxService) + * ] + * }); + */ +export function mockLazyProvider(type: Type, mock: Type) { + return [ + { + provide: ENVIRONMENT_INITIALIZER, + multi: true, + useValue: () => { + inject(InjectLazyImpl).override(type, mock); + }, + }, + ]; +} diff --git a/libs/ngxtension/inject-lazy/src/inject-lazy.spec.ts b/libs/ngxtension/inject-lazy/src/inject-lazy.spec.ts new file mode 100644 index 00000000..39e5faa4 --- /dev/null +++ b/libs/ngxtension/inject-lazy/src/inject-lazy.spec.ts @@ -0,0 +1,213 @@ +import { AsyncPipe } from '@angular/common'; +import { + ChangeDetectorRef, + Component, + inject, + Injectable, + Injector, + OnInit, + Type, +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing'; +import { catchError, of, switchMap } from 'rxjs'; +import { injectLazy } from './inject-lazy'; +import { mockLazyProvider } from './inject-lazy-impl'; + +@Injectable({ providedIn: 'root' }) +export class MyService { + data$ = of(1); +} + +const lazyServiceImport = () => + new Promise>((resolve) => { + setTimeout(() => { + return resolve(MyService); + }, 500); + }); + +const lazyServiceImportWithError = () => + new Promise>((resolve, reject) => { + setTimeout(() => { + return reject(new Error('error loading service')); + }, 500); + }); + +const lazyDefaultServiceImport = () => + new Promise<{ default: Type }>((resolve) => { + setTimeout(() => { + return resolve({ default: MyService }); + }, 500); + }); + +describe(injectLazy.name, () => { + describe('lazy loads a service', () => { + @Component({ + standalone: true, + imports: [AsyncPipe], + template: '
{{data$ | async}}
', + }) + class TestComponent { + private myLazyService$ = injectLazy(lazyServiceImport); + data$ = this.myLazyService$.pipe(switchMap((service) => service.data$)); + } + + let fixture: ComponentFixture; + + beforeEach(async () => { + fixture = TestBed.createComponent(TestComponent); + }); + + it('using normal import in injection context', fakeAsync(() => { + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe(''); + tick(499); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe(''); + tick(1); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('1'); + })); + }); + + describe('lazy loads a service that is exported as default', () => { + @Component({ + standalone: true, + imports: [AsyncPipe], + template: '
{{data$ | async}}
', + }) + class TestComponent { + private myLazyService$ = injectLazy(lazyDefaultServiceImport); + data$ = this.myLazyService$.pipe(switchMap((service) => service.data$)); + } + + let fixture: ComponentFixture; + + beforeEach(async () => { + fixture = TestBed.createComponent(TestComponent); + }); + + it('in injection context', fakeAsync(() => { + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe(''); + tick(499); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe(''); + tick(1); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('1'); + })); + }); + + describe('lazy loads a service not in injection context', () => { + @Component({ standalone: true, template: '
{{data}}
' }) + class TestComponent implements OnInit { + private injector = inject(Injector); + private cdr = inject(ChangeDetectorRef); + + data = 0; + + ngOnInit() { + injectLazy(lazyServiceImport, this.injector) + .pipe(switchMap((service) => service.data$)) + .subscribe((data) => { + this.data = data; + this.cdr.detectChanges(); + }); + } + } + + let fixture: ComponentFixture; + let component: TestComponent; + + beforeEach(async () => { + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + }); + + it('by passing an injector', fakeAsync(() => { + component.ngOnInit(); + expect(fixture.nativeElement.textContent).toBe(''); + tick(499); + expect(fixture.nativeElement.textContent).toBe(''); + tick(1); + expect(fixture.nativeElement.textContent).toBe('1'); + })); + }); + + describe('can be mocked using mockLazyProvider', () => { + @Injectable({ providedIn: 'root' }) + class MyLazyService { + data = 2; + } + + @Injectable() + class MockService { + data = 3; + } + + const myLazyServiceImport = () => Promise.resolve(MyLazyService); + + @Component({ standalone: true, template: '' }) + class TestComponent { + myLazyService$ = injectLazy(myLazyServiceImport); + } + + let fixture: ComponentFixture; + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + // 👇 here we provide mocked service + mockLazyProvider(MyLazyService, MockService), + ], + }); + fixture = TestBed.createComponent(TestComponent); + }); + + it('using normal import in injection context', (done) => { + fixture.componentInstance.myLazyService$.subscribe((service) => { + expect(service.data).toBe(3); + done(); + }); + }); + }); + + describe('throws an error', () => { + @Component({ + standalone: true, + imports: [AsyncPipe], + template: '
{{data$ | async}}
', + }) + class TestComponent { + private myLazyService$ = injectLazy(() => lazyServiceImportWithError()); + data$ = this.myLazyService$.pipe( + switchMap((service) => service.data$), + catchError((error) => { + return of(error.message); + }) + ); + } + + let fixture: ComponentFixture; + + beforeEach(async () => { + fixture = TestBed.createComponent(TestComponent); + }); + + it('when import fails', fakeAsync(() => { + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe(''); + tick(499); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe(''); + tick(1); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('error loading service'); + })); + }); +}); diff --git a/libs/ngxtension/inject-lazy/src/inject-lazy.ts b/libs/ngxtension/inject-lazy/src/inject-lazy.ts new file mode 100644 index 00000000..ac3e8bf6 --- /dev/null +++ b/libs/ngxtension/inject-lazy/src/inject-lazy.ts @@ -0,0 +1,28 @@ +import { Injector } from '@angular/core'; +import { assertInjector } from 'ngxtension/assert-injector'; +import { Observable } from 'rxjs'; +import type { LazyImportLoaderFn } from './inject-lazy-impl'; +import { InjectLazyImpl } from './inject-lazy-impl'; + +/** + * Loads a service lazily. The service is loaded when the observable is subscribed to. + * + * @param loader A function that returns a promise of the service to load. + * @param injector The injector to use to load the service. If not provided, the current injector is used. + * @returns An observable of the service. + * + * @example + * ```ts + * const dataService$ = injectLazy(() => import('./data-service').then((m) => m.MyService)); + * or + * const dataService$ = injectLazy(() => import('./data-service')); + * ``` + */ +export function injectLazy( + loader: LazyImportLoaderFn, + injector?: Injector +): Observable { + injector = assertInjector(injectLazy, injector); + const injectImpl = injector.get>(InjectLazyImpl); + return injectImpl.get(injector, loader); +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 1e24f84a..23c28ccb 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -47,6 +47,7 @@ "ngxtension/inject-is-intersecting": [ "libs/ngxtension/inject-is-intersecting/src/index.ts" ], + "ngxtension/inject-lazy": ["libs/ngxtension/inject-lazy/src/index.ts"], "ngxtension/intl": ["libs/ngxtension/intl/src/index.ts"], "ngxtension/map-array": ["libs/ngxtension/map-array/src/index.ts"], "ngxtension/map-skip-undefined": [