Cas habituel : provisionner une ressource synchrone

En terme général, on va mettre à disposition sur l'injecteur des services ou des valeurs qui sont disponibles immédiatement.

Par exemple :

@Injectable()
export class SyncService {
  config = { appName: 'Async provider demo' };
}

@Component({
  selector: 'app-root',
  standalone: true,
  providers: [SyncService],
  template: `
    <p>appName = {{appName}}</p>
  `,
})
export class App {
  appName = inject(SyncService).config.appName;
}

On a un service qui est provisionné et est injecté dans notre composant de manière synchrone. Ce service est extrêmement simple mais suffit pour montrer que tout fonctionne.

Provisionner une ressource asynchrone

Si maintenant on veut faire la même chose mais avec des données qu'on récupèrerait de manière asynchrone via un fetch, on fait comment ?

Déjà on ne peut pas juste passer par la syntaxe courte comme pour le SyncService, il faut passer par la syntaxe complète et pour rappel la syntaxe complète pour le SyncService donne :

@Component({
  providers: [{ provide: SyncService, useClass: SyncService }],
})
export class App {}

Il existe plusieurs options pour faire du provisionnement, mais la plus logique ici serait un FactoryProvider qui pour un cas très simple se résume a quelque chose du genre :


const AsyncConfig = new InjectionToken<{ url: string }>("AsyncConfig");

@Component({
  providers: [{ provide: AsyncService, useFactory: () => ({ url: 'static' }) }],
})
export class App {}

Naïvement, on se dit qu'il suffit de rendre la fonction async, faire un fetch et renvoyer la valeur ?

@Component({
  providers: [
    {
      provide: AsyncConfig,
      useFactory: async () => {
        const config = await fetch("/config.json").then((r) => r.json());
        return config;
      },
    },
  ],
  template: `
    <h1>Hello from {{ name }}!</h1>
    <p>appName = {{ appName }}</p>
    <p>dynamicUrl = {{ asyncConfig | json }}</p>
  `,
})
export class App {
    // ...
    asyncConfig = inject(AsyncConfig);
}

Malheureusement, on aura ça à l'affichage : dynamicUrl = { "__zone_symbol__state": null, "__zone_symbol__value": [] } ou dynamicUrl = { "__zone_symbol__state": true, "__zone_symbol__value": { "url": "https://dynamic-url.example.com" } }… un objet Zone et non pas la config 😰

Pourquoi ? En fait aucun provider n'est prévu pour être asynchrone donc ce n'est pas du tout la bonne route pour faire ça… 🤔

Option de semi-facilité : configuration asynchrone

Cette solution serait de ne pas chercher à provisionner en asynchrone la configuration, mais avoir une configuration asynchrone porté par un service provisionné en synchrone. Sous forme de code :

interface AsyncConfig {
  url: string;
}

@Injectable()
export class AsyncConfigService {
  config$ = inject(HttpClient).get<AsyncConfig>("/config.json");
}

@Component({
  providers: [AsyncConfigService],
  template: `
    <p>dynamicUrl = {{ asyncConfig | async | json }}</p>
  `,
})
export class App {
  asyncConfig = inject(AsyncConfigService).config$;
}

Ça fonctionne, mais ça a un inconvénient majeur : on va devoir résoudre l'Observable à chaque accès ! Par contre rien de compliqué à mettre en place.

Vrai provider asynchrone

On ne peut toujours pas magiquement définir un provider asynchrone, mais on peut fournir de manière asynchrone à Angular un provider.

Prenons un cas courant synchrone :

bootstrapApplication(App, { providers: [provideHttpClient()] });

Ici on provisionne le HttpClient dès le bootstrap de l'application. Rien ne nous empêche de faire un appel à bootstrapApplication après avoir résolu la réponse à un fetch de manière asynchrone !

interface AsyncConfig {
  url: string;
}

export class AsyncConfigService {
  constructor(public readonly config: AsyncConfig) {}
}

(async () => {
  const config = await fetch("/config.json").then((r) => r.json());
  bootstrapApplication(App, {
    providers: [
      { provide: AsyncConfigService, useValue: new AsyncConfigService(config) },
    ],
  });
})();

Ici je suis passé directement par un provider par valeur pour directement fournir une instance du service. J'aurai pu passer par un InjectionToken comme plus haut, mais ça me semble beaucoup plus simple comme ça, sans forcément avoir d'argument.

