AngularAtlaséd. 22.2

Chap. 12SSR et Hydration56 / 75

Code compatible serveur

window n’existe pas sur le serveur : PLATFORM_ID, afterNextRender, DOCUMENT, TransferState, le cache HTTP et PendingTasks.

v22 ~0 min de lecture Documentation officielle

Avec le SSR ou le prerendering, vos composants s’exécutent deux fois : une fois dans Node — à chaque requête ou au build — pour produire le HTML, et une fois de plus dans le navigateur, pendant l’hydration. Les constructeurs, les initialiseurs de champ, ngOnInit, effect(), les services, les intercepteurs et les guards s’exécutent sur les deux plateformes. Seules quelques API sont réservées au navigateur : afterNextRender, afterEveryRender, afterRenderEffect et, bien sûr, les événements de l’utilisateur.

Sur le serveur, il n’y a ni window, ni localStorage, ni layout, ni observers. Il y a un document — émulé par Domino — mais uniquement via inject(DOCUMENT). Le code écrit « pour le navigateur » fonctionne parfaitement avec ng serve sans SSR et n’échoue que dans le build serveur. Ou, pire, il n’échoue pas, mais envoie un HTML erroné : mauvais thème, données manquantes, chaque requête faite deux fois.

En bref
  • Il n’y a ni window, ni document, ni localStorage sur le serveur. Le code qui en a besoin s’exécute dans afterNextRender.
  • Ce que vous avez chargé sur le serveur arrive dans le navigateur via le cache de transfert HTTP : HttpClient ne refait pas la requête à l’hydratation.
  • Le serveur n’envoie la page que lorsque l’application n’a plus de tâches en attente (PendingTasks). Un timer qui ne s’arrête jamais garde la requête ouverte.
  • Une page inexistante doit répondre avec le statut 404, pas seulement afficher « introuvable ».
  • Les variables au niveau du module sont partagées par toutes les requêtes sur le serveur. L’état d’un utilisateur va dans les services, pas là.
Pourquoi c’est important
Cette page est la checklist de la revue de code dans un projet avec SSR : ce qui casse sur le serveur, où déplacer le code navigateur, comment ne pas faire le travail deux fois (TransferState et cache HTTP), quand le serveur décide que la page est prête (PendingTasks), et comment éviter que l’état fuite entre utilisateurs. La configuration du SSR se trouve dans SSR, prerender & render modes, et la reprise du HTML dans le navigateur dans Hydration.

Ce qui n’existe pas sur le serveur

Le serveur Angular s’exécute dans Node, avec un DOM émulé par Domino (inclus dans @angular/platform-server). La distinction importante : les globals du navigateur sont complètement absents et donnent une ReferenceError, alors que le document reçu via la DI a une structure mais pas de layout. Le tableau est vérifié sur Node 22/24 et sur le Domino de @angular/platform-server 22.2.2.

APISur le serveurÀ utiliser à la place
window, document (global), location, historyn’existe pas : ReferenceError: window is not definedinject(DOCUMENT), Router, Location
localStorage, sessionStoragen’existent pasafterNextRender ; pour ce qui compte au moment du rendu, un cookie lu depuis REQUEST
matchMedia, innerWidthabsents aussi de document.defaultView (le window de Domino)media queries CSS ; BreakpointObserver du CDK (sur le serveur, les requêtes ne correspondent pas) ; afterNextRender
IntersectionObserver, ResizeObserver, MutationObserver, requestAnimationFramen’existent pas@defer (on viewport); afterNextRender + DestroyRef
getBoundingClientRect(), offsetWidth, scrollHeightles éléments Domino ne les ont pas (TypeError ou undefined)afterNextRender({ read })
document.cookiele getter lève Error: NotYetImplementedinject(REQUEST)?.headers.get('cookie')
navigatorexiste dans Node 21+, mais appartient au serveur (userAgent: 'Node.js/22')les en-têtes depuis REQUEST ; isPlatformBrowser
setTimeout, setInterval, fetchexistent, mais les timers ne s’arrêtent pas d’eux-mêmes, et fetch n’accepte pas les URL relativesafterNextRender + DestroyRef; HttpClient

Ce qui s’exécute où

CodeServeurNavigateur
initialiseurs de champ, constructeur, ngOnInit, ngOnChangesouioui
effect(), computed(), le template, les pipesouioui
services, intercepteurs, guards, resolversoui — nouvelles instances à chaque requêteoui
ngOnDestroy, DestroyRef.onDestroyoui — le serveur détruit l’application après le renduoui
afterNextRender, afterEveryRender, afterRenderEffectnon (NOOP)oui
écouteurs d’événements : (click), (input)…non — personne ne clique sur le serveuroui
@defer sans hydrateuniquement le @placeholderles triggers démarrent ici
LiveEst-ce que ça s’exécuterait sur le serveur ? (simulation de l’environnement serveur)
6 erreurs2 attention3 OK
theme = 'light';

constructor() {
  this.theme = localStorage.getItem('theme') ?? 'light';
}
Erreur · serveur (Node + Domino)ReferenceError: localStorage is not defined

Pourquoi : Le constructeur s’exécute aussi sur le serveur, et dans Node il n’y a pas de localStorage. Même s’il y en avait un, le serveur n’a pas accès au stockage de l’utilisateur.

La correction
readonly theme = signal('light');
constructor() {
  afterNextRender(() =>
    this.theme.set(localStorage.getItem('theme') ?? 'light'));
}
// ou : un cookie lu sur le serveur depuis inject(REQUEST)
Basculez entre Navigateur et « S’exécute sur le serveur ». Chaque fragment s’exécute réellement : sur « serveur », dans un sandbox où les globals du navigateur sont absents comme dans Node, navigator est celui de Node, et inject(DOCUMENT) donne un document de type Domino. Les messages d’erreur sont les mêmes que dans Node. Seuls les modificateurs TypeScript (private, readonly) sont retirés avant l’exécution.
La règle
Aucune API navigateur dans le constructeur, les initialiseurs de champ, ngOnInit ou effect(). Tout ce qui touche au DOM, au layout, au stockage ou à une bibliothèque navigateur va dans afterNextRender / afterRenderEffect — ou derrière un service avec une implémentation serveur distincte.

afterNextRender et les phases

afterNextRender et afterEveryRender (stables depuis la v20, quand afterRender a été renommé afterEveryRender) enregistrent des callbacks qui s’exécutent une fois qu’Angular a fini le rendu — uniquement dans le navigateur. Sur le serveur, ce sont des NOOP : le callback n’est même pas enregistré. C’est pourquoi ils sont l’endroit par défaut pour tout code qui touche au DOM, au layout ou aux API de window. Ils doivent être appelés dans un contexte d’injection (constructeur, initialiseur de champ) ou avec l’option injector, et ils se nettoient tout seuls quand le composant est détruit.

Vous pouvez leur donner une fonction ou un objet avec des phases. Les phases s’exécutent dans l’ordre earlyRead → write → mixedReadWrite → read, et chacune reçoit la valeur renvoyée par la précédente. Cette séparation existe pour qu’Angular puisse regrouper les lectures et les écritures de toute l’application : une lecture du layout après une écriture force le navigateur à recalculer le layout sur-le-champ (layout thrashing). Une simple fonction s’exécute dans mixedReadWrite, la phase que la documentation vous demande d’éviter.

