Skip to content

Versions Angular 16 → 20

TL;DR — Entre Angular 16 (mai 2023) et Angular 20 (mai 2025), le framework s'est transformé en profondeur : Signals (v16 preview, v17 stable, v20 forms), control flow natif (@if/@for/@switch en v17), standalone par défaut (v17, modules deviennent l'exception), @defer (v17, hydratation incrémentale en v19), SSR refondu sous @angular/ssr (v17), zoneless change detection (v18 preview, v20 stable), @angular/localize et builder esbuild (v17 par défaut), et tout un écosystème de primitives reactives : linkedSignal, resource, httpResource (v19+). Migrer 16 → 20 se fait via ng update, idéalement une version majeure à la fois, avec validation des schematics et des breaking changes. Les nouveaux projets démarrent en standalone + signals + control flow + esbuild, ce qui rend la stack plus simple à apprendre qu'avant. Cette page sert de playbook de migration version par version, avec gotchas et recommandations.

Compatibilité TypeScript / Node par version

AngularTypeScriptNode.js (LTS supportés)RxJSSortie
164.9 – 5.116, 186.5, 7.4+mai 2023
175.2 – 5.418.13+, 206.6, 7.4+novembre 2023
185.4 – 5.518.19+, 20.11+, 227.4+mai 2024
195.5 – 5.618.19+, 20.11+, 227.4+, 8 OKnovembre 2024
205.6 – 5.820.11+, 22, 247+, 8mai 2025

Règle : avant ng update, vérifier que la version de Node et de TS sont supportées par la version cible. Sinon, mettre à jour Node/TS d'abord. Angular suit le calendrier LTS de Node ; les versions retirées sont parfois exclues prématurément en prod.

⚠️ Versions exactes : ce tableau est une carte de tendance, pas un contrat. La matrice de compat précise vit dans le package.json de @angular/core (champ peerDependencies + ng-update). Avant une migration de prod, lisez-la pour la version patch cible, pas pour la majeure générique.

Le fil rouge : Angular passe de « pull-on-tick » à « push réactif »

Si vous venez de PHP/TS et que ces 5 versions vous semblent un sac de features sans direction, voici le modèle mental unique qui les relie. Toute la roadmap 16 → 20 sert un seul virage : abandonner la détection de changement par balayage global au profit d'un graphe de dépendances réactif.

ÈreComment Angular sait « quoi re-render »CoûtVersions
Zone.js (legacy)Zone.js monkey-patche setTimeout, addEventListener, fetch… À chaque événement async, il déclenche un tick qui balaie tout l'arbre de composants (Default) ou les branches markForCheck (OnPush).O(n) composants par tick, même si rien n'a changé.≤ 16
Signals + Zone.jsLes signaux lus dans un template créent un lien de dépendance précis. Un signal.set() marque uniquement les composants consommateurs dirty. Zone.js déclenche encore les ticks.O(composants impactés), mais Zone.js paie toujours son overhead.17–19
ZonelessPlus de Zone.js. La CD est planifiée par les notifications de signaux (et markForCheck/AsyncPipe/event bindings). Le scheduler coalesce dans un microtask + requestAnimationFrame.O(composants impactés), zéro overhead Zone.js, bundle −25 KB.18 preview → 20 stable

Conséquence pour vous, ex-PHP/TS : en PHP, une requête = un rendu, le state meurt à la fin du script. En Angular legacy, le rendu est imprévisible — il arrive à chaque tick. En Angular zoneless, on revient à un modèle déterministe et causal : « j'ai changé CETTE donnée, donc CE bout d'écran se redessine, point ». signalcomputedeffectresource/httpResource → signal forms ne sont pas des features isolées : ce sont les nœuds d'un même graphe réactif. Comprenez le graphe, et les 5 versions deviennent une seule idée déroulée.

Comment un staff engineer arbitre la migration

  • La dette n'est pas « être en v16 », c'est « être bloqué sur Zone.js + Default CD ». La valeur business d'une migration est dans le passage à OnPush/signals, pas dans le numéro de version. Migrer v16→v20 sans toucher la CD ne vous donne presque rien sur la perf runtime.
  • ng update est mécanique ; le refactor signal est stratégique. Séparez les deux : d'abord rester iso-fonctionnel version par version (Zone.js gardé), puis convertir les hot paths mesurés au profiler en signals. Ne signal-ifiez jamais à l'aveugle 400k lignes.
  • Le risque #1 d'une migration zoneless, ce sont les libs tierces. Toute lib qui suppose que setTimeout/Promise déclenche un re-render (sans signal/markForCheck) cassera silencieusement. Auditez node_modules pour les patterns Zone-dépendants avant de promettre une date.
  • Le risque #2, ce sont vos tests. fakeAsync/tick() reposent sur les patches Zone.js des timers : en zoneless ils ne fonctionnent plus. async/await + fixture.whenStable() les remplacent, et fixture.detectChanges() reste nécessaire car en test on ne laisse pas tourner le scheduler. Compter le coût de réécriture de la suite de tests dans l'estimation, pas seulement le code applicatif.

Le coût réel d'une migration : ordre de grandeur

Postev16→v17v17→v18v18→v19v19→v20Ce qui domine le coût
ng update + schematicsfaiblefaiblefaiblefaibleMécanique, automatisé
Builder esbuild (SCSS, loaders Webpack)élevénulnulnulLoaders custom, raw-loader, chemins SCSS absolus
Refactor signals (hot paths)optionnelmoyenmoyenmoyenStratégique, mesuré au profiler
Audit libs tiercesfaiblefaiblemoyenélevéZone-dépendance silencieuse
Réécriture tests (fakeAsync)nulnulfaibleélevétick()whenStable()
Material MDCfaiblefaibleélevéfaiblemat-legacy-* supprimés

Lecture staff : le coût n'est pas linéaire avec le numéro de version. Les deux sauts chers sont v16→v17 (changement de builder) et v19→v20 (zoneless + tests). Planifiez-les comme des epics, pas comme des chores. Les sauts intermédiaires sont quasi-gratuits si vous restez iso-fonctionnel.

Angular 16 — Signals preview, hydration non-destructive

Mai 2023. La v16 est la première version « moderne » d'Angular. C'est ici qu'arrive l'API signal(), même si elle reste marquée preview.

Nouveautés majeures

  • Signals (signal, computed, effect) en preview. Première brique de la « reactive primitives roadmap ». Pas encore intégrés au change detection : les signaux dans un template fonctionnent, mais Zone.js reste obligatoire.
  • Required inputs : @Input({ required: true }) name!: string — le compilateur exige la présence du binding.
  • Input transforms : @Input({ transform: numberAttribute }) age!: number — conversion automatique au binding.
  • takeUntilDestroyed() — opérateur RxJS qui se base sur DestroyRef, élimine les Subject<void> manuels pour le teardown.
  • Hydratation non-destructive (SSR) — provideClientHydration() réutilise le DOM serveur au lieu de le détruire.
  • @angular/cli esbuild builder en preview (browser-esbuild), build 2-3× plus rapide.
  • Self-closing tags dans les templates : <app-foo /> au lieu de <app-foo></app-foo>.
  • Standalone reste opt-in ; les modules sont la norme.