Pour récupérer notre configuration :

@Component({
  template: `
    <p>dynamicUrl = {{ asyncConfig | json }}</p>
  `,
})
export class App {
  asyncConfig = inject(AsyncConfigService).config;
}

Mettre au propre et prévoir l'usage dans les tests

Histoire de rendre le code plus lisible et organiser, je vous propose de sortir tout ce qui concerne notre AsyncConfigService dans un fichier dédié et exporter une fonction du même style que provideHttpClient() :

// async-config.service.ts
import { StaticProvider } from "@angular/core";

interface AsyncConfig {
  url: string;
}

export class AsyncConfigService {
  constructor(public readonly config: AsyncConfig) {}
}

export async function provideConfigAsync(): Promise<StaticProvider> {
  const config = await fetch("/config.json").then((r) => r.json());
  return {
    provide: AsyncConfigService,
    useValue: new AsyncConfigService(config),
  };
}

Et adapter le bootstrap comme ça :

// main.ts

(async () => {
  bootstrapApplication(App, { providers: [await provideConfigAsync()] });
})();

Et quitte à s'inspirer du module http, on peut aussi fournir une fonction pour provisionner la configuration de test sans avoir à faire d'appel asynchrone en exposant aussi une fonction provideConfigAsyncTest :

export async function provideConfigAsyncTest(
  config: Partial<AsyncConfig> = {}
): Promise<StaticProvider> {
  return {
    provide: AsyncConfigService,
    useValue: new AsyncConfigService({
      url: "http://default-url.local",
      ...config,
    }),
  };
}

Cette fonction est très simple : on prend ou pas une partie de la configuration en paramètre, si une configuration est fournie on s'en sert sinon prend des valeurs par défaut.

L'idée c'est de pouvoir écrire ça côté test :

TestBed.configureTestingModule({
    providers: [provideConfigAsyncTest()]
});

Mais aussi :

TestBed.configureTestingModule({
    providers: [provideConfigAsyncTest({ url: 'http://specific-url.example.com' })]
});

APP_INITIALIZER (update 16/11/2024)

L'ami Denis Souron m'a partagé une autre solution : l'APP_INITIALIZER. Ici on est face à une solution purement Angular, mais elle ne me semble pas tellement plus intuitive.

L'idée c'est qu'au lieu d'avoir un appel asynchrone qui conduit à l'ajout dans l'injecteur Angular d'un service déjà initialisé, on va d'abord provisionner le service (avec une valeur par défaut ou un null/undefined en fonction de votre préférence), puis on va provisionner une factory d'un type particulier pour faire l'appel asynchrone et mettre à jour notre service.

En termes de code ça donne ça :


@Injectable()
export class AsyncConfigService {
  public config: AsyncConfig = { url: "" };
}

export function provideConfigAsync(): Provider[] {
  return [
    // provider for the service
    AsyncConfigService,
    // provider for function that will feed the service
    {
      provide: APP_INITIALIZER,
      multi: true,
      useFactory:
        (asyncConfigService: AsyncConfigService) =>
        async () => {
          const config = await fetch("/config.json").then((r) => r.json());
          asyncConfigService.config = config;
        },
      deps: [AsyncConfigService],
    },
  ];
}

Avec cette solution on peut d'ailleurs passer le HttpClient et un Observable (au lieu de la Promise) :

export function provideConfigAsync(): Provider[] {
  return [
    AsyncConfigService,
    {
      provide: APP_INITIALIZER,
      multi: true,
      useFactory:
        (asyncConfigService: AsyncConfigService, http: HttpClient) => () =>
          http
            .get<AsyncConfig>("/config.json")
            .pipe(tap((config) => (asyncConfigService.config = config))),
      deps: [AsyncConfigService, HttpClient],
    },
  ];
}

Ce qui permet aussi d'alléger la syntaxe de notre boostrap :

bootstrapApplication(App, { providers: [...provideConfigAsync()] });

Conclusion

Si ce n'est pas très intuitif et pas décrit dans la documentation Angular comment réaliser un provisionnement asynchrone, c'est tout à fait possible. Je vous ai montré la technique que j'ai récemment employée, si vous en connaissez d'autres, je suis évidemment curieux ! 🤓

Source :

Crédit photo : Générée via Microsoft Designer avec le prompt suivant :

inject, async, asynchrone, injection, angular, provisionning, resources, web, frontend