PhaseVous pouvezExemple
earlyReaduniquement lire, avant les écrituresvous mesurez quelque chose dont dépend une écriture (layout personnalisé)
writeuniquement écrirestyles, initialisation d’une bibliothèque
mixedReadWritelectures et écritures mélangéesseulement si le travail ne peut pas être découpé
readuniquement lire, après les écrituresvous mesurez le résultat final et le placez dans un signal
import { Component, ElementRef, afterNextRender, input, viewChild } from '@angular/core';

/** Réduit le titre jusqu’à ce qu’il tienne sur une ligne. Sur le serveur, rien ne se passe. */
@Component({
  selector: 'app-fit-title',
  template: `<h2 #title class="fit">{{ text() }}</h2>`,
})
export class FitTitle {
  readonly text = input.required<string>();
  private readonly title = viewChild.required<ElementRef<HTMLElement>>('title');

  constructor() {
    // Une fois, après le premier rendu, UNIQUEMENT dans le navigateur (sur le serveur, c’est un no-op).
    afterNextRender({
      // 1. earlyRead : lit le layout avant toute écriture
      earlyRead: () => {
        const el = this.title().nativeElement;
        return Math.min(1, el.clientWidth / el.scrollWidth); // 1 = ça tient
      },
      // 2. write : reçoit le résultat de la phase précédente et ne fait qu’écrire
      write: (ratio) => {
        this.title().nativeElement.style.fontSize = `${Math.floor(32 * ratio)}px`;
      },
    });
  }
}
LivePLATFORM_ID et le moment de la mesure (API réelle)
ExpressionIci (en direct) Sur le serveur, ce serait
inject(PLATFORM_ID)'server''server'
isPlatformBrowser(id)falsefalse
isPlatformServer(id)truetrue
inject(REQUEST)nullRequest (url, headers, cookie)
inject(RESPONSE_INIT)null{ status, headers }
Le composant mesurélargeur actuelle (afterEveryRender, phase read) : 0 px
  1. +0.1 msconstructorisConnected=false, width=0px, children=0, binding=null
  2. +0.2 msngOnInitisConnected=true, width=0px, children=0, binding=""

Sur le serveur, seules les lignes constructor et ngOnInit s’exécuteraient (pas de layout : les largeurs n’existeraient pas) ; afterNextRender et afterEveryRender sont ignorés.

Le tableau montre les vraies valeurs de cette page. Cliquez sur « Remonter le composant » et lisez le journal : dans le constructeur, l’élément n’est pas sur la page et n’a pas de contenu ; dans ngOnInit il est attaché, mais les bindings du template ne sont pas encore appliqués (et sur le serveur il n’aurait pas de layout) ; ce n’est que dans les phases d’afterNextRender que tout est rendu. Déplacez le curseur : afterEveryRender mesure à nouveau après chaque rendu.

afterEveryRender ou afterRenderEffect

afterEveryRender s’exécute après chaque rendu de l’application, y compris quand quelque chose a changé dans un tout autre composant. Si votre travail dépend de certains signals, afterRenderEffect est l’option la moins coûteuse : un effect qui s’exécute après le rendu uniquement quand ses dépendances changent (et là encore seulement dans le navigateur). La différence avec effect() : effect s’exécute aussi sur le serveur et n’a pas de phases read/write.

import { Component, ElementRef, afterRenderEffect, input, viewChild } from '@angular/core';

@Component({
  selector: 'app-chat-log',
  template: `
    <div #scroller class="messages">
      @for (m of messages(); track m.id) { <p>{{ m.text }}</p> }
    </div>
  `,
})
export class ChatLog {
  readonly messages = input.required<readonly { id: number; text: string }[]>();
  private readonly scroller = viewChild.required<ElementRef<HTMLElement>>('scroller');

  constructor() {
    // ✗ s’exécuterait après CHAQUE rendu de l’application, même si la liste n’a pas changé :
    // afterEveryRender({ write: () => this.scrollToBottom() });

    // ✓ s’exécute après le rendu uniquement quand les signals qu’il lit ont changé (toujours seulement dans le navigateur)
    afterRenderEffect({
      write: () => {
        this.messages(); // dépendance
        this.scrollToBottom();
      },
    });
  }

  private scrollToBottom(): void {
    const el = this.scroller().nativeElement;
    el.scrollTop = el.scrollHeight;
  }
}
L’hydration n’est pas garantie
La documentation de l’API est explicite : les composants ne sont pas garantis d’être hydratés quand un callback afterNextRender s’exécute. Lisez et écrivez le DOM avec précaution et ne déplacez pas les nœuds qu’Angular rend — sinon apparaissent les erreurs de mismatch décrites dans Hydration.

Les bibliothèques qui touchent à window

De nombreuses bibliothèques (cartes, graphiques, éditeurs de texte riche) accèdent à window dès l’import. Le serveur plante au chargement du module, avant qu’aucun composant ne s’exécute, et aucune vérification dans le constructeur ne vous sauve plus. La solution est un import() dynamique dans afterNextRender ; en bonus, la bibliothèque se retrouve dans un chunk séparé. Pour des composants entiers, @defer fait la même chose de façon déclarative.

import { Component, DestroyRef, ElementRef, afterNextRender, inject, viewChild } from '@angular/core';
// ✗ import * as L from 'leaflet';  — le module touche à window dès qu’il est importé :
//    le serveur plante au chargement du fichier, avant qu’aucun composant ne s’exécute.

@Component({
  selector: 'app-store-map',
  template: `<div #host class="map" aria-label="Carte des magasins"></div>`,
  // une hauteur fixe réserve aussi la place dans le HTML du serveur → pas de layout shift
  styles: `.map { height: 320px; }`,
})
export class StoreMap {
  private readonly host = viewChild.required<ElementRef<HTMLElement>>('host');
  private readonly destroyRef = inject(DestroyRef); // inject() uniquement en synchrone, pas après un await

  constructor() {
    afterNextRender(async () => {
      const L = await import('leaflet'); // chunk séparé, téléchargé uniquement dans le navigateur
      if (this.destroyRef.destroyed) return; // l’utilisateur est parti entre-temps
      const map = L.map(this.host().nativeElement).setView([44.4268, 26.1025], 12);
      this.destroyRef.onDestroy(() => map.remove());
    });
  }
}

Si la bibliothèque réécrit le DOM qu’Angular a rendu, l’hydration se plaint de mismatches. ngSkipHydration sur le composant hôte arrête l’hydration uniquement pour lui — c’est un pansement, pas une solution.

DOCUMENT, PLATFORM_ID et les providers de plateforme

inject(DOCUMENT) — depuis @angular/core à partir de la v20 ; @angular/common le réexporte — vous donne le document courant sur n’importe quelle plateforme. Sur le serveur, c’est le document Domino de la requête : vous pouvez lire et écrire la structure (title, <meta>, <link rel="canonical">, attributs sur <html>), et les modifications se retrouvent dans le HTML envoyé. Vous n’avez ni layout, ni cookies, ni matchMedia.

import { DOCUMENT, Service, inject } from '@angular/core';

@Service()
export class CanonicalLink {
  private readonly document = inject(DOCUMENT); // sur le serveur : le document Domino de la requête

  /** S’exécute aussi sur le serveur : la balise se retrouve dans le HTML que voient les crawlers. */
  set(url: string): void {
    let link = this.document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
    if (!link) {
      link = this.document.createElement('link');
      link.rel = 'canonical';
      this.document.head.appendChild(link);
    }
    link.href = url;
  }
}

// Pour <title> et <meta>, il existe déjà des services SSR-safe dans @angular/platform-browser :
//   inject(Title).setTitle('Phone X — Store');
//   inject(Meta).updateTag({ name: 'description', content: '…' });

