Change Detection — OnPush, signals, profiling, optimisations
TL;DR En 2026, l'optimisation de la Change Detection (CD) Angular repose sur trois piliers :
OnPushpar défaut, signals comme source de vérité, et zoneless mode pour les apps modernes. Avec les signals, letrackBydu*ngForest devenu obsolète : le nouveau@fordu control-flow utilisetrackqui en est l'équivalent direct mais obligatoire. Les principaux leviers de performance sont : minimiser le nombre de composants à vérifier (OnPush), éviter les expressions coûteuses dans les templates (pipes purs, computed), isoler le calcul lourd (Web Workers,runOutsideAngular), et mesurer avec Angular DevTools plutôt que de deviner. Cette note couvre les patterns 2026 — beaucoup de conseils 2020-2022 sont devenus obsolètes ou nuisibles.
🧠 Mental model — ASCII + analogie
La Change Detection est le mécanisme qui synchronise le state TS avec le DOM. À chaque tick (déclenché par Zone.js dans le mode classique, ou par signals dans le mode zoneless), Angular descend l'arbre des composants et compare les bindings. Avec Default, tous les composants sont vérifiés à chaque tick. Avec OnPush, un composant n'est vérifié que si l'une des conditions est remplie : un de ses inputs change (référence), un événement DOM se produit dans son template, un observable consommé via async pipe émet, ou un signal qu'il consomme émet.
┌────────────────────────────────────────────────────────────┐
│ Modes de Change Detection — quand un composant CD ? │
└────────────────────────────────────────────────────────────┘
Mode Default (legacy) Mode OnPush + signals (recommandé)
──────────────────────── ──────────────────────────────────
À chaque tick Zone.js : Vérifié UNIQUEMENT si :
- macrotask - input ref change
- setTimeout, setInterval - event DOM dans le template
- XHR, fetch, Promise - async pipe émet
- clic, scroll, input - signal consommé émet
→ tout l'arbre est vérifié - markForCheck explicite
→ seul ce composant + ancêtres
┌──────┐ ┌──────┐
│ Root │ ← CD à chaque tick │ Root │ ← CD si nécessaire
└──┬───┘ └──┬───┘
┌──┴───┬──────┐ ┌──┴───┬──────┐
│ A │ B │ ← tous CD │ A │ B │
└─┬────┘ └───┘ │OnPush│OnPush│
┌─┴──┐ └─┬────┘ └───┘
│ A1 │ ← CD aussi ┌─┴──┐
└────┘ │ A1 │ ← CD uniquement si trigger
└────┘L'analogie : la CD Default, c'est un facteur qui passe à toutes les boîtes aux lettres de la ville à chaque tournée, même si rien n'a changé. La CD OnPush+signals, c'est un facteur intelligent qui ne livre qu'aux maisons qui attendent un colis. Beaucoup moins de travail, beaucoup plus rapide. Le coût : il faut signaler clairement quand on attend un colis (= utiliser signals, async pipes, ou markForCheck). Sinon, le composant ne se met jamais à jour.
Comment le scheduler décide vraiment (le modèle exact)
Le détail qui sépare un dev senior d'un dev qui « connaît OnPush » : comprendre que marquer un composant ≠ le vérifier. Angular maintient deux notions distinctes par composant :
Dirty(LViewFlags.Dirty) : « ce composant a un changement potentiel, il faut le re-checker ». Posé parmarkForCheck(), un input qui change de référence, un event DOM dans son template, l'émission d'un signal consommé, ou d'unasyncpipe.RefreshView/ le chemin de remontée : quand un composant est marqué dirty, Angular remonte la chaîne de parents et les marqueHasChildViewsToRefresh. Au prochain tick, il descend depuis la racine mais skip toute branche dont ni le nœud ni un descendant n'est dirty.
Signal `price` émet (consommé par A1)
│
▼ notifie le scheduler → microtask CD planifiée
┌──────┐
│ Root │ marqué HasChildViewsToRefresh (chemin remonté)
└──┬───┘
┌──┴───┬──────┐
│ A │ B │ B : ni dirty ni ancêtre d'un dirty → SKIPPÉ
│ HCVR │ skip │
└─┬────┘
┌─┴──┐
│ A1 │ Dirty → effectivement re-checké (bindings recalculés)
└────┘Conséquences pratiques que beaucoup ignorent :
- Un signal consommé dans le template marque le composant dirty même en
OnPush— c'est précisément ce qui rend OnPush + signals fiable sansmarkForCheck. Leasyncpipe fait pareil (il appellemarkForChecken interne). - Un signal lu hors template (dans une méthode, un
computednon lu, uneffect) ne marque rien. L'effecta son propre cycle de planification, découplé de la CD du composant. markForCheck()ne déclenche pas la CD immédiatement : il pose juste le flagDirtyet planifie un tick. Le rendu arrive à la fin de la microtask/frame. Si tu as besoin du DOM à jour maintenant (rare, ex. mesure de layout), c'estdetectChanges()(synchrone, local) — mais c'est un code smell hors cas canvas/mesure.- En zoneless, il n'y a plus de
ApplicationRef.tick()déclenché par Zone.js sur chaque macrotask. Le tick est planifié uniquement par : un signal qui notifie,markForCheck, un event handler de template, ouApplicationRef.tick()manuel. C'est pour ça qu'unsetTimeout(() => this.x = 5)qui marchait en mode Zone casse silencieusement en zoneless.
Tradeoffs des stratégies — table de décision
| Stratégie | Coût par tick | Risque de bug « ne se met pas à jour » | Quand c'est le bon choix |
|---|---|---|---|
| Default + Zone.js | Élevé (tout l'arbre) | Très faible (tout est revérifié) | Prototype, app legacy, équipe junior, libs Zone-dépendantes |
| OnPush + Zone.js | Moyen (branches dirty) | Moyen (mutation in-place, input non-immutable) | App de prod classique en migration |
| OnPush + signals + Zone.js | Faible | Faible (signals marquent automatiquement) | Standard recommandé en transition |
| Zoneless + signals | Très faible (aucun overhead Zone) | Faible si discipline signals, élevé si mutation nue | Nouveaux projets 2026, perf critique |
cdr.detach() | Nul (aucune CD auto) | Maximal (tu pilotes tout à la main) | Canvas/WebGL/dataviz à RAF propre uniquement |
Le piège mental classique : croire qu'OnPush « rend l'app plus rapide ». Faux. OnPush réduit le nombre de composants vérifiés par tick ; il ne change pas le coût d'un check individuel ni le nombre de ticks. Si ton bottleneck est un computed à O(n²) ou un tick déclenché 60×/s par un mousemove non sorti de la zone, OnPush n'y change rien. Mesure d'abord (Profiler) : largeur de l'arbre coché → OnPush ; coût d'un check → computed/pipes purs ; nombre de ticks → runOutsideAngular/zoneless/coalescing.
Le tick en deux passes — d'où vient ExpressionChangedAfterItHasBeenChecked
Le détail qui explique 90 % des erreurs cryptiques en dev. Un tick CD est une descente top-down, une seule passe par composant : Angular évalue chaque binding, écrit le DOM, puis descend dans les enfants. La règle d'or qui découle de l'architecture : un composant parent est checké AVANT ses enfants, et la CD ne remonte jamais en arrière dans la même passe. Donc si un composant enfant, pendant son propre check, modifie une valeur que le parent a déjà lue et écrite dans le DOM, le DOM du parent est maintenant désynchronisé du modèle — mais la passe est finie pour lui.
En mode dev uniquement, Angular fait une seconde passe de vérification (sans appliquer les changements) et compare : si une valeur a bougé entre la première et la seconde passe, il lève ExpressionChangedAfterItHasBeenChecked. C'est un garde-fou de cohérence unidirectionnelle, pas un bug à contourner avec setTimeout ou detectChanges(). La vraie cause est presque toujours : un enfant (ou un ngAfterViewInit/ngAfterContentChecked) qui écrit dans un état lu par un ancêtre dans la même passe. Le fix propre : déplacer la mutation dans un signal (résolu au prochain tick), ou un computed (la valeur dérivée est cohérente par construction), pas un Promise.resolve().then(...) qui masque le symptôme.
Passe 1 (dev + prod) — applique au DOM
Root.title = "A" ──écrit DOM──► <h1>A</h1>
└─ Child écrit Root.title = "B" ← mutation arrière interdite
Passe 2 (DEV SEULEMENT) — vérifie, n'écrit pas
Root.title === "B" ≠ "A" lu en passe 1 → ❌ ExpressionChangedAfterItHasBeenCheckedMental model staff : la CD Angular est unidirectionnelle par conception (comme React). Toute valeur affichée doit être stable au moment où le parent la lit. Les signals/computed respectent ça nativement ; les mutations impératives dans les lifecycle hooks tardifs sont ce qui le casse.
🛠️ Code minimal (ts + html)
Composant OnPush idiomatique avec signals.
// product-card.component.ts
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
@Component({
selector: 'app-product-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<article class="card" [class.featured]="featured()">
<h3>{{ name() }}</h3>
<p>{{ formattedPrice() }}</p>
@if (inStock()) {
<button (click)="addToCart.emit(id())">Ajouter</button>
} @else {
<span class="oos">Rupture</span>
}
</article>
`,
})
export class ProductCardComponent {
readonly id = input.required<string>();
readonly name = input.required<string>();
readonly price = input.required<number>();
readonly currency = input<string>('EUR');
readonly inStock = input<boolean>(true);
readonly featured = input<boolean>(false);
readonly addToCart = output<string>();
readonly formattedPrice = computed(() =>
new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: this.currency(),
}).format(this.price()),
);
}Boucle moderne avec @for et track (remplace *ngFor + trackBy).
// product-list.component.ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { ProductCardComponent } from './product-card.component';
interface Product {
id: string;
name: string;
price: number;
inStock: boolean;
}
@Component({
selector: 'app-product-list',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ProductCardComponent],
template: `
<section>
@for (product of products(); track product.id) {
<app-product-card
[id]="product.id"
[name]="product.name"
[price]="product.price"
[inStock]="product.inStock"
(addToCart)="onAdd($event)"
/>
} @empty {
<p>Aucun produit disponible.</p>
}
</section>
`,
})
export class ProductListComponent {
readonly products = input.required<Product[]>();
onAdd(id: string): void {
console.log('Added', id);
}
}Pipe pur custom (recalculé uniquement quand l'input change).
// formatted-date.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'formattedDate',
pure: true, // défaut, à laisser
standalone: true,
})
export class FormattedDatePipe implements PipeTransform {
transform(value: Date | string, format: 'short' | 'long' = 'short'): string {
const date = typeof value === 'string' ? new Date(value) : value;
return new Intl.DateTimeFormat('fr-FR', {
dateStyle: format,
}).format(date);
}
}async pipe avec shareReplay pour éviter les subscriptions multiples.
// dashboard.component.ts
import { Component, inject } from '@angular/core';
import { shareReplay } from 'rxjs';
import { AsyncPipe } from '@angular/common';
import { UsersService } from './users.service';
@Component({
selector: 'app-dashboard',
imports: [AsyncPipe],
template: `
<p>Utilisateurs : {{ (users$ | async)?.length }}</p>
<ul>
@for (user of (users$ | async) ?? []; track user.id) {
<li>{{ user.name }}</li>
}
</ul>
`,
})
export class DashboardComponent {
private readonly service = inject(UsersService);
protected readonly users$ = this.service.getAll().pipe(shareReplay({ bufferSize: 1, refCount: true }));
}runOutsideAngular pour les events fréquents qui ne touchent pas le DOM.
// scroll-tracker.directive.ts
import { Directive, ElementRef, inject, NgZone, OnInit } from '@angular/core';
@Directive({ selector: '[appScrollTracker]', standalone: true })
export class ScrollTrackerDirective implements OnInit {
private readonly el = inject(ElementRef<HTMLElement>);
private readonly zone = inject(NgZone);
ngOnInit(): void {
this.zone.runOutsideAngular(() => {
this.el.nativeElement.addEventListener('scroll', this.onScroll, { passive: true });
});
}
private readonly onScroll = (event: Event) => {
// calcul intensif sans déclencher de CD
const y = (event.target as HTMLElement).scrollTop;
if (y > 1000 && !document.body.classList.contains('scrolled')) {
this.zone.run(() => {
// ré-entrée explicite dans Angular pour update du DOM
document.body.classList.add('scrolled');
});
}
};
}Web Worker pour calcul lourd.
// heavy-calc.worker.ts
addEventListener('message', ({ data }: MessageEvent<number[]>) => {
const result = data.reduce((sum, n) => sum + Math.sqrt(n) * Math.log(n + 1), 0);
postMessage(result);
});
// heavy.service.ts
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class HeavyService {
readonly result = signal<number | null>(null);
compute(input: number[]): void {
const worker = new Worker(new URL('./heavy-calc.worker', import.meta.url), { type: 'module' });
worker.onmessage = ({ data }) => {
this.result.set(data);
worker.terminate();
};
worker.postMessage(input);
}
}Détacher complètement la CD pour un composant ultra-spécialisé.
// canvas-visualization.component.ts
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, inject, OnInit, viewChild } from '@angular/core';
@Component({
selector: 'app-canvas-viz',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<canvas #canvas width="800" height="600"></canvas>`,
})
export class CanvasVisualizationComponent implements OnInit {
private readonly cdr = inject(ChangeDetectorRef);
protected readonly canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
ngOnInit(): void {
// détache : plus aucune CD automatique
this.cdr.detach();
this.startRenderLoop();
}
private startRenderLoop(): void {
const ctx = this.canvas().nativeElement.getContext('2d')!;
const loop = () => {
// dessin custom (animation, données temps réel)
ctx.clearRect(0, 0, 800, 600);
ctx.fillRect(Math.random() * 800, Math.random() * 600, 10, 10);
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
}Comparaison performance Default vs OnPush avec marquage explicite.
// monitoring-widget.component.ts
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnDestroy, OnInit } from '@angular/core';
@Component({
selector: 'app-monitoring-widget',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div>
<p>Tick : {{ tickCount }}</p>
<p>Latency : {{ latency }} ms</p>
</div>
`,
})
export class MonitoringWidget implements OnInit, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef);
protected tickCount = 0;
protected latency = 0;
private intervalId?: number;
ngOnInit(): void {
// On reste hors Angular pour le polling, on ne CD que tous les N ticks
this.intervalId = window.setInterval(() => {
this.tickCount++;
this.latency = Math.random() * 100;
if (this.tickCount % 10 === 0) {
this.cdr.markForCheck();
}
}, 100);
}
ngOnDestroy(): void {
if (this.intervalId) clearInterval(this.intervalId);
}
}Application en mode zoneless.
// app.config.ts — Angular 20+ : API stable
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
],
};Migration de nom : avant Angular 20, le provider s'appelait
provideExperimentalZonelessChangeDetection(). Depuis la v20 il est stabilisé enprovideZonelessChangeDetection(). Si tu lis encore l'ancien nom dans un tuto, c'est qu'il date de 2024. En zoneless, tout binding de template doit être alimenté par une source réactive connue d'Angular : signal,asyncpipe, ou un appel explicite àmarkForCheck(). Une mutation « nue » (this.foo = xsur une propriété de classe lue dans le template, sans signal) ne déclenche plus rien — c'est le piège n°1 de la migration.
// angular.json — supprimer Zone.js des polyfills
{
"projects": {
"app": {
"architect": {
"build": {
"options": {
"polyfills": []
}
}
}
}
}
}🎯 Patterns courants
OnPush systématique. Tout nouveau composant doit avoir changeDetection: ChangeDetectionStrategy.OnPush. C'est la règle 2026, sans exception. Pour rendre cela un défaut, créer un schematic interne ou un lint rule (@angular-eslint/prefer-on-push-component-change-detection).
Signals partout pour les inputs. L'API input(), input.required(), model() doit être préférée systématiquement aux décorateurs @Input(). Les inputs signals sont automatiquement réactifs, ils marquent le composant comme dirty quand leur valeur change. Plus de @Input set foo() avec getter/setter manuel.
computed pour les valeurs dérivées dans le composant. Toute valeur calculée depuis un signal d'input doit être un computed. C'est mémoïsé : tant que les dépendances ne changent pas, le calcul n'est pas refait. C'est l'équivalent d'un selector pour le composant.
Pipes purs pour les transformations stables. Un pipe pur n'est recalculé que si son input change (référence). C'est parfait pour des transformations stateless : formatage, traduction, conversion d'unités. À éviter pour des données qui changent souvent ou des calculs très lourds — préférer un computed dans le composant.
async pipe + shareReplay. Pour un Observable consommé par plusieurs async dans le même template, sans shareReplay({ bufferSize: 1, refCount: true }), chaque async provoque un nouveau subscribe (et donc une nouvelle requête HTTP si c'est un cold observable). Le shareReplay partage le résultat.
toSignal() pour migrer depuis Observable. Quand un service expose un Observable et que le composant est en signals, on convertit : protected readonly users = toSignal(this.service.users$, { initialValue: [] }). Plus besoin de async pipe, et le composant peut lire users() partout.
Détacher la CD pour les composants vraiment isolés. Pour un composant qui rend une visualisation complexe (canvas, SVG, dataviz) et qui se met à jour via son propre mécanisme, on peut détacher complètement la CD : cdr.detach() dans le constructor, puis cdr.detectChanges() manuel quand on veut. À utiliser avec parcimonie.
runOutsideAngular pour les events fréquents. Scroll, mousemove, resize, animations Canvas — ces events déclenchent des dizaines de ticks par seconde. Les écouter hors Angular (zone.runOutsideAngular()) évite des CD inutiles, puis on ré-entre (zone.run()) seulement quand un update du DOM Angular est nécessaire.
Web Workers pour les calculs lourds. Tout calcul qui prend plus de 50ms doit être déporté dans un worker. Angular CLI supporte les workers nativement (new Worker(new URL(...))). Le composant reçoit le résultat via signal.set() ou un Observable.
@defer pour différer le chargement. Bloc @defer { ... } qui ne charge le composant qu'à la condition spécifiée (on viewport, on interaction, on idle, etc.). C'est un levier de perf énorme pour les pages avec beaucoup de composants secondaires.
Mesure systématique avec Angular DevTools. L'onglet Profiler d'Angular DevTools enregistre les frames de CD avec, pour chaque composant, le temps passé en check, en init, en update. C'est l'outil principal pour diagnostiquer un ralentissement. Workflow type : enregistrer une session de 5-10 secondes en interagissant comme l'utilisateur, identifier les composants > 5ms, optimiser.
linkedSignal pour les dérivés mutables. Depuis Angular 19, linkedSignal permet de créer un signal dérivé d'un autre, qui peut aussi être muté localement, mais qui se resynchronise quand la source change. Utile pour gérer un selected state qui suit une liste tout en permettant de modifier la sélection.
import { linkedSignal, signal } from '@angular/core';
const products = signal<Product[]>([]);
const selectedId = linkedSignal({
source: products,
computation: (list, previous) =>
list.find((p) => p.id === previous?.value)?.id ?? list[0]?.id ?? null,
});Virtual scrolling pour les longues listes. Pour une liste > 100 éléments, le rendu de chaque ligne en DOM est coûteux. Le CDK <cdk-virtual-scroll-viewport> ne rend que les éléments visibles dans le viewport, recyclant le DOM. Combiné avec @for + track, c'est radicalement plus rapide.
<cdk-virtual-scroll-viewport itemSize="50" style="height: 400px;">
<div *cdkVirtualFor="let item of items(); trackBy: trackById">
{{ item.name }}
</div>
</cdk-virtual-scroll-viewport>🔄 Versions — Angular 16 → 20
Angular 16 (mi-2023) : signals introduits en preview. OnPush recommandé mais pas obligatoire. trackBy toujours utilisé avec *ngFor. Hydration partielle introduite.
Angular 17 (fin 2023) : signals stables. Nouveau control flow @for/@if/@switch introduit. track devient obligatoire pour @for. Hydration améliorée. Pas encore de zoneless.
Angular 18 (mi-2024) : provideExperimentalZonelessChangeDetection() introduit. Première version fonctionnelle de zoneless. OnPush + signals devient le pattern dominant. @defer stabilisé. Le scheduler hybride (provideZoneChangeDetection({ eventCoalescing: true })) coalesce plusieurs events DOM en un seul CD.
Angular 19 (fin 2024) : zoneless promu de preview à expérimental stable. Migration automatique trackBy vers track via schematic. linkedSignal introduit. resource() / rxResource() introduits pour le state async. effect() stabilisé.
Angular 20 (mi-2025) : zoneless stable, provider renommé provideZonelessChangeDetection() (sans flag experimental). Devient le mode recommandé pour les nouveaux projets. Performance améliorée de 30-50% sur les apps complexes (suppression du monkey-patching Zone.js de toutes les API async). Profiling intégré dans Angular DevTools amélioré. resource() stabilisé, httpResource() introduit.
Trajectoire 2026 : zoneless est le défaut recommandé, et ng new peut le proposer. Zone.js reste supporté pour la rétrocompatibilité (apps avec libs tierces non-signal) mais les nouveaux projets démarrent zoneless. La majorité des optimisations historiques (NgZone tricks, markForCheck manuel, ChangeDetectorRef.detach) ne sont plus nécessaires : la granularité de réactivité vient des signals eux-mêmes. Réactivité fine-grained (mise à jour du DOM au niveau du binding, sans même cocher le composant) est sur la roadmap — c'est l'aboutissement de la stratégie signals.
⚠️ Pitfalls — 6-10
1. Oublier OnPush sur un composant lourd. Un composant avec une grande liste en Default est revalidé à chaque tick. Symptôme : profiler Angular DevTools montre des CD multiples par seconde même quand rien ne change. Solution : OnPush + signals.
2. Muter un input array sans changer la référence. En OnPush, @Input items: Item[] ne déclenche pas la CD si on fait items.push(newItem) dans le parent (même référence). Toujours retourner un nouveau tableau : items = [...items, newItem]. Avec input() signals, le problème disparaît si on utilise signal.update.
3. Pipe impur consommé partout. Un pipe pure: false est recalculé à chaque CD. Si utilisé dans une grande liste, c'est catastrophique. Soit on le rend pur (et on accepte ses limites), soit on remplace par un computed ou un signal dérivé.
4. *ngFor sans trackBy (legacy). Sans trackBy, chaque update du tableau recrée tous les éléments du DOM, perdant les états (focus, scroll, animations). En 2026, @for + track est obligatoire — le compilateur refuse de compiler sans.
5. Expressions complexes dans le template. Une expression comme un .filter(...).length interpolée directement dans le template est recalculée à chaque CD (Angular ne sait pas la mémoïser). Solution : un computed dans le composant, qui n'est recalculé que si ses dépendances changent.
<!-- ❌ recalculé à chaque tick CD -->
<p>{{ users().filter(u => u.active).length }}</p>
<!-- ✅ mémoïsé -->
<p>{{ activeCount() }}</p>// dans le composant
readonly activeCount = computed(() => this.users().filter((u) => u.active).length);6. Setters d'input lourds. @Input set foo(v) { this.expensiveCompute(v); } est appelé à chaque assignment, même si la valeur est identique. Avec input() signals, on ne réagit qu'aux vrais changements. Si on doit garder un setter, on guard : if (v === this._foo) return.
7. Effets dans ngOnInit qui devraient être effect. Pour réagir aux changements d'un signal, ne pas faire ngOnInit + subscribe. Utiliser effect(() => { ... }) dans le constructor. C'est automatiquement nettoyé à la destruction.
8. Subscriptions non nettoyées. En 2026, ce ne devrait plus jamais arriver : utiliser takeUntilDestroyed() ou toSignal(). Mais reste un piège classique en code legacy.
9. markForCheck mal utilisé. Si on a vraiment besoin de cdr.markForCheck() régulièrement, c'est qu'on n'utilise pas les bons outils (signals, async, inputs). C'est presque toujours un signe qu'il faut refactoriser.
10. Mesurer après optimisation seulement. Trop d'optimisations à l'aveugle. Toujours profiler avant avec Angular DevTools (onglet Profiler), identifier le bottleneck, puis optimiser. Sinon on perd du temps sur des optimisations sans impact mesurable.
🧪 Testing
Tester la performance directement est difficile, mais on peut tester que les comportements OnPush sont corrects.
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductCardComponent } from './product-card.component';
describe('ProductCardComponent (OnPush)', () => {
let fixture: ComponentFixture<ProductCardComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [ProductCardComponent] }).compileComponents();
fixture = TestBed.createComponent(ProductCardComponent);
fixture.componentRef.setInput('id', '1');
fixture.componentRef.setInput('name', 'Café');
fixture.componentRef.setInput('price', 3);
fixture.detectChanges();
});
it('met à jour quand l’input change', () => {
fixture.componentRef.setInput('price', 5);
fixture.detectChanges();
const text = fixture.nativeElement.textContent;
expect(text).toContain('5,00');
});
it('recalcule le formattedPrice via computed', () => {
fixture.componentRef.setInput('currency', 'USD');
fixture.detectChanges();
const text = fixture.nativeElement.textContent;
expect(text).toContain('$');
});
});Mesurer le nombre de CD de façon déterministe. Spy ChangeDetectorRef.prototype.detectChanges ne sert à rien : Angular n'appelle pas cette méthode lors d'un tick OnPush normal (il appelle des routines internes refreshView). Le proxy fiable et reproductible est un compteur dans ngDoCheck — c'est le hook appelé une fois par CD effective du composant. C'est aussi ce qu'on automatise en CI (cf. exercice 6) parce que, contrairement aux millisecondes, il n'est pas flaky.
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { TestBed } from '@angular/core/testing';
@Component({
selector: 'app-probe',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ name() }}</p>`,
})
class ProbeComponent {
readonly name = input.required<string>();
checks = 0;
ngDoCheck(): void {
this.checks++; // incrémenté une fois par CD effective de CE composant
}
}
it('ne re-coche pas le composant OnPush quand un input réémet la même valeur', () => {
const fixture = TestBed.createComponent(ProbeComponent);
fixture.componentRef.setInput('name', 'Café');
fixture.detectChanges(); // 1er check
const after = fixture.componentInstance.checks;
fixture.componentRef.setInput('name', 'Café'); // même valeur → input signal n'émet pas
fixture.detectChanges();
// OnPush + input signal : valeur identique → aucune re-vérification supplémentaire
expect(fixture.componentInstance.checks).toBe(after);
});Attention au piège du test :
setInputavec une valeur identique ne ré-émet pas le signal d'input (égalité par défautObject.is), donc le composant n'est pas re-coché — c'est exactement ce que le test prouve. Maisfixture.detectChanges()force quand même un cycle sur l'arbre de test : si tu observes unngDoCheckde trop, c'est souvent parce que le composant hôte du test (le wrapperTestBed) est enDefault. Pour isoler, mets le composant testé seul à la racine du fixture, comme ci-dessus.
Pour le profiling réel, utiliser Angular DevTools : onglet Profiler → enregistrer une session → analyser les composants les plus coûteux. Toujours mesurer en build de production (ng build --configuration=production) : en dev, Angular exécute un second cycle de CD (ExpressionChangedAfterItHasBeenCheckedError check) qui double artificiellement le coût et fausse complètement le diagnostic. Mesurer en dev, c'est mesurer un fantôme.
📡 Observabilité & budgets de perf en production
Profiler en local prouve qu'un fix marche sur ta machine. En prod, le bottleneck CD se manifeste sur le téléphone milieu de gamme d'un utilisateur réel, pas sur ton M3. Un senior instrumente la CD comme n'importe quel autre coût.
1. Mesurer le coût réel des ticks avec un profiler hook. Depuis Angular 17, ApplicationRef expose un hook de profilage utilisable en prod pour échantillonner la durée des cycles de CD et les remonter à ton RUM (Sentry, Datadog, console maison).
// cd-profiler.ts — à câbler dans un APP_INITIALIZER ou au bootstrap
import { ApplicationRef, ɵsetProfiler as setProfiler, ɵProfilerEvent as ProfilerEvent } from '@angular/core';
export function installCdProfiler(report: (ms: number) => void): void {
let start = 0;
setProfiler((event, _instance) => {
if (event === ProfilerEvent.ChangeDetectionStart) start = performance.now();
else if (event === ProfilerEvent.ChangeDetectionEnd) {
const dt = performance.now() - start;
if (dt > 16) report(dt); // ne remonter que les frames qui ratent le budget 60fps
}
});
}Le préfixe
ɵmarque une API privée (peut casser entre versions mineures) — acceptable pour de l'observabilité, à isoler derrière une fonction et à pinner sur la version Angular. Alternative stable : uneffectglobal qui timestampe, ou lesLong Animation Frames(LoAF) de l'API webPerformanceObserver({ entryTypes: ['long-animation-frame'] }), qui attribuent les blocages > 50ms au script responsable — c'est aujourd'hui la meilleure source RUM pour le jank, indépendante d'Angular.
2. Budgets de bundle = budgets de CD indirects. Plus de composants chargés = arbre plus large à cocher. Les budgets dans angular.json (bundle / initial) échouent le build si le JS dépasse un seuil — c'est ta première ligne de défense contre la dérive de perf.
3. Le triage staff face à un rapport « l'app rame ». Toujours dans cet ordre, parce que chaque levier adresse une dimension différente du coût nb_ticks × nb_composants_cochés × coût_par_check :
| Symptôme RUM / Profiler | Dimension | Levier |
|---|---|---|
| Beaucoup de frames CD/s même au repos | nb_ticks | runOutsideAngular, zoneless, eventCoalescing |
| Peu de ticks mais chacun très long | coût_par_check | computed/memo, pipes purs, sortir le calcul du template, Web Worker |
| Tick long proportionnel à la taille de l'écran | nb_composants | OnPush, virtual scroll, @defer |
| Jank uniquement au premier rendu | hydration/LCP | SSR + hydration incrémentale, @defer (on viewport) |
4. Sécurité & CD. Point souvent oublié : un binding [innerHTML] recalculé à chaque tick rejoue le DomSanitizer. Si la source est du contenu utilisateur ou LLM (cf. section IA), un tick à 60fps qui re-sanitize une grande chaîne markdown est à la fois un coût CPU et augmente la surface si on a la tentation de bypassSecurityTrustHtml pour « gagner du temps ». Règle : sanitize une fois dans un computed, jamais dans le template, et jamais de bypass sur de la donnée non fiable.
🎬 Cas d'usage concrets
Scénario 1 — Dashboard banque, trading à 60fps
Contexte : poste de travail trader avec 8 panneaux live alimentés par 3 WebSocket à ~1500 msg/s combinés. Chaque panneau (carnet d'ordres, P&L, positions, news, alertes) doit refléter les changements en moins de 16ms pour rester à 60fps. Approche : zoneless mode (provideZonelessChangeDetection) pour éliminer le coût Zone.js, signals partout comme source de vérité, et un signal par cellule animée (prix bid/ask) pour que seul le DOM concerné se réévalue. Les ticks WebSocket sont batchés via bufferTime(16) dans un service, puis appliqués via un untracked() sur un seul signal.update() pour éviter de réveiller 8 panneaux par message. Les charts SVG (D3) sont rendus hors Angular avec NgZone.runOutsideAngular(() => ...) car on ne veut pas que chaque frame d3 déclenche un CD cycle. Le profilage avec Angular DevTools (Profiler tab) montre des cycles CD de 2ms sur 80 composants vs 28ms en mode Default — différence visible à l'œil sur les animations.
Scénario 2 — E-commerce catalog, 1000 produits affichés
Contexte : page catégorie du retailer mode qui affiche jusqu'à 1000 produits avec scroll infini, filtres facettés, et hover preview vidéo. Sans optimisation, chaque interaction (filtre, scroll) faisait un CD de 200ms+ (visible jank). Approche : @for avec track sur l'id produit pour éviter les re-créations DOM, ChangeDetectionStrategy.OnPush sur ProductCardComponent, inputs en signal (input.required<Product>()), CDK Virtual Scroll pour ne rendre que les ~30 cartes visibles à la fois, @defer (on viewport) sur la vidéo de chaque carte pour ne charger l'élément <video> que lorsqu'on hover. Les pipes coûteux (formatage devise, traduction taille) sont remplacés par des computed calculés une fois par produit. Résultat : CD de 4ms sur le changement de filtre, scroll fluide à 60fps, Lighthouse Performance passé de 62 à 94.
Scénario 3 — SaaS RH, liste de 5000 candidats
Contexte : recruteur du SaaS RH ouvre la liste paginée de candidats (5000 lignes potentielles, pagination serveur 50/page mais filtres client sur la page courante). Avec 50 lignes × 8 cellules formatées par pipe (date, currency, i18n), le CD initial prenait 180ms. Approche : MatTableHarness avec CDK virtual scroll sur 50 lignes ne change rien (peu de lignes), donc on attaque les pipes. Remplacement des pipes par des fields pré-calculés côté composant (candidat.afficheDate, candidat.afficheSalaire) calculés une fois dans un computed ou à la réception API. Application d'OnPush + signals sur CandidatRowComponent. Désactivation du markForCheck global déclenché par un timer toutes les secondes (oubli historique). Résultat : CD à 12ms, et surtout réactivité immédiate des filtres (typing dans le champ recherche) car le re-render ne touche que les lignes filtrées. Pour les listes vraiment longues (5000+ visibles), bascule sur CDK Virtual Scroll avec trackBy sur id.
🛠️ Exemple end-to-end
Use case : table candidats SaaS RH avec 5000 lignes, virtual scroll, OnPush, signals, et tri/filtre instantané.
// candidat.model.ts
export interface Candidat {
readonly id: string;
readonly nom: string;
readonly poste: string;
readonly stage: string;
readonly salairePretendu: number;
readonly dateAjout: string;
}
export interface CandidatRow extends Candidat {
readonly afficheSalaire: string;
readonly afficheDate: string;
}// candidats-list.component.ts
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { CandidatRowComponent } from './candidat-row.component';
import { CandidatsService } from './candidats.service';
import { Candidat, CandidatRow } from './candidat.model';
@Component({
selector: 'app-candidats-list',
imports: [ScrollingModule, CandidatRowComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<input type="search" (input)="onFilter($any($event.target).value)" placeholder="Rechercher…" />
<select (change)="onSort($any($event.target).value)">
<option value="nom">Nom</option>
<option value="salaire">Salaire</option>
<option value="date">Date</option>
</select>
<cdk-virtual-scroll-viewport itemSize="48" class="viewport">
<app-candidat-row
*cdkVirtualFor="let row of visible(); trackBy: trackById"
[row]="row"
/>
</cdk-virtual-scroll-viewport>
`,
styles: [`.viewport { height: 600px; }`],
})
export class CandidatsListComponent {
private readonly api = inject(CandidatsService);
private readonly filter = signal('');
private readonly sortKey = signal<'nom' | 'salaire' | 'date'>('nom');
private readonly all = signal<ReadonlyArray<CandidatRow>>([]);
protected readonly visible = computed(() => {
const q = this.filter().toLowerCase();
const list = this.all().filter((c) => !q || c.nom.toLowerCase().includes(q));
const key = this.sortKey();
return [...list].sort((a, b) => {
if (key === 'salaire') return a.salairePretendu - b.salairePretendu;
if (key === 'date') return a.dateAjout.localeCompare(b.dateAjout);
return a.nom.localeCompare(b.nom);
});
});
constructor() {
const fmtEUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
const fmtDate = new Intl.DateTimeFormat('fr-FR');
this.api.loadAll().then((items) => {
this.all.set(items.map((c: Candidat) => ({
...c,
afficheSalaire: fmtEUR.format(c.salairePretendu),
afficheDate: fmtDate.format(new Date(c.dateAjout)),
})));
});
}
onFilter(v: string) { this.filter.set(v); }
onSort(v: 'nom' | 'salaire' | 'date') { this.sortKey.set(v); }
trackById = (_: number, row: CandidatRow) => row.id;
}// candidat-row.component.ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { CandidatRow } from './candidat.model';
@Component({
selector: 'app-candidat-row',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="row" [attr.data-stage]="row().stage">
<span class="nom">{{ row().nom }}</span>
<span class="poste">{{ row().poste }}</span>
<span class="stage">{{ row().stage }}</span>
<span class="salaire">{{ row().afficheSalaire }}</span>
<span class="date">{{ row().afficheDate }}</span>
</div>
`,
styles: [`.row { display: grid; grid-template-columns: 2fr 2fr 1fr 1fr 1fr; height: 48px; }`],
})
export class CandidatRowComponent {
readonly row = input.required<CandidatRow>();
}// candidats-list.perf.spec.ts (mesure de perf budgets)
import { TestBed } from '@angular/core/testing';
import { CandidatsListComponent } from './candidats-list.component';
it('CD initial sous 30ms pour 5000 candidats', async () => {
const fixture = TestBed.createComponent(CandidatsListComponent);
const t0 = performance.now();
fixture.detectChanges();
const dt = performance.now() - t0;
expect(dt).toBeLessThan(30);
});Combinaison : signals + computed + virtual scroll + OnPush + pré-calcul = liste de 5000 candidats fluide, filtre/tri instantanés, CD < 15ms par tick.
🤖 CD-perf pour une UI d'agent IA en streaming
C'est le cas où la Change Detection devient le goulot d'étranglement de manière non-évidente. Un agent Claude (claude-opus-4-8 pour la qualité, claude-haiku-4-5 pour le throughput) qui streame des tokens via SSE peut émettre 50 à 150 chunks/seconde. Si chaque chunk déclenche un tick CD qui re-rend tout le thread de conversation (markdown, code highlighting, timeline de tool-calls), tu passes 28ms par frame et l'UI saccade pendant que le modèle « parle ». Le bon design plie exactement les leviers de cette note.
Principe 1 — buffer append-only + coalescing rAF. Ne pas signal.set() à chaque token (un tick par token = mort). On accumule dans un buffer et on flushe le signal une fois par frame d'animation. En zoneless, le flush du signal est ce qui planifie le tick — donc 1 tick/frame max, pas 1 tick/token.
// agent-stream.service.ts
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class AgentStreamService {
// signal lu par le template — flushé au rythme des frames, pas des tokens
readonly answer = signal('');
readonly status = signal<'idle' | 'streaming' | 'done' | 'error'>('idle');
private pending = '';
private rafId: number | null = null;
private controller: AbortController | null = null;
async start(prompt: string, generationId: string): Promise<void> {
this.controller = new AbortController();
this.answer.set('');
this.status.set('streaming');
const res = await fetch('/api/agent/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': generationId },
body: JSON.stringify({ prompt }),
signal: this.controller.signal, // annulation client → coupe le fetch
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
// parse SSE : ici on simplifie en supposant un flux texte de deltas
this.pending += decoder.decode(value, { stream: true });
this.scheduleFlush();
}
this.flush();
this.status.set('done');
} catch (e) {
if ((e as Error).name !== 'AbortError') this.status.set('error');
}
}
stop(): void {
this.controller?.abort(); // rejette le reader.read() en cours → AbortError attrapé sans passer en 'error'
this.controller = null;
this.status.set('idle');
}
private scheduleFlush(): void {
if (this.rafId !== null) return; // déjà une frame en attente → coalesce
this.rafId = requestAnimationFrame(() => {
this.rafId = null;
this.flush();
});
}
private flush(): void {
if (!this.pending) return;
// un seul set par frame → un seul tick CD par frame
this.answer.update((cur) => cur + this.pending);
this.pending = '';
}
}Principe 2 — Stop wired client ET serveur. Le bouton Stop doit faire deux choses : controller.abort() côté client (arrête de lire et libère le reader) et propager au serveur pour qu'il abort() l'appel Anthropic — sinon tu continues de payer des tokens pour une réponse que personne ne lit. La déconnexion du fetch ferme la socket ; un backend NestJS bien fait écoute req.on('close') et AbortController.abort() l'appel SDK (coût-guard). Sans ça, fuite de coût.
Principe 3 — timeline de tool-calls en union discriminée. L'agentic loop côté serveur émet des steps (tool_use → exécution → tool_result). Côté UI, on les modélise en union discriminée pour un rendu OnPush propre, où chaque step est un signal et seul le step qui change re-rend.
type ToolStep =
| { kind: 'pending'; id: string; tool: string }
| { kind: 'running'; id: string; tool: string; input: unknown }
| { kind: 'streaming'; id: string; tool: string; partial: string }
| { kind: 'done'; id: string; tool: string; output: unknown; ms: number }
| { kind: 'error'; id: string; tool: string; message: string };// agent-timeline.component.ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
@Component({
selector: 'app-agent-timeline',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ol class="timeline">
@for (step of steps(); track step.id) {
<li [attr.data-kind]="step.kind">
@switch (step.kind) {
@case ('pending') { <span>⏳ {{ step.tool }}</span> }
@case ('running') { <span>▶️ {{ step.tool }}…</span> }
@case ('streaming') { <span>📡 {{ step.tool }} : {{ step.partial }}</span> }
@case ('done') { <span>✅ {{ step.tool }} ({{ step.ms }} ms)</span> }
@case ('error') { <span>❌ {{ step.tool }} — {{ step.message }}</span> }
}
</li>
}
</ol>
`,
})
export class AgentTimelineComponent {
readonly steps = input.required<ReadonlyArray<ToolStep>>();
}Principe 4 — markdown sécurisé. Le texte du modèle est du markdown non fiable. On le rend en HTML (marked/markdown-it) puis on sanitize via DomSanitizer avant [innerHTML]. Ne jamais bypassSecurityTrustHtml sur de la sortie LLM brute — c'est une porte XSS si l'agent recrache du contenu attaquant (prompt injection → markdown malveillant). Pour éviter de re-parser tout le markdown à chaque frame de streaming, mémoïse via un computed sur le texte stable, et n'applique le highlight de code qu'au step done.
import { Component, computed, inject, input } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { marked } from 'marked';
@Component({
selector: 'app-agent-message',
template: `<div class="md" [innerHTML]="safeHtml()"></div>`,
})
export class AgentMessageComponent {
private readonly sanitizer = inject(DomSanitizer);
readonly markdown = input.required<string>();
// computed : re-parse uniquement quand le texte change, pas à chaque CD
readonly safeHtml = computed<SafeHtml>(() =>
this.sanitizer.sanitize(1 /* SecurityContext.HTML */, marked.parse(this.markdown()) as string) ?? '',
);
}Pourquoi zoneless + signals est ici un multiplicateur. Sous Zone.js, chaque reader.read() (une Promise) déclencherait un tick par chunk — exactement ce qu'on veut éviter. En zoneless, seul le signal.update() du flush rAF planifie un tick. On passe mécaniquement de ~120 ticks/s à ~60 (capés par rAF), et chaque tick ne re-coche que le composant message + la timeline (OnPush). Profiler Angular DevTools : frame de streaming à 1,5–3ms au lieu de 15–20ms. Le track step.id garantit que la liste de tool-calls ne re-crée pas le DOM des steps déjà terminés.
🔁 Quand utiliser / éviter
| Pattern | Utiliser quand | Éviter quand |
|---|---|---|
| OnPush | Toujours (2026 default) | Jamais (sauf composants legacy en migration) |
| Signals | Tout nouveau code | Code legacy avec RxJS profondément ancré |
| Zoneless | Nouveaux projets, perf critique | Apps avec libs tierces qui dépendent de Zone.js |
runOutsideAngular | Events fréquents (scroll, mouse, resize) | Logique métier qui doit déclencher CD |
| Web Workers | Calculs > 50ms (parsing, chiffrement, algo) | Petits calculs (overhead de message passing) |
@defer | Composants secondaires lourds | Composants critiques pour le LCP |
| Détacher CD | Visualisations spécialisées (canvas, SVG complexe) | Composants standards (overhead de maintenance) |
shareReplay | Observable consommé plusieurs fois dans template | Observable consommé une seule fois |
| Pipes purs | Transformations stateless stables | Calculs lourds (préférer computed) |
markForCheck | Cas isolés où signals ne sont pas possibles | Code moderne avec signals |
🏋️ Exercices
Progression : implémenter → rendre production-grade → casser puis réparer. Fais-les en mode zoneless (provideZonelessChangeDetection) pour que les bugs de réactivité soient visibles — c'est le but.
Exercice 1 — Compteur de ticks (instrumenter la CD)
Objectif : prouver, chiffres à l'appui, qu'OnPush + signals réduit le nombre de CD.
Crée un composant qui incrémente un compteur statique partagé à chaque ngDoCheck et l'affiche dans un coin de l'écran. Monte deux sous-arbres identiques : un en Default, un en OnPush + signals. Déclenche des events globaux (un setInterval qui ne touche rien, des clics ailleurs) et compare les compteurs.
Indice/Solution : un
@InjectablesingletonCdCounteraveccount = signal(0), injecté dans chaque composant,ngDoCheck() { this.counter.bump(this.constructor.name); }. En zoneless, le sous-arbre Default ne bouge que si un ancêtre est marqué — observe que sans signal, il ne se met jamais à jour. C'est l'illustration du modèleDirty/skip.
Exercice 2 — Migrer une liste *ngFor + pipe impur vers du fine-grained
Objectif : transformer une liste qui jank en une liste à 60fps sans changer le rendu.
Pars d'une liste de 2000 lignes en *ngFor (sans trackBy) avec un pipe pure: false qui formate une date dans chaque cellule, en Default. Mesure le CD au Profiler. Migre vers @for + track id, OnPush, pré-calcul des champs formatés dans un computed, et CDK Virtual Scroll. Documente le delta de ms.
Indice/Solution : le coupable principal est le
pure: false(recalculé pour chaque cellule à chaque tick). Le remplacer par un champ pré-formaté à la réception API gagne le plus.track idsupprime la re-création DOM, virtual scroll borne le nombre de nœuds. Cible : passer de >150ms à <10ms par interaction.
Exercice 3 — Rendre le canvas cdr.detach() production-grade
Objectif : un composant dataviz détaché qui reste correct quand ses données changent.
Reprends CanvasVisualizationComponent. Ajoute un input.required<DataPoint[]>(). Problème : avec cdr.detach(), changer l'input ne redessine rien (la CD est coupée). Fais en sorte que le canvas se redessine quand l'input change, sans réactiver la CD du template.
Indice/Solution : ne détache pas aveuglément. Garde un
effect(() => { const d = this.data(); this.draw(d); })dans le constructor — l'effect a son propre cycle, indépendant de la CD du template, donc il réagit au signal d'input même détaché. La boucle rAF reste pour l'animation ; l'effect gère les changements de données. C'est la combinaisondetach(pas de CD template inutile) +effect(réactivité ciblée).
Exercice 4 — Casser le zoneless, puis le réparer
Objectif : reproduire et diagnostiquer le bug n°1 de la migration zoneless.
Dans une app zoneless, écris un composant où un setTimeout(() => { this.label = 'fini'; }, 1000) met à jour une propriété de classe (pas un signal) lue dans le template. Observe que le DOM ne change jamais. Diagnostique au Profiler (aucun tick planifié). Répare de trois façons et explique le tradeoff de chacune.
Indice/Solution : (a) convertir
labelensignal— le.set()planifie un tick, c'est la bonne réponse 2026 ; (b) injecterChangeDetectorRefetmarkForCheck()dans lesetTimeout— marche mais c'est un pansement ; (c) injecterApplicationRefetappRef.tick()— force global, à éviter. La leçon : en zoneless, toute mutation visible doit transiter par un signal/async/markForCheck, sinon Angular ne sait pas qu'il doit re-render.
Exercice 5 — Streaming d'agent IA sans saccade
Objectif : afficher un flux de tokens à 100 chunks/s sans descendre sous 60fps.
Simule un endpoint SSE qui émet un token toutes les ~8ms. Branche-le naïvement avec un signal.set() par token et profile (tu verras la saccade). Refactore avec le buffer append-only + coalescing rAF de la section IA. Ajoute un bouton Stop qui abort() le fetch. Mesure le nombre de ticks/s avant/après.
Indice/Solution : naïf = 1 tick/token ≈ 120 ticks/s, chaque tick re-parse le markdown → jank. Coalescé = 1
update()par frame rAF ≈ 60 ticks max, markdown mémoïsé viacomputed. Le Stop est unAbortControllerpartagé entre le fetch et un signalstatus. Bonus production-grade : propager l'abort au serveur (header +req.on('close')) pour ne pas payer de tokens après l'arrêt.
Exercice 6 (bonus, architecte) — Budget de perf CI
Objectif : empêcher une régression de CD de passer en review.
Écris un test qui monte la liste de 5000 candidats, mesure performance.now() autour de detectChanges(), et échoue si > 30ms. Intègre-le au CI. Puis introduis volontairement une régression (un pipe impur) et vérifie que le test rouge bloque le merge.
Indice/Solution : voir
candidats-list.perf.spec.ts. Attention : les budgets temps sont flaky en CI partagé — mesure plutôt un proxy déterministe (nombre dengDoCheckappelés, nombre de nœuds DOM rendus) en complément du wall-clock. Un compteur de CD est reproductible ; les millisecondes ne le sont pas.
🎤 En entretien
Q : « OnPush rend-il l'application plus rapide ? » R : Non, c'est un piège. OnPush réduit le nombre de composants vérifiés par tick (skip des branches non-dirty) mais ne change ni le coût d'un check, ni le nombre de ticks. Si le bottleneck est un computed O(n²) ou des ticks déclenchés 60×/s par un mousemove, OnPush n'aide pas. On mesure d'abord au Profiler, puis on choisit le levier : largeur d'arbre → OnPush, coût de check → computed/pipes purs, fréquence de ticks → runOutsideAngular/zoneless.
Q : « En OnPush, pourquoi un signal met-il le composant à jour alors qu'une propriété de classe mutée ne le fait pas ? » R : Un signal consommé dans le template enregistre une dépendance ; à son émission il appelle markForCheck() sur la vue, qui pose le flag Dirty et remonte HasChildViewsToRefresh jusqu'à la racine, garantissant que la branche sera re-cochée au prochain tick. Une mutation de propriété de classe ne notifie personne — en Default+Zone elle est rattrapée par le balayage global, mais en OnPush (et a fortiori en zoneless) rien ne marque la vue dirty, donc le DOM ne bouge pas.
Q : « Qu'est-ce qui change concrètement en zoneless, et quel est le risque de migration n°1 ? » R : Zone.js monkey-patchait toutes les API async (setTimeout, Promise, addEventListener, XHR) pour déclencher ApplicationRef.tick() à chaque macrotask. En zoneless, ce déclencheur disparaît : un tick n'est planifié que par un signal qui notifie, markForCheck, un event handler de template, ou un tick() manuel. Risque n°1 : tout code qui mettait à jour l'UI via une mutation nue dans un callback async (setTimeout(() => this.x = 5)) casse silencieusement — l'UI ne se met plus à jour. Fix : passer par des signals partout. Gain : suppression de l'overhead de patching, CD 30-50% plus rapide sur les gros arbres.
Q : « Quelle est la différence entre ngOnChanges, ngDoCheck et un effect, et quand chacun se déclenche-t-il ? » R : ngOnChanges ne se déclenche que pour les @Input() décorateurs (pas les input() signals !) et seulement quand Angular détecte un changement de référence d'input, avant le check de la vue. ngDoCheck se déclenche à chaque CD effective du composant (d'où son usage comme compteur de CD déterministe) — c'est là qu'on branchait une détection de changement custom à l'ère pré-signals (KeyValueDiffers). effect est découplé de la CD du composant : il s'exécute quand un signal qu'il lit change, sur son propre cycle de planification (microtask), même si le composant est OnPush détaché. Piège classique : attendre que ngOnChanges réagisse à un input() signal — il ne le fait pas, il faut un effect ou un computed.
Q : « Qu'est-ce que la réactivité "fine-grained" et en quoi diffère-t-elle de OnPush + signals d'aujourd'hui ? » R : Aujourd'hui, même avec signals, l'unité de re-rendu reste le composant : un signal qui change marque la vue dirty et Angular re-coche tous les bindings de ce composant, puis applique le diff. La réactivité fine-grained (sur la roadmap, à la SolidJS) supprimerait cette étape : le signal mettrait à jour directement le nœud DOM lié à ce binding précis, sans cocher le reste du composant ni passer par un diff de vue. Concrètement, mettre à jour un prix dans une carte ne toucherait que ce <span>, jamais le composant entier. C'est l'aboutissement logique de la migration signals — les computed/input() qu'on écrit aujourd'hui sont déjà compatibles, c'est le moteur de rendu qui évoluera sous nos pieds.
Q : « Tu streames des tokens d'un LLM à 100 chunks/s dans une UI Angular et ça saccade. Comment tu débugges et corriges ? » R : Symptôme classique d'un tick par chunk. Au Profiler je confirme ~100-120 frames CD/s, chacune re-parsant le markdown. Correction : accumuler les tokens dans un buffer et flusher le signal une fois par frame via requestAnimationFrame (coalescing) — on passe à ≤60 ticks/s. Mémoïser le rendu markdown dans un computed pour ne pas re-parser à chaque tick, sanitize via DomSanitizer (jamais bypassSecurityTrustHtml sur de la sortie LLM). OnPush + track sur les messages pour ne re-cocher que la bulle active. Et un bouton Stop câblé sur un AbortController qui coupe le fetch et propage l'annulation au serveur pour arrêter de payer des tokens.
🔗 Liens
- Angular DevTools (extension Chrome/Firefox officielle)
- Documentation Change Detection : https://angular.dev/guide/components/change-detection
- Zoneless Change Detection : https://angular.dev/guide/experimental/zoneless
- Article Minko Gechev — « The 6 levels of performance optimization in Angular »
- Article Jeff Cross — « Understanding Angular Change Detection »
- @defer blocks : https://angular.dev/guide/templates/defer
- Web Workers Angular : https://angular.dev/guide/web-workers
@angular-eslint/prefer-on-push-component-change-detectionrule