Breaking changes

  • Node 14 et 16.10 abandonnés (Node 16.14+ minimum, recommandé 18).
  • RxJS 6 toujours supporté mais déprécié.
  • ngcc (compatibility compiler pour libs ViewEngine) supprimé.

Recommandations

  • Tester les signals sur un composant nouveau, pas migrer l'existant tout de suite.
  • Activer provideClientHydration() si SSR : gain LCP immédiat.
  • Migrer les Subject<void> + takeUntil vers takeUntilDestroyed().

Exemples

ts
import { Component, Input, signal, computed } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { timer } from 'rxjs';

@Component({ /* ... */ })
export class CounterComponent {
  // v16 : signal API en preview
  count = signal(0);
  double = computed(() => this.count() * 2);

  // Required input
  @Input({ required: true }) label!: string;
  @Input({ transform: (v: string) => v.trim().toLowerCase() }) tag = '';

  constructor() {
    timer(0, 1000)
      .pipe(takeUntilDestroyed())
      .subscribe(() => this.count.update((c) => c + 1));
  }
}

Angular 17 — Control flow, standalone par défaut, defer

Novembre 2023. Version rupture esthétique et technique — nouveau logo, nouveau site (angular.dev), nouvelle syntaxe de templates.

Nouveautés majeures

  • Control flow natif : @if, @else if, @else, @for (item of items; track item.id), @switch, @case, @default. Remplace *ngIf/*ngFor/*ngSwitch (toujours fonctionnels mais dépréciés à terme). Compile en plus petit, plus rapide, et le track est désormais obligatoire sur @for (perf garantie).
  • @defer : lazy rendering avec triggers (on idle, on viewport, on interaction, on hover, on timer(2s), on immediate, when condition). Composants chargés à la demande, avec @placeholder / @loading / @error.
  • Standalone par défaut dans ng new. Le flag --standalone n'est plus nécessaire ; pour les modules : --no-standalone.
  • Application builder (esbuild + Vite) par défaut. Build prod 2-4× plus rapide, dev server quasi-instantané grâce à Vite (HMR ESM natif).
  • @angular/ssr : nouveau package officiel remplaçant @nguniversal/express-engine. ng add @angular/ssr génère server.ts moderne.
  • Signal-based view queries : viewChild(), viewChildren(), contentChild(), contentChildren() retournent des signaux.
  • View Transitions API (withViewTransitions() dans provideRouter) — animations natives entre routes.
  • afterRender / afterNextRender lifecycle hooks pour code DOM-only.
  • Logo réétudié, slogan « The web development framework ».

Breaking changes

  • Node 16 abandonné (Node 18.13+ minimum).
  • TypeScript 5.2 minimum.
  • Webpack builder toujours là mais l'app builder devient la norme.
  • L'API routerLink reste mais la résolution interne change : tester les liens.

Recommandations

  • Migrer le control flow via ng generate @angular/core:control-flow (schematic).
  • Adopter @defer sur les composants lourds (modals, charts, formulaires longs).
  • Activer View Transitions pour SPA fluide.
  • Standalone : migrer les modules avec ng generate @angular/core:standalone (3 étapes : convert components, replace modules, remove root module).

Exemples

html
<!-- v17 : control flow natif -->
@if (user(); as u) {
  <p>Bonjour {{ u.name }}</p>
} @else {
  <a routerLink="/login">Connexion</a>
}

@for (item of items(); track item.id) {
  <li>{{ item.label }}</li>
} @empty {
  <li>Aucun élément</li>
}

@switch (status()) {
  @case ('loading') { <app-spinner /> }
  @case ('error')   { <app-error /> }
  @default          { <app-content /> }
}

@defer (on viewport) {
  <app-comments />
} @placeholder {
  <div class="skeleton"></div>
} @loading (minimum 500ms) {
  <app-spinner />
}
ts
// Signal-based queries
import { Component, viewChild, ElementRef, afterNextRender } from '@angular/core';

@Component({ template: '<input #search />' })
export class SearchComponent {
  searchInput = viewChild<ElementRef<HTMLInputElement>>('search');

  constructor() {
    afterNextRender(() => this.searchInput()?.nativeElement.focus());
  }
}

Angular 18 — Signal materialization, zoneless preview, route input binding

Mai 2024. Pas de gros changement visuel, mais fondation du futur zoneless.

Nouveautés majeures

  • Signal materialization : les signals sont enregistrés dans le change detection. Quand un signal lu dans un template change, seul le composant concerné est marqué dirty (OnPush automatique). Performance considérable sur les arbres profonds.
  • provideExperimentalZonelessChangeDetection() — preview du mode zoneless. Pas de Zone.js, change detection déclenché par signals et markForCheck. Bundle plus léger (-20 KB gzip).
  • Route input binding : withComponentInputBinding() dans provideRouter mappe les query params et path params aux @Input() du composant route. Plus besoin d'injecter ActivatedRoute.
  • Event replay (SSR) : withEventReplay() capture les clics avant la fin de l'hydratation (utilise JSAction de Google).
  • @let dans les templates : déclare une variable locale.
  • MatPaginator etc. : Material Design 3 stabilisé.
  • @defer stable (était préliminaire en v17).
  • Form events stream : form.events observable unifié (StatusChangeEvent, ValueChangeEvent, etc.).
  • Build-time prerendering parallélisé.

Breaking changes

  • Node 18.13+ minimum (18.19+ recommandé).
  • TypeScript 5.4+.
  • @angular/cdk migration vers nouveaux imports standalone.
  • Quelques options dépréciées dans angular.json (browserTargetbuildTarget).

Recommandations

  • Tester provideExperimentalZonelessChangeDetection sur un nouveau projet ou une feature isolée. Vérifier que les libs tierces sont compatibles (RxJS, Material — OK ; certaines anciennes non).
  • Adopter withComponentInputBinding() pour simplifier les composants de route.
  • Migrer vers @let pour les déstructurations longues dans les templates.

Exemples

ts
// app.config.ts
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';

export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalZonelessChangeDetection(),
    provideRouter(routes, withComponentInputBinding()),
  ],
};
ts
// route input binding — plus besoin d'ActivatedRoute
import { Component, Input } from '@angular/core';

@Component({ /* route: /products/:id */ template: '...' })
export class ProductPage {
  @Input() id!: string;          // path param
  @Input() filter?: string;      // query param
}
html
<!-- @let dans le template -->
@let user = currentUser();
@let fullName = user.firstName + ' ' + user.lastName;
<h1>{{ fullName }}</h1>
<p>Email : {{ user.email }}</p>

Angular 19 — linkedSignal, resource, incremental hydration

Novembre 2024. Renaissance reactive : les primitives signal s'étendent à des cas plus complexes.