inject(PLATFORM_ID) renvoie 'browser' ou 'server', et vous le testez avec isPlatformBrowser / isPlatformServer de @angular/common. Ça marche, mais le guide officiel recommande plutôt des providers de plateforme à des vérifications à l’exécution, pour deux raisons : les vérifications se multiplient dans tout le code, et, utilisées dans un @if du template, elles produisent un contenu différent sur le serveur et dans le navigateur, c’est-à-dire un layout shift et des erreurs d’hydration. Gardez-le pour les effets de bord qui ne changent pas ce qui est rendu.

import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID, Service, inject } from '@angular/core';

@Service()
export class Analytics {
  // PLATFORM_ID vaut 'browser' ou 'server' ; ne le comparez pas à la main, utilisez les helpers de @angular/common
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

  track(event: string): void {
    if (!this.isBrowser) return; // ok : un effet de bord qui ne change pas ce qui est rendu
    navigator.sendBeacon('/collect', JSON.stringify({ event, at: Date.now() }));
  }
}

// ✗ PAS comme ça — le serveur et le navigateur rendent un contenu différent → layout shift et erreurs d’hydration :
//   @if (isBrowser) { <app-recommendations /> } @else { <p>Chargement…</p> }
// ✓ utilisez @defer (on viewport) ou afterNextRender pour ce qui n’a de sens que dans le navigateur.

Quand les plateformes ont besoin de comportements différents, l’approche propre est un contrat avec deux implémentations : les composants injectent la classe abstraite, app.config.ts fournit l’implémentation navigateur, et app.config.server.ts la remplace. Aucun if dans le code métier — le même principe que dans Providers et Injection tokens.

import { Injectable } from '@angular/core';

// Le contrat : les composants ne dépendent que de la classe abstraite.
export abstract class AnalyticsService {
  abstract trackEvent(name: string): void;
}

@Injectable()
export class BrowserAnalyticsService implements AnalyticsService {
  trackEvent(name: string): void {
    navigator.sendBeacon('/collect', name); // API navigateur — autorisée ici
  }
}

@Injectable()
export class ServerAnalyticsService implements AnalyticsService {
  trackEvent(_name: string): void {
    // sur le serveur : rien (ou un log), pas d’API navigateur
  }
}

TransferState

Sans rien de particulier, une page SSR charge ses données deux fois : une fois sur le serveur, pour rendre le HTML, et de nouveau dans le navigateur, car pendant l’hydration les composants repartent de zéro, avec un état vide. Résultat : une requête en plus et le contenu du serveur brièvement remplacé par un skeleton.

TransferState est un dictionnaire clé → valeur qui voyage dans le HTML. Sur le serveur, vous y écrivez ; avant la sérialisation, Angular appelle toJson() et place le résultat dans un <script id="ng-state" type="application/json"> à la fin de body — seulement s’il n’est pas vide. Dans le navigateur, à la première injection, TransferState relit le script. Les clés se créent avec makeStateKey<T>('…'), qui lie aussi le type de la valeur. L’API complète : get(key, default), set, remove, hasKey, isEmpty, onSerialize, toJson.

import { isPlatformServer } from '@angular/common';
import { PLATFORM_ID, Service, TransferState, inject, makeStateKey } from '@angular/core';

export interface Article { slug: string; title: string; body: string }

// Clé typée : TransferState sait qu’un Article se trouve dessous
const articleKey = (slug: string) => makeStateKey<Article>(`article:${slug}`);

@Service()
export class ArticleStore {
  private readonly cms = inject(CmsClient); // SDK avec son propre fetch → ne passe PAS par HttpClient
  private readonly state = inject(TransferState);
  private readonly isServer = isPlatformServer(inject(PLATFORM_ID));

  async load(slug: string): Promise<Article> {
    const key = articleKey(slug);
    const cached = this.state.get(key, null);
    if (cached) {
      this.state.remove(key); // utilisé une seule fois : les navigations suivantes demandent des données fraîches
      return cached;
    }
    const article = await this.cms.getArticle(slug);
    if (this.isServer) this.state.set(key, article); // atterrit dans <script id="ng-state">
    return article;
  }
}
LiveTransferState : une requête au lieu de deux (API réelle)

HTML envoyé par le serveur

Rien pour l’instant. Lancez le rendu sur le serveur.

Ce que voit l’utilisateur

La page n’est pas encore chargée.
0requêtes vers /api/products/3
L’étape 1 fait un vrai GET (via le backend simulé) et écrit dans TransferState ; le JSON du script est exactement le résultat de toJson(). L’étape 2 crée un nouveau TransferState à partir d’un document contenant ce script, exactement comme le fait le navigateur au bootstrap. Décochez « utiliser TransferState » et recommencez : la deuxième requête et le skeleton apparaissent.

Les valeurs passent par JSON.stringify / JSON.parse : une Date devient une chaîne, une Map et un Set deviennent {}, les instances de classe perdent leurs méthodes. Transférez de simples DTO (voir Interfaces & models). Tout ce que vous y placez est visible dans le code source de la page et alourdit le HTML : la démo montre combien de caractères cela ajoute.

Vous n’écrivez pas ceci pour HttpClient
Les GET via HttpClient sont transférés automatiquement (section suivante). Le TransferState manuel sert aux données qui ne passent pas par HttpClient : SDK (Firebase, un CMS), un fetch direct, des calculs coûteux faits une seule fois sur le serveur. Pour les resources, il existe aussi depuis la v22 resource({ id }).

Le cache de transfert HTTP

provideClientHydration() démarre, par défaut, un intercepteur qui applique exactement le schéma ci-dessus à HttpClient. Sur le serveur, il enregistre les réponses dans TransferState, sous une clé calculée comme un hash SHA-256 de la méthode, du type de réponse, de l’URL, du corps et des paramètres. Dans le navigateur, il les sert depuis le cache — mais seulement jusqu’à ce que l’application devienne stable pour la première fois ; ensuite tout passe par le réseau, comme d’habitude. httpResource passe par HttpClient, donc il en bénéficie automatiquement, tout comme vos intercepteurs (voir HTTP).

En 22.2.2, seules les requêtes GET et HEAD qui remplissent ces conditions sont mises en cache par défaut :

  • elles n’ont pas d’en-têtes Authorization, Proxy-Authorization ou Cookie ;
  • elles ne sont pas envoyées avec withCredentials ni avec credentials: 'include' / 'same-origin' ;
  • elles n’ont pas de Cache-Control contenant no-store, no-cache ou private (ni dans la requête ni dans la réponse), et n’utilisent pas cache: 'no-store' / 'no-cache' ;
  • leur réponse ne contient pas de Set-Cookie.
import { ApplicationConfig } from '@angular/core';
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    // Le cache de transfert est ACTIVÉ par défaut avec provideClientHydration() ;
    // withHttpTransferCacheOptions ne fait que le régler.
    provideClientHydration(
      withHttpTransferCacheOptions({
        // quels en-têtes de réponse sont sauvegardés (par défaut : aucun)
        includeHeaders: ['ETag'],
        // false → la requête n’est pas mise en cache (données propres à l’utilisateur, données qui changent souvent)
        filter: (req) => !req.url.includes('/api/cart'),
        // POST en lecture seule (p. ex. GraphQL) — seulement s’ils sont idempotents
        includePostRequests: true,
        // false par défaut : les requêtes avec Authorization / Proxy-Authorization / Cookie sont ignorées
        includeRequestsWithAuthHeaders: false,
      }),
    ),
  ],
};
OptionPar défautQuand la changer
filter(req)toutes les requêtes éligiblesvous retirez les endpoints propres à un utilisateur ou très volatils (false = non mis en cache)
includeHeadersaucun en-têtele navigateur a besoin de ETag, Link… Jamais d’en-têtes sensibles
includePostRequestsfalseun POST utilisé pour lire (GraphQL), seulement s’il est idempotent
includeRequestsWithAuthHeadersfalsel’en-tête d’authentification ne change pas la réponse (une clé publique)
includeRequestsWithCredentialsfalseles réponses avec credentials sont sûrement publiques
includeNonCacheableRequestsfalsevous ignorez consciemment Cache-Control / Set-Cookie (rare)

