Skip to content

Commit

Permalink
feat: added inject-lazy
Browse files Browse the repository at this point in the history
  • Loading branch information
eneajaho committed Oct 14, 2023
1 parent 8dc8330 commit b81fad2
Show file tree
Hide file tree
Showing 9 changed files with 498 additions and 0 deletions.
117 changes: 117 additions & 0 deletions docs/src/content/docs/utilities/Injectors/inject-lazy.md
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.
3 changes: 3 additions & 0 deletions libs/ngxtension/inject-lazy/README.md
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`.
5 changes: 5 additions & 0 deletions libs/ngxtension/inject-lazy/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "src/index.ts"
}
}
33 changes: 33 additions & 0 deletions libs/ngxtension/inject-lazy/project.json
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"
]
}
}
}
}
2 changes: 2 additions & 0 deletions libs/ngxtension/inject-lazy/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './inject-lazy';
export * from './inject-lazy-impl';
96 changes: 96 additions & 0 deletions libs/ngxtension/inject-lazy/src/inject-lazy-impl.ts
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);
},
},
];
}

0 comments on commit b81fad2

Please sign in to comment.