Nouveautés majeures

  • linkedSignal : un signal dérivé mais writable. Quand sa source change, il se ré-initialise ; entre-temps, on peut le set(). Idéal pour les états UI dérivés (selected item, form draft).
  • resource() : primitive de chargement asynchrone basée sur signals. Remplace les patterns BehaviorSubject + switchMap. Statuts (loading, error, value), reload, et integration avec Suspense-like.
  • httpResource() : resource() spécialisé pour HTTP, sans avoir à écrire le loader.
  • Incremental hydration stable : @defer (hydrate on viewport), hydrate on idle, hydrate on interaction, hydrate never. Hydrate uniquement ce qui est visible/utilisé.
  • Route-level render modes : RenderMode.Prerender | Server | AppShell | Client par route via app.routes.server.ts.
  • Standalone par défaut partout : ng new ne génère plus de NgModule. Schematic @angular/core:standalone peut migrer les apps restantes.
  • provideAppInitializer(fn) : remplace le verbeux APP_INITIALIZER (avec useFactory, multi: true). Fonction async simple avec inject().
  • Zoneless development preview stabilisé : provideExperimentalZonelessChangeDetection reste, mais avec moins de bugs.
  • CSP-friendly inline styles : Angular génère des hashes pour les <style> inlinés.
  • Hot module replacement stable pour les styles (Vite).

Breaking changes

  • TypeScript 5.5+.
  • Node 18.19+ ou 20.11+.
  • @angular/material MDC stable, retire les vieux composants legacy.
  • Deprecation de *ngIf/*ngFor/*ngSwitch annoncée pour suppression future (probablement v20-21).

Recommandations

  • Adopter resource et httpResource pour les nouveaux écrans de listing/détail.
  • Activer l'incremental hydration sur les pages SSR lourdes.
  • Migrer les APP_INITIALIZER vers provideAppInitializer.
  • Tester sérieusement le mode zoneless en preprod.

Exemples

ts
import { Component, signal, linkedSignal, resource } from '@angular/core';
import { httpResource } from '@angular/common/http'; // ⚠️ httpResource vit dans @angular/common/http, PAS @angular/core

@Component({ /* ... */ })
export class ProductPage {
  // linkedSignal : dérivé, mais peut être écrit
  productId = signal(1);
  draftName = linkedSignal({
    source: this.productId,
    computation: (id, previous) => `Brouillon produit ${id}`,
  });

  // resource : chargement async signal-based
  // ⚠️ Le champ s'appelle `request` en v19 ; renommé `params` à partir de v20 (le loader reçoit `{ params, abortSignal }`).
  product = resource({
    request: () => ({ id: this.productId() }),  // v20 : `params: () => ...`
    loader: async ({ request, abortSignal }) => { // v20 : `({ params, abortSignal })`
      const res = await fetch(`/api/products/${request.id}`, { signal: abortSignal });
      return res.json();
    },
  });

  // httpResource : version HTTP simplifiée
  productHttp = httpResource(() => `/api/products/${this.productId()}`);
}
html
@if (product.isLoading()) {
  <app-spinner />
} @else if (product.error()) {
  <app-error [err]="product.error()!" />
} @else {
  <h1>{{ product.value()?.name }}</h1>
  <button (click)="product.reload()">Recharger</button>
}
ts
// app.routes.server.ts — route-level render modes
import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  { path: '', renderMode: RenderMode.Prerender },
  { path: 'pricing', renderMode: RenderMode.Prerender },
  { path: 'dashboard/**', renderMode: RenderMode.Server },
  { path: 'admin/**', renderMode: RenderMode.Client },
];
ts
// provideAppInitializer remplace APP_INITIALIZER
import { provideAppInitializer, inject } from '@angular/core';
import { ConfigService } from './config.service';

export const appConfig: ApplicationConfig = {
  providers: [
    provideAppInitializer(async () => {
      await inject(ConfigService).load();
    }),
  ],
};

Angular 20 — Zoneless stable, signal forms

Mai 2025. Maturité signal-first : la stack reactive devient l'API canonique.

Nouveautés majeures

  • Zoneless stable : provideZonelessChangeDetection() (sans Experimental). Pas de Zone.js dans les nouveaux projets par défaut (opt-out via provideZoneChangeDetection). Bundle -25 KB gzip, moins de surprises de change detection.
  • Signal forms (preview) : nouvelle API formulaires basée signals, alternative à ReactiveFormsModule. Moins de boilerplate, intégration native avec computed/effect.
  • @angular/animations réécrit plus léger, build-out optionnel.
  • Selectorless components : permet d'utiliser un composant par référence d'import sans selector. Réduit les conflits de sélecteurs dans grosses apps.
  • effect() runtime amélioré : meilleur ordering, debug, et option allowSignalWrites plus claire.
  • output() function stable : output<T>() remplace @Output() avec API uniforme avec input().
  • Standalone-only schematics : suppression complète des chemins NgModule de la CLI par défaut.
  • linkedSignal, resource, httpResource stabilisés (preview en v19).
  • TypeScript 5.8 supporté, mode --isolatedModules strict.
  • Edge SSR mieux supporté (Cloudflare Workers via adapter officiel).

Breaking changes

  • Node 20.11+ minimum (18 abandonné).
  • TypeScript 5.6+.
  • Webpack builder définitivement déprécié (toujours présent en compat, mais plus de doc).
  • *ngIf, *ngFor, *ngSwitch toujours fonctionnels mais marqués pour suppression future.
  • Material Design 3 par défaut (M2 toujours utilisable via thème).
  • Suppression de plusieurs APIs dépréciées depuis v15-17.

Recommandations

  • Nouveau projet : ng new mon-app --ssr --zoneless. C'est le futur.
  • Migration : ne pas tout zoneless d'un coup. Activer en preprod, identifier les libs incompatibles (vieux RxJS, ngx-* sans signal awareness).
  • Migrer @Output vers output<T>() pour cohérence.
  • Adopter selectorless components pour les composants utilitaires.

Exemples

ts
// Zoneless stable
import { provideZonelessChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [provideZonelessChangeDetection()],
};
ts
// output() function — symétrique avec input()
import { Component, input, output } from '@angular/core';

@Component({ /* ... */ })
export class ButtonComponent {
  label = input.required<string>();
  disabled = input(false, { transform: (v: any) => v === true || v === '' });

  click = output<MouseEvent>();
  longPress = output<void>();
}
ts
// Signal forms (preview v20, stabilisé courant de la lignée v21)
// ⚠️ API encore mouvante : vérifier la signature exacte sur angular.dev/guide/forms/signals.
import { Component, signal } from '@angular/core';
import { form, required, email, minLength } from '@angular/forms/signals';

@Component({ /* ... */ })
export class SignupForm {
  // Le model est un WritableSignal : la source de vérité.
  private readonly model = signal({ email: '', password: '' });