Les resources qui n’utilisent pas HttpClient v22

Depuis la v22, resource() et rxResource() acceptent un id. La valeur résolue sur le serveur est écrite dans TransferState sous cet id, et dans le navigateur la resource démarre directement à l’état resolved, sans rappeler le loader — là encore seulement tant que le cache d’hydration est actif, c’est-à-dire jusqu’à la première stabilité, et uniquement avec provideClientHydration() dans la configuration.

import { Component, inject, resource } from '@angular/core';

@Component({ selector: 'app-weather', template: `{{ forecast.value()?.summary }}` })
export class Weather {
  private readonly sdk = inject(WeatherSdk); // pas HttpClient → le cache HTTP ne le voit pas

  // v22 : avec un id, la valeur résolue sur le serveur est placée dans TransferState et réutilisée pendant l’hydration.
  // L’id doit être unique sur la page et identique sur le serveur et dans le navigateur.
  protected readonly forecast = resource({
    id: 'weather-bucharest',
    loader: () => this.sdk.forecast('Bucharest'),
  });
}

Des origines différentes sur le serveur et dans le navigateur

Si le serveur parle à l’API sur une adresse interne et le navigateur sur l’adresse publique, les URL diffèrent et les clés de cache ne correspondent plus. HTTP_TRANSFER_CACHE_ORIGIN_MAP (depuis @angular/common/http) indique au serveur de calculer la clé comme s’il avait utilisé l’origine publique. Il se déclare uniquement dans la configuration serveur : si Angular le trouve dans le navigateur, il lève l’erreur NG02803.

// api-base-url.ts — dans le navigateur : l’origine publique
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL', {
  factory: () => 'https://api.magazin.ro',
});

// app.config.server.ts (fragment) — sur le serveur : le réseau interne, sans passer par le load balancer
providers: [
  provideServerRendering(withRoutes(serverRoutes)),
  { provide: API_BASE_URL, useValue: 'http://catalog.internal:8080' },
  // les mêmes chemins sur les deux origines → les clés du cache de transfert correspondent
  // (uniquement dans la configuration serveur ; dans le navigateur, Angular lève une erreur s’il le trouve)
  {
    provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
    useValue: { 'http://catalog.internal:8080': 'https://api.magazin.ro' },
  },
];

Un détail de plus à partir de la v22, quand HttpClient utilise par défaut le backend fetch : sur le serveur, chaque corps de réponse est limité à 1 Mo (sinon l’erreur NG02825). La limite se change avec provideServerRendering({ maxResponseBodySize }, withRoutes(…)), mais le guide recommande de la garder petite : les gros téléchargements n’ont pas leur place dans le rendu serveur.

Stabilité et PendingTasks

Le serveur ne sait pas quand votre page est « prête ». Sa règle est simple : après le bootstrap, il attend ApplicationRef.whenStable() et ne sérialise qu’ensuite le DOM et TransferState. L’application est stable quand il n’y a plus de tâches dans PendingTasks. Angular ajoute des tâches de lui-même pour les requêtes HttpClient en cours, pour la navigation du routeur et pour les resources en chargement (resource, httpResource). Avec Zone.js, tout timer ou microtâche dans la zone Angular garde aussi l’application instable.

Les applications zoneless — le mode par défaut depuis la v21 — ne voient plus vos timers et vos promesses. Si vous chargez des données avec votre propre SDK, avec fetch ou après un setTimeout, le serveur sérialise avant l’arrivée des données : le HTML part vide, et le navigateur les redemande. Rendez-les visibles avec PendingTasks (stable depuis la v20 ; la méthode run et l’opérateur pendingUntilEvent sont encore en developer preview) :

import { Component, PendingTasks, inject, signal } from '@angular/core';
import { pendingUntilEvent, toSignal } from '@angular/core/rxjs-interop';

@Component({ selector: 'app-price', template: `{{ price() }} · {{ stock() }}` })
export class Price {
  private readonly tasks = inject(PendingTasks);
  private readonly sdk = inject(PricingSdk); // ses propres promesses, pas HttpClient
  readonly price = signal<string | null>(null);

  // 1. run() : la stabilité attend la fin de la fonction asynchrone (developer preview)
  constructor() {
    this.tasks.run(async () => this.price.set(await this.sdk.priceOf(3)));
  }

  // 2. add() : contrôle manuel — N’OUBLIEZ PAS le nettoyage, sinon le serveur n’arrête jamais d’attendre
  async refresh(): Promise<void> {
    const done = this.tasks.add();
    try {
      this.price.set(await this.sdk.priceOf(3));
    } finally {
      done();
    }
  }

  // 3. pendingUntilEvent() : instable jusqu’à la première émission / complete / error (developer preview)
  readonly stock = toSignal(this.sdk.stock$(3).pipe(pendingUntilEvent()), { initialValue: 0 });
}
LiveQuand le serveur sérialise-t-il ? (vrai whenStable, dans le navigateur)
appRef.isStable: true
Ce que le serveur enverrait (instantané à whenStable()):
<p class="price"></p>
Les deux boutons chargent un prix en 1,2 seconde puis attendent appRef.whenStable() — exactement ce que fait le serveur avant la sérialisation. Sans PendingTasks, whenStable se résout aussitôt et le « HTML » part vide ; avec PendingTasks.run(), il attend les données. Surveillez aussi la pastille isStable.
Une tâche qui ne se termine jamais = une requête qui ne se termine jamais
Le rendu serveur n’a pas de timeout : si une tâche de PendingTasks n’est jamais retirée (un add() sans finally, un Observable qui n’émet jamais avec pendingUntilEvent), la réponse reste bloquée jusqu’à ce que le proxy la coupe. Avec Zone.js, un setInterval démarré à l’initialisation produit le même effet. Le polling, les websockets et les timers récurrents n’ont pas leur place sur le serveur : démarrez-les dans afterNextRender.
import { provideStabilityDebugging } from '@angular/core';

// Actif par défaut en mode dev quand vous utilisez provideClientHydration().
// Manuellement : pour du SSR sans hydration ou dans un build de production, temporairement.
bootstrapApplication(App, {
  providers: [provideStabilityDebugging()],
});
// Si l’application ne devient pas stable en ~9 s, elle journalise dans la console les PendingTasks
// qui la tiennent occupée. Avec Zone.js, importez aussi 'zone.js/plugins/task-tracking' pour obtenir des stack traces.

Requête, réponse et statut 404

@angular/core exporte trois tokens pour la requête courante. @angular/ssr ne les remplit que pendant le rendu à la requête (RenderMode.Server) ; dans le navigateur, au prerender, au build et lors de l’extraction des routes en dev, ils valent null — utilisez donc toujours ?..

TokenContientSert à
REQUESTla Request de la Web API : url, headerscookies, Accept-Language, une URL absolue pour canonical / Open Graph
RESPONSE_INITune ResponseInit modifiable : status, statusText, headers404 / 410 dynamique, en-têtes calculés au rendu
REQUEST_CONTEXTunknown : le deuxième argument de handle(request, context)tenant, id de requête, l’utilisateur déjà authentifié par un middleware
import { Component, RESPONSE_INIT, inject } from '@angular/core';

