3.1.0 • Published 1 month ago

@nx-squeezer/ngx-async-injector v3.1.0

Weekly downloads
-
License
MIT
Repository
github
Last release
1 month ago

@nx-squeezer/ngx-async-injector

CI npm latest version CHANGELOG codecov compodoc renovate semantic-release

Motivation

Angular's dependency injection (DI) system is designed to be synchronous, since having asynchronous providers would make component rendering asynchronous and break existing renderer.

As of today it is not possible to lazy load data asynchronously and consume it through a provider. The only option recommended by Angular when it needs to be loaded before the app initializes is using APP_INITIALIZER. However, it has several known cons because it is blocking and delays rendering the whole component tree and loading routes.

Another common problem is the initial payload of the main bundle caused by needing to declare providers in root. When a provider is needed by various features it usually needs to be declared in the root injector, increasing the initial bundle size. It would be great that services could be declared in the root component, but lazy loaded when needed. It is true that using providedIn: root could be used in many scenarios, but there are others where using async import() of a dependency would be more useful, such as code splitting and fine grained lazy loading.

For the scenarios described above, having a way to declare asynchronous providers, either by loading data from the server and later instantiating a service, or to lazy load them using import(), could help and give flexibility to implementers. This particular problem is what @nx-squeezer/ngx-async-injector solves.

Show me the code

The API that this library offers is very much similar to Angular's DI. Check this code as an example:

// main.ts
bootstrapApplication(AppComponent, {
  providers: [
    {
      provide: MY_SERVICE,
      useClass: MyService,
    },
  ],
});

// component
class Component {
  private readonly myService = inject(MY_SERVICE);
}

Could be made asynchronous and lazy loaded using provideAsync():

// main.ts
bootstrapApplication(AppComponent, {
  providers: [
    provideAsync({
      provide: MY_SERVICE,
      useAsyncClass: () => import('./my-service').then((x) => x.MyService),
    }),
  ],
});

// component
class Component {
  private readonly myService = inject(MY_SERVICE);
}

That's it! Declaration is almost identical, and consumption is the same. But wait, when is the async provided actually loaded and resolved?

It needs another piece that triggers it: async provider resolvers. Check this diagram:

resolver diagram

Async providers need to be resolved before being used, and that is a responsibility of the application. It can be done while loading a route using a route resolver, or with a structural directive that will delay rendering until they are loaded.

Check this online Stackblitz playground with a live demo.

Examples

Resolve using route's resolver

export const appRoutes: Route[] = [
  {
    path: '',
    loadComponent: () => import('./route.component'),
    resolve: {
      asyncProviders: () => resolveMany(MY_SERVICE),
    },
  },
];

In this case, the async provider will be resolved while the route loads, and the inside the component MY_SERVICE can be injected.

Resolve using a structural directive

@Component({
  imports: [ResolveAsyncProvidersDirective, ChildComponent],
  template: ` <child-component *ngxResolveAsyncProviders="{ myService: MY_SERVICE }" /> `,
  standalone: true,
})
export default class ParentComponent {
  readonly MY_SERVICE = MY_SERVICE;
}

In this case, the async provider will be resolved when the parent component renders, and once completed the child component will be rendered having MY_SERVICE available.

Resolve configuration from API

// Instead of using the common approach of APP_INITIALIZER, which blocks loading and rendering until resolved:
bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(),
    {
      provide: APP_INITIALIZER,
      useFactory: () => inject(HttpClient).get('/config'),
      multi: true,
    },
  ],
});

// You could declare it with an async provider, which will be resolved on demand without blocking,
// and yet available through DI:
bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(),
    {
      provide: CONFIG_TOKEN,
      useAsyncFactory: () => firstValueFrom(inject(HttpClient).get('/config')),
    },
  ],
});

API documentation

Check the full documentation to see all available features.

provideAsync function

It is used to declare one or more async providers. For each provider, it requires the token, and then an async function that can be useAsyncValue, useAsyncClass or useAsyncFactory. It supports multi providers as well. It can be used in environment injectors, modules, components and directives. If multiple providers need to be declared in the same injector, use a single provideAsync function with multiple providers instead of using it multiple times.

Async provider tokens are regular Angular injection tokens typed with the resolved value of the async provider.

Example of declaring a single async provider:

export const MY_SERVICE = new InjectionToken<MyService>('my-service-token');

bootstrapApplication(AppComponent, {
  providers: [
    provideAsync({
      provide: MY_SERVICE,
      useAsyncClass: () => import('./my-service').then((x) => x.MyService),
    }),
  ],
});

Example of declaring multiple providers, each one with different async functions:

bootstrapApplication(AppComponent, {
  providers: [
    provideAsync(
      {
        provide: CLASS_PROVIDER,
        useAsyncClass: () => import('./first-service').then((x) => x.FirstService),
      },
      {
        provide: VALUE_PROVIDER,
        useAsyncValue: () => import('./value').then((x) => x.value),
      },
      {
        provide: FACTORY_PROVIDER,
        useAsyncFactory: () => import('./factory').then((x) => x.providerFactory),
      }
    ),
  ],
});

// first-service.ts
export class FirstService {}