  // form() retourne un FieldTree : chaque champ est un signal navigable (signup.email, signup.password).
  protected readonly signup = form(this.model, (path) => {
    required(path.email);
    email(path.email);
    required(path.password);
    minLength(path.password, 8);
  });

  submit() {
    // L'état (valid/touched/errors) est exposé via () sur chaque field et sur la racine.
    if (this.signup().valid()) {
      console.log(this.signup().value()); // { email, password }
    }
  }
}

Pourquoi signal forms ? Les ReactiveForms historiques sont un arbre d'objets FormControl parallèle à votre état : double source de vérité, valueChanges en RxJS, et une CD qui ne sait pas quel champ a bougé. Signal forms inversent la relation — votre signal de model EST le formulaire ; validations et état dérivent par computed. En zoneless, seul le champ touché re-render. C'est le même virage mental que httpResource : du push impératif (form.patchValue) vers le pull réactif (model.set).

Mode d'échec n°1 du zoneless : l'écran figé (et comment le diagnostiquer)

En Zone.js legacy, le bug classique était ExpressionChangedAfterItHasBeenCheckedError — bruyant, mais visible. En zoneless, cette erreur disparaît : à la place vous obtenez son inverse, silencieux : une mutation de state qui ne notifie aucun signal → le DOM ne bouge pas, sans erreur, sans log. C'est le piège le plus coûteux à débugger en prod parce qu'il ne lève rien.

Origines typiques :

SymptômeCauseCorrectif
Un champ d'instance changé dans un setTimeout/Promise.then ne s'affiche pasAucun signal notifié, pas de markForCheckMuter un signal, ou injecter ChangeDetectorRef et appeler markForCheck()
Un callback de lib tierce (WebSocket, IntersectionObserver, SDK) met à jour l'UI mais rien ne bougeLa lib appelle hors du graphe réactifWrapper le callback : signal.set(...) dans le handler
Un @HostListener natif passe mais l'UI reste figéeListener enregistré hors Angular (addEventListener manuel)Repasser par event binding template ou signal
Le rendu marche en dev, casse en prodEn dev, Angular ajoute un ApplicationRef.tick() périodique de debug qui masque le bugNe jamais valider zoneless sans build prod

Observabilité : instrumentez provideZonelessChangeDetection() en ajoutant un effect() de debug qui log les transitions d'un signal critique, et utilisez l'Angular DevTools profiler (onglet Change Detection) — en zoneless, une frame sans CD attendue est un signal d'alarme. En production, un compteur de « renders attendus vs observés » sur les écrans critiques (via un afterRender qui incrémente une métrique) attrape les régressions avant les utilisateurs.

Playbook de migration