@Component({
  selector: 'app-product-missing',
  template: `<h1>Ce produit n’existe plus</h1><a href="/produse">Voir le catalogue</a>`,
})
export class ProductMissing {
  constructor() {
    // null dans le navigateur, pendant le prerender (SSG) et au build — d’où le ?.
    const response = inject(RESPONSE_INIT);
    if (response) response.status = 404; // sinon : un « soft 404 » est indexé comme une vraie page
  }
}

Pourquoi le statut compte : une page « Le produit n’existe pas » servie avec un 200 est un soft 404, que les moteurs de recherche peuvent indexer comme une vraie page. Le status de la ServerRoute couvre les cas statiques (la route **) ; pour « l’id n’existe pas en base », il faut RESPONSE_INIT. Cela fonctionne parce que @angular/ssr ne construit la réponse qu’après whenStable() : d’ici là, vous pouvez changer le statut.

import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID, REQUEST, Service, inject } from '@angular/core';

@Service()
export class RequestInfo {
  private readonly request = inject(REQUEST); // Request de la Web API ; null dans le navigateur / en SSG
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

  /** Langue préférée : l’en-tête sur le serveur, navigator dans le navigateur. */
  preferredLanguage(): string {
    if (this.request) return this.request.headers.get('accept-language')?.split(',')[0].trim() || 'fr';
    // pas de requête : navigateur OU prerender. NE testez PAS « typeof navigator » — Node 21+ l’a.
    return this.isBrowser ? navigator.language : 'fr';
  }

  cookie(name: string): string | null {
    const raw = this.request?.headers.get('cookie') ?? '';
    const match = raw.split('; ').find((c) => c.startsWith(`${name}=`));
    return match ? decodeURIComponent(match.slice(name.length + 1)) : null;
  }
}

URL relatives et absolues

Sur le serveur, il n’y a pas de « page courante » par rapport à laquelle résoudre une URL relative. HttpClient règle le problème tout seul : @angular/platform-server ajoute un intercepteur qui transforme /api/… en URL absolue, à partir de l’URL de la requête et de <base href>. Un fetch() direct et les SDK qui l’utilisent n’en bénéficient pas.

// ✓ HttpClient sur le serveur : l’URL relative est résolue par rapport à l’URL de la requête courante
this.http.get('/api/products');          // → https://magazin.ro/api/products

// ✗ fetch() directement (ou un SDK qui l’utilise) — Node n’a pas de « page courante » :
await fetch('/api/products');            // TypeError: Failed to parse URL from /api/products

// ✓ si vous devez utiliser fetch() : construisez l’URL absolue à partir de la requête
const base = inject(REQUEST)?.url ?? inject(DOCUMENT).location.href;
await fetch(new URL('/api/products', base));

Précisément parce que les URL sont construites à partir de l’hôte de la requête, AngularNodeAppEngine vérifie le nom d’hôte par rapport à allowedHosts (dans angular.json, à build.options.security.allowedHosts, ou via la variable NG_ALLOWED_HOSTS) et répond 400 pour un hôte inconnu. C’est la protection contre le SSRF : configurez-la avec vos domaines.

État qui fuit entre les requêtes

Dans le navigateur, un module JavaScript est évalué une fois par onglet, c’est-à-dire par utilisateur. Sur le serveur, le même module est évalué une fois par processus et sert des milliers de requêtes. Toute variable au niveau du module devient un état partagé par tous les utilisateurs. Un provider avec useValue a le même problème : l’objet est créé au chargement du module et vit jusqu’au redémarrage du serveur — le guide SSR le dit explicitement et recommande useFactory.

Les services sont sûrs : à chaque requête, @angular/ssr crée une nouvelle plateforme et une nouvelle application, donc un nouvel injecteur root. Chaque utilisateur a ses propres instances, détruites après la réponse.

// ✗ État au niveau du module : le fichier est évalué UNE SEULE FOIS par processus Node,
//    donc les variables sont partagées par TOUTES les requêtes (et tous les utilisateurs).
let currentUser: User | null = null;          // l’utilisateur B voit l’utilisateur A
const cart = new Map<number, number>();       // le panier grossit à chaque requête

// ✗ Pareil avec useValue : l’objet est créé une seule fois, au chargement du module
{ provide: CART_STATE, useValue: { items: [] } }

// ✓ Un service : sur le serveur, chaque requête démarre une nouvelle application,
//    donc il obtient une nouvelle instance (détruite après la réponse).
@Service()
export class CartStore {
  private readonly items = signal<CartItem[]>([]);
  readonly count = computed(() => this.items().length);
}

// ✓ Ou useFactory : s’exécute pour chaque application créée
{ provide: CART_STATE, useFactory: () => ({ items: [] }) }
Le cache « inoffensif » à côté d’un service
const cache = new Map() au niveau du module ressemble à une optimisation. Sur le serveur, c’est une fuite mémoire (il grossit sans fin) et, si la clé n’inclut pas l’utilisateur, une fuite de données entre comptes. Si vous voulez un cache entre les requêtes, rendez-le explicite — dans server.ts ou au niveau du CDN — avec des clés, un TTL et une limite de taille.

Étude de cas : le thème

Le thème est l’exemple classique : la préférence vit dans localStorage, et le CSS dépend d’un attribut sur <html>. Ce site est une SPA, sans SSR, donc son ThemeStore est correct ici. Mais c’est un bon exercice : si nous passions au SSR demain, qu’est-ce qui casserait ?

// Le ThemeStore de ce site (fragment). Le site est une SPA, donc il est correct ICI.
@Service()
export class ThemeStore {
  private readonly document = inject(DOCUMENT);
  // ✗ sur le serveur : defaultView est le window de Domino, qui n’a PAS de matchMedia → TypeError
  private readonly media = this.document.defaultView?.matchMedia('(prefers-color-scheme: dark)');

  // readStoredMode() lit localStorage dans un try/catch : sur le serveur, il ne plante pas,
  //    mais renvoie toujours 'system' — le serveur ne peut pas savoir ce que l’utilisateur a choisi.
  readonly mode = signal<ThemeMode>(readStoredMode());

  constructor() {
    this.media?.addEventListener('change', (e) => this.systemDark.set(e.matches));
    effect(() => {
      // ✓ DOM via DOCUMENT : fonctionne aussi sur le serveur
      // localStorage dans un try/catch : sur le serveur, il est ignoré en silence
    });
  }
}

Deux problèmes. Le premier : la ligne avec matchMedia lève une exception à la création du service, et ?. n’aide pas, car sur le serveur defaultView existe (le fragment defaultView?.matchMedia(…) de la première démo reproduit exactement cela). Le second : le serveur rend toujours le thème system, car il ne voit pas localStorage. La variante SSR-safe déplace la préférence dans un cookie, que le serveur lit depuis REQUEST, et l’effect() écrit aussi l’attribut sur le serveur :

import { isPlatformBrowser } from '@angular/common';
import {
  DOCUMENT, DestroyRef, PLATFORM_ID, REQUEST, Service,
  afterNextRender, computed, effect, inject, signal,
} from '@angular/core';

export type ThemeMode = 'system' | 'light' | 'dark';
const COOKIE = 'atlas-theme';

@Service()
export class ThemeStore {
  private readonly document = inject(DOCUMENT);
  private readonly request = inject(REQUEST);
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