// value.ts
export const value = 'value';

// factory.ts
export async function providerFactory() {
  return await Promise.resolve('value');
}

Multi providers can also be declared as it happens with Angular:

bootstrapApplication(AppComponent, {
  providers: [
    provideAsync(
      {
        provide: VALUE_PROVIDER,
        useAsyncValue: () => import('./first-value').then((x) => x.value),
        multi: true,
      },
      {
        provide: VALUE_PROVIDER,
        useAsyncValue: () => import('./second-value').then((x) => x.value),
        multi: true,
      }
    ),
  ],
});

Finally, the lazy load behavior can be controlled by the mode flag. By default it is lazy, which means it won't be resolved until requested. eager on the contrary will trigger the load on declaration, even though resolvers are still needed to wait for completion. Example:

bootstrapApplication(AppComponent, {
  providers: [
    provideAsync({
      provide: VALUE_PROVIDER,
      useAsyncValue: () => import('./first-value').then((x) => x.value),
      mode: 'eager',
    }),
  ],
});

When using a factory provider, the function itself can be async. Regular inject function from Angular can be used before executing any async code since the injection context is preserved, however it can't be used afterwards. To solve that problem, and also to protect against cyclic dependencies between async providers, the factory provider function is called with a context that exposes two functions that are self explanatory, inject and resolve. Example:

import { InjectionContext } from '@nx-squeezer/ngx-async-injector';

export async function providerFactory({ inject, resolve }: InjectionContext): Promise<string> {
  const firstString = await resolve(FIRST_INJECTION_TOKEN);
  const secondString = inject(SECOND_INJECTION_TOKEN);
  return `${firstString} ${secondString}`;
}

resolve and resolveMany

resolve and resolveMany functions can be used in route resolvers to ensure that certain async providers are resolved before a route loads. They could be used in other places as needed, since they return a promise that resolves when the async provider is resolved and returns its value. It can be compared to Angular's inject function, but for async providers.

Example of how to use it in a route resolver:

export const routes: Route[] = [
  {
    path: '',
    loadComponent: () => import('./route.component'),
    providers: [
      provideAsync(
        {
          provide: CLASS_PROVIDER,
          useAsyncClass: () => import('./first-service').then((x) => x.FirstService),
        },
        {
          provide: VALUE_PROVIDER,
          useAsyncValue: () => import('./value').then((x) => x.value),
        }
      ),
    ],
    resolve: {
      asyncProviders: () => resolveMany(CLASS_PROVIDER, VALUE_PROVIDER),
    },
  },
];

*ngxResolveAsyncProviders structural directive

This directive can be used to render a template after certain async providers have resolved. It can be useful to delay loading them as much as possible. The template can safely inject those resolved async providers.

When no parameters are passed, it will load all async injectors in the injector hierarchy:

@Component({
  template: `<child-component *ngxResolveAsyncProviders></child-component>`,
  providers: [provideAsync({ provide: STRING_INJECTOR_TOKEN, useAsyncValue: stringAsyncFactory })],
  imports: [ResolveAsyncProvidersDirective, ChildComponent],
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
class ParentComponent {}

@Component({
  selector: 'child-component',
  template: `Async injector value: {{ injectedText }}`,
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
class ChildComponent {
  readonly injectedText = inject(STRING_INJECTOR_TOKEN);
}

Additionally, it also supports a map of async provider tokens. Only those will be resolved instead of all. The resolved async providers are available as the context for the structural directive. Example:

@Component({
  template: `
    <!-- Use $implicit context from the structural directive, it is type safe -->
    <child-component
      *ngxResolveAsyncProviders="{ stringValue: stringInjectionToken }; let providers"
      [inputText]="providers.stringValue"
    ></child-component>

    <!-- Use the key from the context, it is type safe as well -->
    <child-component
      *ngxResolveAsyncProviders="{ stringValue: stringInjectionToken }; stringValue as stringValue"
      [inputText]="stringValue"
    ></child-component>
  `,
  providers: [provideAsync({ provide: STRING_INJECTOR_TOKEN, useAsyncValue: stringAsyncFactory })],
  imports: [ResolveAsyncProvidersDirective, ChildComponent],
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
class ParentComponent {
  readonly stringInjectionToken = STRING_INJECTOR_TOKEN;
}

@Component({
  selector: 'child-component',
  template: `Async injector value: {{ inputText }}`,
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
class ChildComponent {
  @Input() inputText!: string;
}

Installation

Do you like this library? Go ahead and use it! It is production ready, with 100% code coverage, protected by integration tests, and uses semantic versioning. To install it:

npm install @nx-squeezer/ngx-async-injector
3.1.0

1 month ago

3.0.5

2 months ago

3.0.4

3 months ago

3.0.3

3 months ago

3.0.2

3 months ago

3.0.1

3 months ago

3.0.0

5 months ago

2.0.2

9 months ago

2.0.1

10 months ago

2.0.0

12 months ago

1.0.6

1 year ago

1.0.5

1 year ago

1.0.4

1 year ago

1.0.3

1 year ago

1.0.2

1 year ago

1.0.1

1 year ago

1.0.0

1 year ago