-
Notifications
You must be signed in to change notification settings - Fork 76
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
9 changed files
with
498 additions
and
0 deletions.
There are no files selected for viewing
117 changes: 117 additions & 0 deletions
117
docs/src/content/docs/utilities/Injectors/inject-lazy.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: '<div>{{data$ | async}}</div>', | ||
}) | ||
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: '<div>{{data}}</div>', | ||
}) | ||
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# ngxtension/inject-lazy | ||
|
||
Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/inject-lazy`. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{ | ||
"lib": { | ||
"entryFile": "src/index.ts" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
] | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export * from './inject-lazy'; | ||
export * from './inject-lazy-impl'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<T> = () => | ||
| Promise<ProviderToken<T>> | ||
| Promise<{ default: ProviderToken<T> }>; | ||
|
||
@Injectable({ providedIn: 'root' }) | ||
export class InjectLazyImpl<T> { | ||
private overrides = new WeakMap(); // no need to clean up | ||
override<T>(type: Type<T>, mock: Type<unknown>) { | ||
this.overrides.set(type, mock); | ||
} | ||
|
||
get(injector: Injector, loader: LazyImportLoaderFn<T>): Observable<T> { | ||
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<T>(type: Type<T>, mock: Type<unknown>) { | ||
return [ | ||
{ | ||
provide: ENVIRONMENT_INITIALIZER, | ||
multi: true, | ||
useValue: () => { | ||
inject(InjectLazyImpl).override(type, mock); | ||
}, | ||
}, | ||
]; | ||
} |
Oops, something went wrong.