  // Le serveur ne sait pas si le système est en mode sombre : pour 'system', c’est le CSS qui décide
  // (prefers-color-scheme), et le signal est corrigé dans le navigateur.
  private readonly systemDark = signal(false);
  readonly mode = signal<ThemeMode>(this.readCookie());
  readonly isDark = computed(
    () => this.mode() === 'dark' || (this.mode() === 'system' && this.systemDark()),
  );

  constructor() {
    const destroyRef = inject(DestroyRef);
    afterNextRender(() => {
      const media = matchMedia('(prefers-color-scheme: dark)'); // navigateur uniquement
      const onChange = (e: MediaQueryListEvent) => this.systemDark.set(e.matches);
      this.systemDark.set(media.matches);
      media.addEventListener('change', onChange);
      destroyRef.onDestroy(() => media.removeEventListener('change', onChange));
    });

    effect(() => {
      const mode = this.mode();
      // s’exécute aussi sur le serveur → <html data-theme="dark"> est déjà dans le HTML, pas de flash
      const root = this.document.documentElement;
      if (mode === 'system') root.removeAttribute('data-theme');
      else root.setAttribute('data-theme', mode);
      // cookie (pas localStorage) : le serveur le reçoit à la requête suivante
      if (this.isBrowser) {
        this.document.cookie = `${COOKIE}=${mode}; path=/; max-age=31536000; samesite=lax`;
      }
    });
  }

  private readCookie(): ThemeMode {
    const raw = this.request?.headers.get('cookie') // serveur : l’en-tête de la requête
      ?? (this.isBrowser ? this.document.cookie : ''); // navigateur (sur le serveur, cookie lève une erreur)
    const value = raw
      .split('; ')
      .find((c) => c.startsWith(`${COOKIE}=`))
      ?.slice(COOKIE.length + 1);
    return value === 'light' || value === 'dark' ? value : 'system';
  }
}

L’alternative sans cookie : vous gardez localStorage et un script inline dans index.html qui définit data-theme avant le premier paint — exactement ce que fait ce site. Ça fonctionne aussi avec le SSR (le serveur envoie du HTML sans l’attribut, le script le place dans le navigateur avant l’application), mais le serveur ne connaît toujours pas le thème, donc il ne peut pas choisir, par exemple, les bonnes images ou theme-color.

<!-- L’alternative sans cookie (ce que fait ce site) : un script inline dans <head>,
     qui s’exécute avant le premier paint, dans n’importe quel mode de rendu (CSR, SSR, prerender). -->
<script>
  try {
    var t = localStorage.getItem('atlas-theme');
    if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
  } catch (e) {}
</script>

Quand OUI, quand NON

Un code SSR-safe ne signifie pas if (isBrowser) partout, mais chaque chose placée au bon endroit.

À utiliser
  • afterNextRender / afterRenderEffect pour tout ce qui touche au DOM, au layout, au stockage ou à une bibliothèque navigateur.
  • inject(DOCUMENT), Title, Meta pour tout ce qui doit être dans le HTML venant du serveur (SEO, partage).
  • Des providers de plateforme dans app.config.server.ts quand le serveur et le navigateur font des choses différentes.
  • Le cache HTTP par défaut pour les GET publics ; un TransferState manuel ou resource({ id }) pour les données qui ne passent pas par HttpClient.
  • PendingTasks ou resource() pour tout travail asynchrone dont le résultat doit se retrouver dans le HTML.
  • RESPONSE_INIT pour les statuts dynamiques, REQUEST pour les cookies et la langue.
À éviter (et quoi mettre à la place)
  • isPlatformBrowser dans un @if du template → @defer ou afterNextRender, avec le même HTML sur les deux plateformes.
  • typeof window saupoudré dans les composants → un service avec une implémentation serveur distincte.
  • TransferState pour de grandes listes ou des données propres à l’utilisateur sur des pages mises en cache au CDN → chargez-les dans le navigateur.
  • PendingTasks pour le polling, les websockets, l’analytics → démarrez-les dans afterNextRender ; le serveur ne doit pas les attendre. Un exemple complet se trouve sur la page WebSockets.
  • Cache HTTP pour des réponses sensibles → filter ou transferCache: false.
  • ngSkipHydration comme solution permanente → corrigez la manipulation directe du DOM.

Pièges fréquents

effect() s’exécute aussi sur le serveur
Beaucoup de gens considèrent effect() comme un « hook navigateur ». Ce n’est pas le cas : il s’exécute pendant la Change Detection, donc aussi sur le serveur. Un effect qui écrit dans localStorage, appelle matchMedia ou démarre un observer casse au rendu. Ce qui appartient au navigateur va dans afterRenderEffect ou sous afterNextRender.
?. n’est pas un garde de plateforme
document.defaultView?.matchMedia(…) et typeof navigator !== 'undefined' semblent sûrs, mais sur le serveur defaultView existe (le window de Domino, sans matchMedia), et Node 21+ a un navigator. Le premier lève une TypeError, le second donne la mauvaise réponse. Utilisez afterNextRender ou isPlatformBrowser.
  • Mesures dans le constructeur ou ngOnInit : elles cassent sur le serveur (il n’y a pas de layout). Dans le navigateur, le constructeur mesure 0, et dans ngOnInit les bindings et les blocs @if / @for du composant ne sont pas encore rendus. Correction : afterNextRender({ read }).
  • Timers et abonnements démarrés dans le constructeur : avec Zone.js ils bloquent la sérialisation, en zoneless ils continuent de tourner. Correction : démarrez-les dans afterNextRender et arrêtez-les avec DestroyRef.
  • pendingTasks.add() sans finally : une exception et la requête ne se termine jamais. Correction : try/finally ou run().
  • Asynchrone non suivi en zoneless : fetch, SDK, setTimeout → HTML vide + une requête de plus dans le navigateur. Correction : resource() ou PendingTasks.
  • Clés TransferState génériques ('data') ou un resource({ id }) statique dans un composant utilisé deux fois sur la page : les valeurs s’écrasent mutuellement. Correction : des clés qui incluent les paramètres (article:${slug}), des ids uniques par page.
  • La clé n’est pas retirée après lecture : une navigation ultérieure sur la même page récupère les anciennes données du HTML. Correction : state.remove(key) après la première utilisation.
  • HTTP_TRANSFER_CACHE_ORIGIN_MAP dans app.config.ts : erreur NG02803 dans le navigateur. Correction : uniquement dans app.config.server.ts.
  • Un fetch('/api/…') relatif sur le serveur : TypeError: Failed to parse URL. Correction : HttpClient ou une URL absolue construite depuis REQUEST.
  • Une page « introuvable » avec le statut 200 : un soft 404 dans Google. Correction : RESPONSE_INIT ou status: 404 sur la route **.
  • Des valeurs qui diffèrent entre serveur et navigateur : Date.now(), Math.random(), des dates formatées dans le fuseau horaire du serveur (souvent UTC). Le texte change à l’hydration. Correction : générez-les une fois sur le serveur et transférez-les, ou affichez-les uniquement dans le navigateur.
  • État au niveau du module et useValue avec des objets : partagés entre utilisateurs. Correction : services ou useFactory.

Aide-mémoire