Stratégie générale

  1. Une version majeure à la fois. Ne sautez pas v17 pour aller directement de v16 à v18 : les schematics et les guides sont conçus version par version.
  2. Toujours sur une branche dédiée, jamais sur main.
  3. Tests verts avant migration. Une suite de tests incomplète masque les régressions.
  4. Lock le Node et le TS en début de migration, mettez-les à jour si besoin avant ng update.
  5. Lire le guide officiel : update.angular.dev génère des instructions ciblées (« je suis en v16.1, je veux v17.3, j'utilise Material ») — incontournable.

Commandes types

bash
# 1. Vérifier la version courante
ng version

# 2. Mettre à jour Angular CLI globalement (optionnel)
npm install -g @angular/cli@17

# 3. Mettre à jour le core et la CLI (schematics auto-applied)
ng update @angular/core@17 @angular/cli@17

# 4. Mettre à jour les libs internes officielles
ng update @angular/material@17 @angular/cdk@17

# 5. Schematics optionnels post-migration
ng generate @angular/core:control-flow
ng generate @angular/core:standalone
ng generate @angular/core:signal-input-migration
ng generate @angular/core:signal-queries-migration
ng generate @angular/core:output-migration
ng generate @angular/core:inject-migration   # constructor injection → inject()

Schematics — l'équivalent de Rector

PHP a Rector pour migrer du code automatiquement (selon des règles AST). Angular utilise les schematics : scripts AST officiels qui transforment le code TypeScript/HTML à la commande. Les principaux :

SchematicEffet
@angular/core:control-flow*ngIf@if, *ngFor@for, etc.
@angular/core:standaloneMigre composants en standalone, supprime NgModule
@angular/core:signal-input-migration@Input()input()
@angular/core:signal-queries-migration@ViewChildviewChild()
@angular/core:output-migration@Output() + EventEmitteroutput<T>()
@angular/core:inject-migrationInjection via constructor → inject()
@angular/core:route-lazy-loading-migrationModule lazy → loadComponent standalone

Lancer un schematic est non-destructif : git diff permet de réviser et adapter avant commit.

Checklist par version

Vers v17 :

  • [ ] Schematic control-flow
  • [ ] Schematic standalone (si modules en cours)
  • [ ] Migrer @nguniversal/express-engine@angular/ssr
  • [ ] Vérifier track obligatoire sur tous @for
  • [ ] Mesurer build time (devrait être 2-4× plus rapide)

Vers v18 :

  • [ ] Activer withComponentInputBinding si routes paramétrées
  • [ ] Tester provideExperimentalZonelessChangeDetection (optionnel)
  • [ ] Ajouter withEventReplay si SSR

Vers v19 :

  • [ ] Migrer APP_INITIALIZERprovideAppInitializer
  • [ ] Adopter httpResource sur nouveaux écrans
  • [ ] Activer incremental hydration sur SSR
  • [ ] Définir app.routes.server.ts avec render modes par route

Vers v20 :

  • [ ] Activer provideZonelessChangeDetection (sans Experimental)
  • [ ] Schematic output-migration, inject-migration
  • [ ] Vérifier libs tierces compatibles zoneless
  • [ ] Bundle size : doit avoir baissé

Gotchas par migration

  • v16 → v17 : passage à esbuild builder peut révéler des incompatibilités SCSS (chemins absolus, partials manquants). Schematic recommandé : change-builders. Les éventuels Webpack-only loaders (raw-loader, file-loader) doivent être remplacés par des imports natifs.
  • v17 → v18 : le signal materialization change le comportement de change detection. Composants OnPush avec inputs mutables ({...obj, x: 1}) peuvent désormais ne plus se mettre à jour si l'input n'est pas un signal. Convertir à input().
  • v18 → v19 : Material MDC retire les anciens composants (mat-legacy-*). Schematic migrate-component-imports.
  • v19 → v20 : passage zoneless peut casser des libs anciennes qui reposent sur les patches Zone.js (timers, fetch). Faire un audit : si une lib utilise setTimeout sans NgZone.run, elle peut être OK ou pas — tester.

Deprecations en cours

À la sortie d'Angular 20, ces APIs sont dépréciées (utilisables mais signalées) et probablement supprimées dans les 1-2 prochaines majeures :

  • *ngIf, *ngFor, *ngSwitch — remplacés par @if/@for/@switch. Suppression prévue v21-v22.
  • NgModule dans les nouveaux projets — toujours fonctionnel pour libs et legacy, mais sans génération CLI.
  • @Input() / @Output() décorateurs — remplacés par input() / output(). Pas de suppression immédiate, mais nouveau code à éviter.
  • HttpClientModule (le module) — remplacé par provideHttpClient().
  • RouterModule.forRoot — remplacé par provideRouter().
  • Webpack builder — remplacé par esbuild app builder.
  • Zone.js obligatoire — remplacé par zoneless. Restera dispo en opt-in longtemps.
  • async pipe sur signals — inutile, lire le signal directement.
  • @nguniversal/* — entièrement remplacé par @angular/ssr.
  • platformBrowserDynamic dans les nouveaux templates — remplacé par bootstrapApplication.
  • Material legacy components (mat-legacy-button, etc.) — déjà supprimés en v19.

Stratégie d'anticipation

  • Pour chaque deprecation : créer un ticket de dette technique, l'évaluer (combien de fichiers concernés ?), planifier une session de schematic + revue.
  • Préférer toujours les nouvelles APIs sur du code neuf, même si l'app a encore du legacy.
  • Maintenir une page interne « migrations en cours » avec la version cible et le owner.

🎬 Cas d'usage concrets

Scénario 1 — Migration legacy SaaS RH 16 → 20

Contexte : SaaS RH historique en Angular 16 (NgModules partout, RxJS roi, change detection Default, builder Webpack), 400k lignes de code, 8 ans d'historique, équipe de 15 devs. Objectif : passer à Angular 20 en 6 mois sans freeze fonctionnel. Approche : migration une version majeure par sprint. v16 → v17 : ng update @angular/core@17 @angular/cli@17 puis schematic standalone (ng generate @angular/core:standalone) appliqué feature par feature en parallèle du développement courant (la coexistence modules/standalone est gérée par Angular). Migration control flow (@if/@for) via schematic, build esbuild activé. v17 → v18 : signals introduits progressivement dans les nouveaux composants, services legacy laissés en RxJS. Tests de non-régression à chaque étape via Playwright sur les parcours critiques. v18 → v19 : migration des composants critiques (5% les plus chauds) vers input() signal et OnPush, mesure de perf avant/après. v19 → v20 : zoneless activé en feature flag pour 10% du trafic, monitoring Sentry pendant 2 semaines, puis généralisation. Résultat : bundle initial de 1.8 MB → 480 KB, TTI mobile 5.2s → 1.9s, et nouvelles features développées 30% plus vite grâce au control flow + signals.

Scénario 2 — E-commerce mode 17 → 20

Contexte : retailer mode déjà en Angular 17 (standalone, control flow, esbuild) mais sans signals (encore en RxJS + async pipe). Migration vers 20 motivée par les besoins de perf (catalog avec 1000+ produits, scroll laggy en mode Default). Approche : v17 → v18 : adoption massive des signals dans les nouveaux composants. Refactor des composants catalog les plus lourds (ProductCardComponent, FilterPanelComponent) en input() signal + computed pour les dérivés (prix avec promo, disponibilité par taille). Mesure DevTools : CD initial passé de 220ms à 45ms. v18 → v19 : adoption de linkedSignal pour la gestion du panier (édition optimiste avec rollback en cas d'erreur API), resource() pour le chargement de la fiche produit (état loading/error/value géré nativement). v19 → v20 : zoneless mode activé. Quelques libs tierces (gtag legacy, certains plugins jQuery résiduels embarqués pour Black Friday) ne survivent pas — remplacés par leurs équivalents modernes. Forms passés en signal-based forms (preview v20) pour le checkout, ce qui simplifie le code de validation. Lighthouse Performance passé de 78 à 96.

Scénario 3 — Cabinet juridique 18 → 20

Contexte : application cabinet juridique récente (créée en 2024 sur Angular 18, standalone + signals dès le départ). Migration vers 20 motivée par les besoins SSR (le site public du cabinet doit être indexable Google) et l'envie d'utiliser les nouvelles API stables. Approche : v18 → v19 : adoption de @angular/ssr v19 avec outputMode configuré (static pour la vitrine, server pour les pages dynamiques). Hydration incrementale (@defer hydrate on viewport) sur les composants lourds (éditeur de conclusions ProseMirror, viewer PDF). Migration des Resolver routing vers resource() qui simplifie le mental model (un seul state, pas de pre-fetch vs in-component). v19 → v20 : zoneless activé (l'app est récente, aucune lib legacy bloquante), httpResource adopté pour tous les nouveaux fetches (remplace progressivement HttpClient.get().pipe(...)). Le nouveau linkedSignal est utilisé pour les formulaires d'édition de pièces (le brouillon dérive de la pièce serveur mais accepte les modifications locales avec reset si la pièce change côté serveur). Les effect() typés strictement permettent la suppression de plusieurs subscriptions RxJS résiduelles.

🛠️ Exemple end-to-end

Use case : migration d'un composant SaaS RH CandidatureListComponent d'Angular 16 (NgModule + RxJS) vers Angular 20 (standalone + signals + zoneless).

ts
// AVANT — Angular 16
// candidature-list.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CandidatureListComponent } from './candidature-list.component';

@NgModule({
  declarations: [CandidatureListComponent],
  imports: [CommonModule, FormsModule],
  exports: [CandidatureListComponent],
})
export class CandidatureListModule {}
ts
// AVANT — Angular 16
// candidature-list.component.ts
import { Component, OnDestroy, OnInit } from '@angular/core';
import { BehaviorSubject, Subject, combineLatest, takeUntil } from 'rxjs';
import { map } from 'rxjs/operators';
import { CandidaturesService } from './candidatures.service';
import { Candidature } from './candidature.model';

@Component({
  selector: 'app-candidature-list',
  templateUrl: './candidature-list.component.html',
})
export class CandidatureListComponent implements OnInit, OnDestroy {
  private destroy$ = new Subject<void>();
  filter$ = new BehaviorSubject<string>('');
  candidatures$ = new BehaviorSubject<Candidature[]>([]);
  visible$ = combineLatest([this.candidatures$, this.filter$]).pipe(
    map(([list, f]) => f ? list.filter((c) => c.nom.toLowerCase().includes(f.toLowerCase())) : list),
  );
  loading = false;

  constructor(private api: CandidaturesService) {}

  ngOnInit() {
    this.loading = true;
    this.api.list().pipe(takeUntil(this.destroy$)).subscribe({
      next: (list) => { this.candidatures$.next(list); this.loading = false; },
      error: () => { this.loading = false; },
    });
  }

  onFilter(v: string) { this.filter$.next(v); }
  ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); }
}
html
<!-- AVANT — candidature-list.component.html (Angular 16) -->
<input [ngModel]="filter$ | async" (ngModelChange)="onFilter($event)" placeholder="Filtrer" />
<div *ngIf="loading">Chargement...</div>
<ul *ngIf="visible$ | async as list">
  <li *ngFor="let c of list; trackBy: trackById">{{ c.nom }} — {{ c.stage }}</li>
</ul>
ts
// APRÈS — Angular 20
// candidature-list.component.ts
import { ChangeDetectionStrategy, Component, computed, model } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { httpResource } from '@angular/common/http';
import { Candidature } from './candidature.model';

@Component({
  selector: 'app-candidature-list',
  imports: [FormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input [(ngModel)]="filter" placeholder="Filtrer" />

    @if (candidatures.isLoading()) {
      <p>Chargement…</p>
    } @else if (candidatures.error()) {
      <p role="alert">Erreur de chargement.</p>
    } @else {
      <ul>
        @for (c of visible(); track c.id) {
          <li>{{ c.nom }} — {{ c.stage }}</li>
        } @empty {
          <li>Aucune candidature ne correspond.</li>
        }
      </ul>
    }
  `,
})
export class CandidatureListComponent {
  // model() = signal bidirectionnel → [(ngModel)]="filter" branche directement le writable signal,
  // plus besoin du couple get/set artisanal.
  protected readonly filter = model('');
  protected readonly candidatures = httpResource<ReadonlyArray<Candidature>>(() => '/api/candidatures');

  protected readonly visible = computed(() => {
    const f = this.filter().toLowerCase();
    const list = this.candidatures.value() ?? [];
    return f ? list.filter((c) => c.nom.toLowerCase().includes(f)) : list;
  });
}
ts
// app.config.ts (zoneless activé — API STABLE v20, sans le préfixe Experimental)
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),   // v20 : nom définitif
    provideHttpClient(withFetch()),     // withFetch() requis pour httpResource + SSR streaming
    provideRouter(routes),
  ],
};
ts
// main.ts (bootstrap standalone)
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';

bootstrapApplication(AppComponent, appConfig).catch(console.error);

Migration : NgModule supprimé, BehaviorSubject/combineLatest/takeUntil remplacés par signal/computed, httpResource gère loading/error/value sans subscription manuelle, OnPush + zoneless rendent la CD ultra-rapide, template @if/@for/@empty plus lisible. Lignes de code divisées par 2, fuites mémoire impossibles, perf nettement supérieure.

🤖 Mise en pratique : une UI d'agent IA en Angular 20 (signals + zoneless + SSE)

Pourquoi cette section dans une page de versions ? Parce que la combinaison exacte qui rend Angular 20 supérieur pour un chat IA streaming n'existait pas avant cette migration : signals + zoneless (CD précise et bon marché sous flux de tokens à 60 fps), @defer hydrate (le timeline d'outils reste statique jusqu'à interaction), et model()/resource() pour l'état. C'est le cas d'usage qui justifie d'aller jusqu'en v20.

Votre stack : backend NestJS qui relaie Anthropic (claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5) en SSE ; front Angular qui consomme le flux.

1. Lire un flux SSE avec fetch + getReader() (pas EventSource)

EventSource ne supporte ni les headers d'auth (Bearer) ni POST. Pour un endpoint LLM, on lit le ReadableStream à la main. En zoneless, la mise à jour du signal suffit à déclencher la CD — aucune NgZone.run requise.

ts
import { Injectable, signal, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

type ChatMessage = { role: 'user' | 'assistant'; content: string };

@Injectable({ providedIn: 'root' })
export class ChatStreamService {
  // Buffer append-only : on ne réassigne JAMAIS le tableau entier sous le flux,
  // on mute le dernier message et on notifie via .set() d'une nouvelle référence superficielle.
  readonly messages = signal<ChatMessage[]>([]);
  readonly streaming = signal(false);

  private controller?: AbortController;

  async send(prompt: string): Promise<void> {
    this.controller?.abort();           // un seul flux à la fois
    this.controller = new AbortController();
    this.streaming.set(true);

    this.messages.update((m) => [
      ...m,
      { role: 'user', content: prompt },
      { role: 'assistant', content: '' }, // slot vide qu'on va remplir token par token
    ]);

    try {
      const res = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ prompt }),
        signal: this.controller.signal,  // ⇦ Stop côté client ET serveur (voir §3)
      });
      if (!res.body) throw new Error('No stream');

      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let buffer = '';

      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });

        // Parsing SSE minimal : lignes "data: {json}\n\n"
        for (const evt of buffer.split('\n\n')) {
          const line = evt.trim();
          if (!line.startsWith('data:')) continue;
          const payload = line.slice(5).trim();
          if (payload === '[DONE]') continue;
          const { delta } = JSON.parse(payload) as { delta: string };
          this.appendToLast(delta);
        }
        buffer = buffer.slice(buffer.lastIndexOf('\n\n') + 2);
      }
    } finally {
      this.streaming.set(false);
    }
  }

  stop(): void {
    this.controller?.abort();           // déclenche le finally ET coupe le HTTP → le serveur voit le disconnect
  }

  private appendToLast(chunk: string): void {
    this.messages.update((m) => {
      const last = m[m.length - 1];
      const updated = { ...last, content: last.content + chunk };
      return [...m.slice(0, -1), updated]; // nouvelle référence → la CD signal voit le changement
    });
  }
}

2. Coalescer les tokens avec rAF sous zoneless

Un flux Anthropic peut émettre des dizaines de deltas par seconde. Appeler .update() à chaque token est correct (le scheduler zoneless coalesce déjà dans un rAF), mais sur un timeline avec markdown lourd vous voulez batcher explicitement pour ne pas re-parser le markdown à chaque caractère :

ts
private pending = '';
private rafId: number | null = null;

private appendToLast(chunk: string): void {
  this.pending += chunk;
  if (this.rafId !== null) return;
  this.rafId = requestAnimationFrame(() => {
    const chunk = this.pending;
    this.pending = '';
    this.rafId = null;
    this.messages.update((m) => {
      const last = m[m.length - 1];
      return [...m.slice(0, -1), { ...last, content: last.content + chunk }];
    });
  });
}

Subtilité zoneless : requestAnimationFrame n'est plus patché par Zone.js, donc il ne déclenche pas de tick global parasite — exactement le comportement voulu. En Zone.js legacy, ce même code aurait provoqué un tick à chaque frame. C'est un cas concret où zoneless rend le code de streaming plus simple ET plus rapide.

3. Bouton Stop : annuler des deux côtés

AbortController.abort() fait deux choses : il rejette la lecture du stream et ferme la connexion TCP. Côté NestJS, écoutez req.on('close') (ou l'AbortSignal du contrôleur) pour propager l'annulation au SDK Anthropic (stream.controller.abort()), sinon vous continuez à payer des tokens pour une réponse que personne ne lit.

html
<!-- chat.component.html -->
@for (msg of chat.messages(); track $index) {
  <article [class.user]="msg.role === 'user'">
    <div [innerHTML]="render(msg.content)"></div>
  </article>
}

@if (chat.streaming()) {
  <button (click)="chat.stop()">⏹ Stop</button>
} @else {
  <button (click)="onSend()">Envoyer</button>
}

4. Markdown streamé : sanitize obligatoire

Ne jamais binder du markdown rendu en HTML sans passer par DomSanitizer. Un LLM peut émettre <img onerror=...> ; en streaming, le HTML est de surcroît partiel à chaque frame.

ts
import { inject, SecurityContext } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { marked } from 'marked';

private readonly sanitizer = inject(DomSanitizer);

render(md: string) {
  const html = marked.parse(md, { async: false }) as string;
  // sanitize() retire scripts/handlers ; bypassSecurityTrust* est INTERDIT sur du contenu LLM.
  return this.sanitizer.sanitize(SecurityContext.HTML, html) ?? '';
}

5. Timeline d'outils : union discriminée + @defer

Pour afficher la boucle agentique (l'agent appelle des outils), modélisez chaque étape en union discriminée et rendez l'historique froid dans un @defer (on viewport) — il n'a pas besoin d'hydrater tant qu'il n'est pas scrollé.

ts
type ToolStep =
  | { kind: 'pending';   tool: string }
  | { kind: 'running';   tool: string; input: unknown }
  | { kind: 'streaming'; tool: string; partial: string }
  | { kind: 'done';      tool: string; output: unknown }
  | { kind: 'error';     tool: string; message: string };

readonly trace = signal<ToolStep[]>([]);
html
@for (step of trace(); track $index) {
  @switch (step.kind) {
    @case ('pending')   { <span class="badge">⏳ {{ step.tool }}</span> }
    @case ('running')   { <span class="badge run">▶ {{ step.tool }}</span> }
    @case ('streaming') { <span class="badge run">{{ step.tool }} : {{ step.partial }}</span> }
    @case ('done')      { <span class="badge ok">✓ {{ step.tool }}</span> }
    @case ('error')     { <span class="badge err">✗ {{ step.message }}</span> }
  }
}

Lien avec la migration : ce composant est impossible à écrire proprement en Angular 16 (pas de @switch, pas de signals, CD globale qui re-render tout le chat à chaque token). Il devient trivial en v20. La migration n'est pas qu'une dette à payer — elle débloque une classe entière d'UIs temps réel.

6. Production : reconnexion, ordering et le pont RxJS

Le ChatStreamService ci-dessus est correct pour un MVP, mais trois trous le rendent fragile en prod :

  • Reconnexion / reprise. Une coupure réseau au milieu d'un flux laisse un message assistant à moitié rempli, sans erreur. Le backend NestJS doit émettre un id: SSE par chunk (offset de génération) et le client renvoyer un Last-Event-ID pour reprendre — ou, plus simple, marquer le dernier message incomplete et offrir un bouton « Régénérer » idempotent sur la même generationId. Ne jamais re-streamer aveuglément : vous payeriez deux fois les tokens.
  • Ordering sous abort. Si l'utilisateur envoie un nouveau prompt avant la fin du précédent, controller.abort() coupe l'ancien flux, mais une read() déjà en vol peut résoudre après le début du nouveau. Garde de version : taguez chaque send() d'un requestId monotone et ignorez tout chunk dont le requestId n'est plus le courant.
  • Le pont avec RxJS. Pour un flux continu (notifications serveur, présence, co-édition) plutôt qu'une requête unique, ne réinventez pas un reader signal : exposez un Observable (WebSocket/SSE long-vécu) et bridez-le en signal avec toSignal(). resource/httpResource sont pour le chargement déclaratif (une réponse par état de params), pas pour les flux.
ts
import { toSignal } from '@angular/core/rxjs-interop';
import { webSocket } from 'rxjs/webSocket';

// Flux continu (présence, co-édition) → Observable, PAS resource()
private readonly presence$ = webSocket<PresenceEvent>('wss://api/presence');
readonly presence = toSignal(this.presence$, { initialValue: [] as PresenceEvent[] });

Règle de décision : une réponse par état d'entréehttpResource/resource. Un flux d'événements dans le tempsObservable + toSignal. Tokens d'une seule génération LLM → reader manuel + buffer signal append-only (ce service). Mélanger les trois est la cause n°1 de code de chat illisible.

🏋️ Exercices

Stack de référence : Angular 20 standalone + zoneless. Mesurez tout au Angular DevTools profiler (onglet Change Detection), pas à l'œil.

Exercice 1 — Migrer un composant 16 → 20 (implémenter)

Objectif : convertir un UserListComponent Angular 16 (NgModule + BehaviorSubject + *ngFor + async pipe) en standalone + signals + control flow, sans changer le comportement observable.

Indice/Solution : ng generate @angular/core:standalone puis :control-flow font 80 % du travail mécaniquement. Le 20 % manuel : remplacer BehaviorSubject + combineLatest par signal + computed, supprimer ngOnDestroy/takeUntil (le graphe signal n'a pas de subscription à fermer), passer en ChangeDetectionStrategy.OnPush. Vérifiez au profiler que la CD ne touche plus que la ligne modifiée.

Exercice 2 — Passer l'app en zoneless (production-grade)

Objectif : retirer Zone.js d'une app existante (provideZonelessChangeDetection() + supprimer zone.js des polyfills) et faire passer toute la suite e2e au vert.

Indice/Solution : grep setTimeout/setInterval/addEventListener hors composants Angular ; chaque endroit qui mutait un champ d'instance hors d'un event binding doit désormais muter un signal ou appeler ChangeDetectorRef.markForCheck(). Auditez les libs tierces : une lib qui n'expose pas d'Observable/signal et qui callback en async est suspecte. Métrique de succès : bundle −~25 KB gzip et zéro Expression has changed after it was checked (impossible en zoneless, mais des bugs de state non notifié apparaissent à la place — un écran « figé »).

Exercice 3 — Streamer un LLM et brancher le Stop (intégration IA)

Objectif : implémenter ChatStreamService (§ ci-dessus) contre un endpoint NestJS SSE qui relaie claude-haiku-4-5, avec un bouton Stop qui annule client et serveur.

Indice/Solution : fetch + getReader() + TextDecoder, buffer append-only via signal.update. Le piège : si abort() ne coupe pas la facture côté serveur, c'est que NestJS n'écoute pas req.on('close') / l'AbortSignal du SDK. Vérifiez côté Anthropic dashboard que les tokens s'arrêtent quand vous cliquez Stop. Bonus : coalescer les tokens en rAF (§2) et mesurer le nombre de re-renders au profiler avant/après.

Exercice 4 — Casser puis réparer la CD signal (break-it-then-fix-it)

Objectif : introduire volontairement le bug classique du signal qui « ne re-render pas », puis le diagnostiquer.

Indice/Solution : faites muter un objet en place (this.user().name = 'x') au lieu de this.user.set({ ...u, name: 'x' }). Le template ne bouge pas : un signal compare par référence, pas par contenu. Variante : un computed qui lit Date.now() (pas un signal) ne sera jamais ré-évalué. Réparation : toujours produire une nouvelle référence au set/update, et n'utiliser dans un computed que des sources réactives. Confirmez au profiler que la CD se redéclenche.

Exercice 5 — resource() vs linkedSignal sous course de requêtes (architecte)

Objectif : une page détail produit dont l'id change vite (navigation rapide). Garantir qu'une réponse lente d'un ancien id n'écrase jamais l'affichage du nouveau, et qu'un brouillon d'édition local survit au changement de source contrôlée.

Indice/Solution : resource({ params: () => ({ id: id() }), loader }) annule automatiquement la requête précédente via abortSignal — pas de switchMap manuel. Pour le brouillon : linkedSignal({ source: serverProduct, computation: (p) => p.name }) se réinitialise quand le produit serveur change mais reste set()-able entre-temps. Cassez-le d'abord avec un signal simple + effect (vous obtiendrez soit un flicker, soit un brouillon perdu) pour sentir pourquoi ces primitives existent.

Exercice 6 — SSR + incremental hydration sur une page lourde (production scale)

Objectif : une landing en RenderMode.Prerender avec un widget interactif coûteux (chart, éditeur) qui ne doit hydrater qu'au scroll.

Indice/Solution : @defer (hydrate on viewport) autour du widget, app.routes.server.ts avec RenderMode.Prerender pour la landing et Server pour les pages dynamiques. Mesurez le TBT Lighthouse avant/après : l'hydratation incrémentale doit faire chuter le JS exécuté au chargement initial. Piège : un widget qui lit window/document au constructeur casse le prerender — déplacez-le dans afterNextRender.

Exercice 7 — Streaming LLM résilient : casser l'ordering, puis le réparer (architecte / IA)

Objectif : prendre le ChatStreamService du §IA, déclencher deux send() rapprochés, et observer le bug où des tokens de l'ancienne génération s'intercalent dans la nouvelle ; puis garantir l'ordering ET une reprise propre après coupure réseau.

Indice/Solution : reproduisez d'abord le bug en retirant toute garde — une read() en vol de l'ancien flux résout après le abort() et appendToLast écrit dans le mauvais message. Fix : taguez chaque send() d'un requestId monotone, capturez-le en closure, et ignorez tout chunk dont requestId !== this.currentRequestId. Pour la reprise : faites émettre au backend un id: SSE par chunk, marquez le message incomplete à la coupure, et offrez « Régénérer » idempotent sur la même generationId (jamais un re-stream aveugle — vous paieriez les tokens deux fois). Vérifiez au profiler que le nombre de re-renders ne dépend que des frames rAF, pas du nombre de tokens.

🎤 En entretien

Q : Pourquoi passer en zoneless plutôt que rester sur OnPush + signals avec Zone.js ? R : OnPush + signals donne déjà une CD précise, mais Zone.js continue de monkey-patcher tout l'async et de déclencher des ticks globaux (overhead CPU + bundle). Zoneless supprime cet overhead et rend la planification de CD déterministe (pilotée par les notifications de signaux), au prix d'un audit des libs tierces qui supposaient un tick automatique.

Q : Un signal set() avec le même objet muté en place ne re-render pas. Pourquoi, et comment un junior se fait piéger ? R : Les signaux comparent par référence (Object.is par défaut). Muter en place garde la même référence → aucune notification. Il faut produire une nouvelle référence (set({...u, x})). C'est le bug le plus fréquent en migration vers signals, et il est silencieux (pas d'erreur, juste un écran figé).

Q : Comment migreriez-vous une app de 400k lignes de v16 à v20 sans geler les features ? R : Une majeure par sprint via ng update en restant iso-fonctionnel (Zone.js gardé), schematics standalone/control-flow appliqués feature par feature (coexistence gérée par Angular), refactor signals ciblé sur les hot paths mesurés au profiler, et zoneless activé en dernier derrière un feature flag avec monitoring. La clé : séparer la migration mécanique (versions) de la refonte stratégique (CD).

Q : resource()/httpResource() vs un BehaviorSubject + switchMap — qu'est-ce que ça change concrètement ? R : resource() est signal-native : il gère value/error/isLoading comme état dérivé, annule automatiquement la requête obsolète via abortSignal (la course de requêtes que switchMap réglait à la main), et s'intègre à la CD zoneless sans subscription à fermer. Il déplace le data-fetching de push impératif vers pull réactif, cohérent avec le reste du graphe signal. Limite à connaître : resource n'est pas un remplacement de RxJS pour les flux (websocket, événements continus, debounce/throttle d'input) — c'est un primitive de chargement déclaratif (une requête par état de params). Pour un flux, restez en Observable + toSignal().

Q : Comment teste-t-on un composant en zoneless, et qu'est-ce qui casse par rapport à fakeAsync/tick() ? R : fakeAsync/tick() reposent sur les patches Zone.js des timers — en zoneless ils ne marchent plus. On passe à async/await + await fixture.whenStable() pour attendre les tâches async, et on garde fixture.detectChanges() car en test le scheduler n'est pas laissé libre. C'est un poste de coût majeur d'une migration zoneless qu'on sous-estime souvent : la réécriture de la suite de tests peut dépasser le coût du code applicatif.

Q : @defer (hydrate on viewport) — quel est le tradeoff, et quand est-ce le mauvais choix ? R : L'incremental hydration réduit le TBT initial en ne livrant pas le JS d'un bloc tant qu'il n'est pas visible/interagi — idéal pour un widget lourd sous la ligne de flottaison (chart, éditeur). Le tradeoff : un délai d'interactivité au premier scroll (le chunk se télécharge à ce moment-là) et une complexité SSR accrue (le bloc doit être rendu côté serveur mais inerte). Mauvais choix pour un élément above the fold ou critique au LCP/interaction immédiate : vous y ajouteriez une latence visible pour économiser un JS qu'il faut de toute façon charger tout de suite.

Q : Pourquoi le sous-tableau v16→v17 et v19→v20 sont-ils les sauts chers, alors que les majeures intermédiaires sont presque gratuites ? R : Parce que le coût d'une migration Angular ne vient pas du numéro de version mais de deux ruptures d'infrastructure : le changement de builder (Webpack → esbuild/Vite, en v17, qui casse les loaders custom et certains chemins SCSS) et le passage zoneless (en v20, qui casse les libs Zone-dépendantes et fakeAsync). Les majeures intermédiaires sont du ng update + schematics iso-fonctionnels. Un staff planifie v17 et v20 comme des epics, le reste comme des chores.

🔗 Liens

  • update.angular.dev — guide de migration interactif officiel
  • angular.dev/roadmap — roadmap officielle
  • blog.angular.dev — annonces de release
  • github.com/angular/angular/blob/main/CHANGELOG.md — changelog complet
  • angular.dev/reference/migrations — schematics par migration
  • « What's new in Angular 17/18/19/20 » — articles de blog Angular team
  • ng-news — newsletter communautaire qui suit toutes les releases

Bibliothèque tech perso — Achref