Vous voulez…À utiliser
toucher au DOM / au layout une seule fois, après le renduafterNextRender({ earlyRead, write, read })
synchroniser le DOM avec certains signalsafterRenderEffect(() => …)
mesurer après chaque renduafterEveryRender({ read })
charger une bibliothèque qui touche à windowawait import('lib') dans afterNextRender, ou @defer
lire / écrire title, meta, canonicalinject(DOCUMENT), Title, Meta
une implémentation différente sur le serveurune classe abstraite + un provider dans app.config.server.ts (mergeApplicationConfig)
un effet de bord uniquement dans le navigateurisPlatformBrowser(inject(PLATFORM_ID))
ne pas répéter les GET dans le navigateurprovideClientHydration() (par défaut), withHttpTransferCacheOptions(…)
retirer une requête du cachefilter ou { transferCache: false }
transférer des données qui ne passent pas par HttpClientmakeStateKey<T>() + TransferState, ou resource({ id }) (v22)
des origines d’API différentes sur le serveur et dans le navigateurHTTP_TRANSFER_CACHE_ORIGIN_MAP, uniquement sur le serveur
que le serveur attende un traitement asynchroneresource(), PendingTasks.run() / add(), pendingUntilEvent()
savoir pourquoi l’application ne devient pas stableprovideStabilityDebugging()
lire les cookies / en-têtesinject(REQUEST)?.headers.get('cookie')
répondre avec un 404inject(RESPONSE_INIT) → status = 404, ou status: 404 sur la route **
recevoir du contexte depuis server.tshandle(req, ctx) → inject(REQUEST_CONTEXT)
un état propre à l’utilisateur sur le serveurservices ou useFactory — jamais de variables de module

Chap. 12 · 0 sur 3 terminées

Leçon terminée ?

Marquez-la : elle apparaîtra sur votre carte, dans le sommaire et sur la page d’accueil.

Se marque toute seule une fois la leçon lue en entier.

  1. SSR, Prerendering et modes de rendu
  2. Hydration : event replay et incrémentale
  3. Code compatible serveur
Voir toute votre carte

Les 75 pages · tapez pour filtrer

  • 01Nouveautés d’Angular 21 & 22Tous les changements importants de la v21 et de la v22, en bref, avec un lien vers la page qui explique chacun.Départ
  • 01Votre première application, pas à pasUne liste de lecture construite d’un composant vide jusqu’à une application terminée — signals, @for, inputs et outputs, un service et un formulaire, pas à pas.Départ
  • 01Décisions rapides« Où mettre ça ? », « Signal ou Observable ? », « Shared ou feature ? » — des réponses courtes aux questions de tous les jours.Départ
  • 01Questions d’entretien Angular60 vraies questions d’entretien pour profils junior, confirmé et senior, avec la réponse à donner, la relance et les signaux d’alerte. Plus un entretien blanc.Départ
  • 02Standalone et bootstrapComment une application démarre sans NgModule : bootstrapApplication, app.config.ts, imports dans le composant.Fondamentaux modernes
  • 02Data binding et événementsInterpolation, bindings de propriétés et d’attributs, événements avec $event et filtres de touches, et comment [( )] est construit — ce qui entre dans le DOM et ce qui en revient.Fondamentaux modernes
  • 02Control Flow : @if, @for, @switchLa syntaxe de blocs qui remplace *ngIf/*ngFor/ngSwitch, avec track, @empty et vérifications d’exhaustivité.Fondamentaux modernes
  • 02@let et les nouvelles expressions de templateVariables locales avec @let, fonctions fléchées, spread, regex, instanceof, commentaires dans les balises.Fondamentaux modernes
  • 02@defer — Lazy Loading dans les templatesChargez les composants lourds uniquement quand c’est nécessaire : triggers, prefetch, placeholder, loading, error.Fondamentaux modernes
  • 02Bindings de classe et de stylePourquoi [class.x] et [style.x] ont remplacé ngClass et ngStyle (avec migration automatique depuis la v21).Fondamentaux modernes
  • 02Styles des composants et encapsulation:host, les trois modes d’encapsulation, les thèmes avec des variables CSS, et pourquoi ::ng-deep est un dernier recours.Fondamentaux modernes
  • 02Pipes : quand et commentPipes pures vs impures, pipes personnalisées, et comment choisir entre une pipe, computed() et une méthode.Fondamentaux modernes
  • 03signal() et computed()Les primitives réactives : état modifiable, état dérivé, égalité, untracked et règles d’immutabilité.Signals et réactivité
  • 03effect() — quand OUI, quand NONEffets de bord synchronisés avec les Signals : localStorage, logs, API impératives. Et leurs pièges.Signals et réactivité
  • 03linkedSignal() — état dérivé modifiableUn signal qui se réinitialise quand sa source change, mais que vous pouvez aussi modifier à la main.Signals et réactivité
  • 03resource, rxResource, httpResourceDes données asynchrones sous forme de Signals : status, value, error, reload, annulation automatique. Stable depuis la v22.Signals et réactivité
  • 03debounced() — signals différésLa version « debounce » d’un signal, sans RxJS. Parfait pour la recherche.Signals et réactivité
  • 03Listes : rechercher, filtrer, trier, paginerUne chaîne de signals computed qui ne recalcule que ce qui a changé, avec les filtres dans l’URL pour qu’un lien rouvre la même vue.Signals et réactivité
  • 03Les Observables depuis zéroCe qu’est un Observable, subscribe et unsubscribe, les opérateurs que vous utilisez chaque semaine, froid ou chaud — et quand un signal suffit.Signals et réactivité
  • 03Signals ↔ RxJStoSignal, toObservable, takeUntilDestroyed, outputFromObservable — et quand RxJS reste le bon choix.Signals et réactivité
  • 04input() et output()Les inputs comme Signals (required, transform, alias) et les outputs typés. Les données descendent, les événements remontent.Composants : communication
  • 04model() — binding bidirectionnelLe « banana-in-a-box » [( )] avec une seule API. Quand l’utiliser, et quand input + output est préférable.Composants : communication
  • 04viewChild et contentChildLes queries comme Signals : viewChild, viewChildren, contentChild, contentChildren, l’option read.Composants : communication
  • 04Lifecycle et DestroyRefQuels hooks comptent encore à l’ère des Signals, afterNextRender, DestroyRef et ngOnChanges typé.Composants : communication
  • 04Projection de contenung-content avec select, plusieurs slots, contenu par défaut et le piège de la projection conditionnelle.Composants : communication
  • 04ng-template et ngTemplateOutletLes templates comme valeurs : les render props en Angular, contexte typé et personnalisation des composants.Composants : communication
  • 05Smart vs DumbContainer vs présentation : qui détient l’état, qui se contente d’afficher, et comment bien les séparer.Conception des composants
  • 05Composants génériques <T>Un DataTable<T> ou un Select<T> correctement typé, avec des templates pour les cellules.Conception des composants
  • 05Composants dynamiquesNgComponentOutlet, createComponent avec inputBinding/outputBinding, et un tableau de bord construit à partir d’une configuration.Conception des composants
  • 05Directives et composition d’hôteLes directives d’attribut, les métadonnées host: {} et la Directive Composition API (hostDirectives).Conception des composants
  • 05Contrôles de formulaire personnalisésFormValueControl (Signal Forms) vs ControlValueAccessor : une notation par étoiles, dans les deux variantes.Conception des composants
  • 05Change DetectionOnPush est la valeur par défaut depuis la v22, Zone.js a disparu depuis la v21. Ce qui déclenche le rendu et comment éviter les mauvaises surprises.Conception des composants
  • 06inject() et @Service()Le nouveau décorateur @Service() de la v22, inject() vs le constructeur, et le contexte d’injection.Dependency Injection
  • 06Providers et hiérarchie des injecteursRoot vs route vs composant, useClass/useValue/useFactory/useExisting, et les modificateurs optional/self/skipSelf.Dependency Injection
  • 06InjectionToken et provideX()Configuration typée, fonctions provideFeature(), makeEnvironmentProviders et initializers.Dependency Injection
  • 06injectAsync() — services en Lazy LoadingChargez un service lourd uniquement quand vous en avez besoin, avec prefetch à l’idle.Dependency Injection
  • 07Structure des dossierscore / shared / features, ce que contient chacun, et à quoi ressemble l’intérieur d’une feature.Architecture d’application
  • 07Shared vs featureQuand déplacer un composant dans shared et (surtout) quand NE PAS le faire. Une checklist concrète.Architecture d’application
  • 07Interfaces et modèlesInterface en ligne ou fichier séparé ? DTO vs modèle de domaine, type vs interface, où les ranger.Architecture d’application
  • 07State Management avec les SignalsStores simples à base de Signals, facades, état local vs global, et quand NgRx devient nécessaire.Architecture d’application
  • 07Guide de style et nommageLes conventions officielles actuelles : noms de fichiers sans suffixes, protected, readonly, ordre des membres.Architecture d’application
  • 07Barrels, alias et frontièresindex.ts, alias de chemins, API publique d’une feature et règles de dépendances vérifiables automatiquement.Architecture d’application
  • 07Erreurs : @boundary et ErrorHandlerLe nouveau bloc @boundary de la v22.2, l’ErrorHandler global, les erreurs HTTP et les erreurs de resource.Architecture d’application
  • 08Routing moderne et Lazy LoadingloadComponent, routes enfants, paramètres comme inputs et héritage des paramètres (le nouveau comportement par défaut de la v22).Routing
  • 08Guards et resolverscanActivate, canMatch, canDeactivate et resolve sous forme de fonctions, avec redirections via UrlTree / RedirectCommand.Routing
  • 09Signal Forms — les basesDes formulaires bâtis sur les Signals : form(), [formField], validation par schéma. Stable depuis la v22.Formulaires
  • 09Signal Forms — avancéValidation personnalisée et inter-champs, async/HTTP avec debounce, listes dynamiques, désactivé/masqué, schémas réutilisables.Formulaires
  • 09Reactive Forms typésFormGroup typé, nonNullable, FormArray — et leur cohabitation avec les Signal Forms.Formulaires
  • 10HttpClient, interceptors et couche d’APIHttpClient sans configuration (v21), Fetch par défaut (v22), interceptors fonctionnels et une couche d’API avec mapping des DTO.HTTP et données
  • 11Performance — une checklistCe qui compte vraiment : OnPush + Signals, track, @defer, routes en Lazy Loading, images, bundles et Hydration.Qualité et performance
  • 11Tests avec VitestVitest est le runner par défaut depuis la v21. TestBed, composants avec Signals, services, HTTP et harnesses.Qualité et performance
  • 11DevTools : regarder dans votre applicationChrome DevTools et Angular DevTools pas à pas : ce que montre chaque panneau, avec des exercices qui fonctionnent sur ce site.Qualité et performance
  • 11Angular AriaDes composants headless et accessibles que vous stylez comme vous voulez : tabs, accordion, menu, listbox...Qualité et performance
  • 12SSR, Prerendering et modes de renduQuand rendre sur le serveur, quand au build et quand uniquement dans le navigateur : RenderMode par route, app.routes.server.ts et les coûts réels.SSR et Hydration
  • 12Hydration : event replay et incrémentaleComment le navigateur reprend le HTML du serveur sans le re-rendre : Hydration complète, event replay, @defer (hydrate on …) et erreurs de mismatch.SSR et Hydration
  • 12Code compatible serveurwindow n’existe pas sur le serveur : PLATFORM_ID, afterNextRender, DOCUMENT, TransferState, le cache HTTP et PendingTasks.SSR et Hydration
  • 13Micro frontendsQuand découper une application en plusieurs déploiements vaut le coup, et quand c’est juste de la complexité : Native Federation, Module Federation, iframes, Web Components.Architecture à grande échelle
  • 13Web Components avec Angular ElementscreateCustomElement : un composant Angular utilisé comme balise HTML n’importe où, plus la communication entre applications via des événements et BroadcastChannel.Architecture à grande échelle
  • 13Monorepo, bibliothèques et frontièresUn workspace avec plusieurs applications et bibliothèques : ng generate library, Nx, points d’entrée secondaires, publication et règles de dépendances.Architecture à grande échelle
  • 13Architecture hexagonale (ports et adapters)Un domaine sans Angular, des ports sous forme de classes abstraites et des adapters interchangeables : HTTP, localStorage ou en mémoire, sans toucher à l’UI.Architecture à grande échelle
  • 14Les Signals sous le capotLe graphe réactif : producteurs et consommateurs, push-pull, pourquoi computed est lazy et sans glitch, le problème du diamant et quand les effects s’exécutent.Réactivité avancée
  • 14RxJS avancé : opérateurs d’ordre supérieurswitchMap, mergeMap, concatMap et exhaustMap sur un diagramme interactif, plus retry avec backoff, shareReplay et comment éviter les fuites.Réactivité avancée
  • 14NgRx SignalStoreState Management à grande échelle sur les Signals : withState, withComputed, withMethods, rxMethod, entités et hooks — et quand un store simple suffit.Réactivité avancée
  • 14Machines à états avec les signalsFini les états impossibles : une machine typée sur des signals, avec transitions, gardes et un diagramme d’états en direct.Réactivité avancée
  • 15WebSockets : un temps réel qui résiste au réseauUn socket typé sous forme de signals : reconnexion avec backoff et jitter, heartbeat, file d’attente hors ligne et sécurité SSR.Temps réel et hors ligne
  • 15Mises à jour optimistes et rollbackUne UI instantanée qui reste honnête : snapshot et rollback, réponses dans le désordre, clés d’idempotence.Temps réel et hors ligne
  • 15Offline-first : Service Worker et cacheLe Service Worker d’Angular : stratégies de cache, annonce des nouvelles versions et file d’attente pour les modifications faites hors ligne.Temps réel et hors ligne
  • 16Web WorkersDes calculs lourds sans bloquer l’UI : un vrai worker dans la page, l’échange de messages et quand ça n’en vaut pas la peine.UI avancée et performance
  • 16Listes géantes : virtual scroll100 000 lignes sans latence : le virtual scroll du CDK, itemSize, trackBy et les alternatives (pagination, scroll infini).UI avancée et performance
  • 16Animations modernes et View Transitionsanimate.enter et animate.leave à la place de @angular/animations (déprécié), transitions CSS et View Transitions à la navigation.UI avancée et performance
  • 16Fuites mémoire : les trouver et les corrigerAbonnements, timers et listeners qui survivent à leur composant — comment les repérer dans les DevTools et les fermer.UI avancée et performance
  • 17Sécurité : XSS, sanitisation et CSPCe qu’Angular fait automatiquement contre le XSS, quand bypassSecurityTrust devient dangereux, plus la CSP avec nonce, Trusted Types et XSRF.Sécurité et internationalisation
  • 17Authentification : tokens et refreshUn interceptor qui ajoute le token, le renouvelle une seule fois sur une 401 et rejoue les requêtes en attente — plus où conserver le token.Sécurité et internationalisation
  • 17OAuth 2.1 et OpenID Connect avec PKCELe flux authorization code avec PKCE, pas à pas : verifier et challenge, state et nonce, l’ID token, et pourquoi un BFF est plus sûr.Sécurité et internationalisation
  • 17Internationalisation (i18n)$localize, l’attribut i18n, ICU plural/select, un build par langue vs des traductions à l’exécution, et le formatage selon la locale.Sécurité et internationalisation