Quand la page arrive déjà rendue par le serveur (SSR ou prerender), le navigateur a déjà le DOM complet. Sans hydration, Angular démarrerait comme dans une application CSR : il jetterait ce DOM et le reconstruirait — un scintillement visible, un LCP dégradé, un décalage de mise en page. L’hydration signifie qu’Angular parcourt le DOM existant, le fait correspondre à l’arbre de composants et le réutilise : il branche les bindings et attache les listeners aux nœuds qui existent déjà.
Deux optimisations ont été construites par-dessus : l’event replay — les clics faits avant l’hydration ne sont pas perdus — et l’hydration incrémentale — des parties de la page restent du HTML statique, sans JS téléchargé, jusqu’à ce qu’on en ait vraiment besoin.
- L’hydratation réutilise le HTML envoyé par le serveur au lieu de le recréer : la page ne clignote pas au démarrage.
- L’event replay retient les clics faits avant le chargement du JavaScript et les rejoue ensuite.
- L’hydratation incrémentale (
@deferavechydrate on …) laisse des parties de la page non interactives jusqu’à ce qu’on en ait besoin. Leur code n’est téléchargé qu’à ce moment-là. - Le HTML du serveur et celui du navigateur doivent être identiques. Sinon apparaissent des erreurs de non-correspondance, avec des codes
NG05xx. ngSkipHydrationest la sortie de secours pour un composant qui modifie le DOM directement.
provideClientHydration()démarre l’hydration incrémentale par défaut, et active automatiquement l’event replay.withIncrementalHydration()est déprécié (suppression prévue en v24) — retirez-le de la config, c’est déjà le comportement par défaut.- La nouvelle option de désactivation est
withNoIncrementalHydration().
Ce que fait l’hydration, étape par étape
Sur le serveur, après le rendu, Angular annote le HTML pour pouvoir le « retrouver » plus tard : l’attribut ngh sur chaque hôte de composant (un index dans les données d’hydration), des commentaires d’ancrage pour @if/@for/@defer, un commentaire HTML nghm comme marqueur d’intégrité, jsaction sur les éléments qui ont des listeners, et un <script id="ng-state"> avec les données d’hydration (__nghData__) et les réponses HTTP du transfer cache.
@Component({
selector: 'app-product-card',
template: `
<h2>{{ product().name }}</h2>
@if (product().stock > 0) {
<button (click)="addToCart()">Ajouter au panier</button>
}
`,
})
export class ProductCard {
readonly product = input.required<Product>();
addToCart() { /* ... */ }
}Dans le navigateur, au bootstrap, Angular ne crée plus de nœuds : il cherche les nœuds existants, grâce aux annotations. Pour chaque nœud, il vérifie le type et la balise (pas le texte) et attache les listeners. Un texte différent n’est pas une erreur — il est simplement écrasé à la première Change Detection.
Une fois l’application stable (aucune requête HTTP ni PendingTasks en cours), vient l’étape finale : Angular supprime les nœuds du serveur que personne n’a réclamés, rejoue les événements de la file et désactive le transfer cache. C’est pourquoi une application qui ne devient pas stable a des effets visibles même quand le DOM a été correctement réutilisé.
| Sans hydration | Hydration complète | Hydration incrémentale | |
|---|---|---|---|
| Le DOM du serveur | jeté et recréé | réutilisé | réutilisé |
| JS nécessaire au démarrage | tout | tout | seulement ce qui est en dehors des blocs @defer (hydrate …) |
| Quand un bloc devient interactif | après le nouveau rendu | au bootstrap, la page entière d’un coup | au trigger du bloc |
| Clics avant le JS | perdus | rejoués seulement avec withEventReplay() | rejoués ; un clic sur un bloc déshydraté l’hydrate |
provideClientHydration et ses options
Si vous avez ajouté le SSR avec ng add @angular/ssr, le provider est déjà dans app.config.ts. Sans arguments, en v22, vous obtenez l’ensemble recommandé :
import { ApplicationConfig } from '@angular/core';
import { provideClientHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
// v22, sans arguments :
// ✓ hydration non destructive (réutilise le DOM du serveur)
// ✓ HTTP transfer cache (les GET/HEAD faits sur le serveur ne sont pas répétés dans le navigateur)
// ✓ hydration incrémentale (@defer avec hydrate …) — inclut l’event replay
// ✓ en mode dev : provideStabilityDebugging()
provideClientHydration(),
],
};
// app.config.server.ts fait mergeApplicationConfig(appConfig, serverConfig),
// le provider atteint donc aussi le serveur — obligatoire, sinon NG0505 apparaît.| Fonction | Ce qu’elle fait | En v22 |
|---|---|---|
provideClientHydration() | hydration non destructive + HTTP transfer cache + hydration incrémentale (avec event replay) | l’ensemble par défaut |
withNoIncrementalHydration() | désactive l’hydration incrémentale (et, avec elle, l’event replay) | nouveau en v22 |
withEventReplay() | event replay sans hydration incrémentale | inclus par défaut ; explicite seulement avec la désactivation ci-dessus |
withI18nSupport() | hydrate aussi les composants avec des blocs i18n ; sans elle, ils sont rendus de zéro | optionnel |
withHttpTransferCacheOptions({…}) | configure quelles réponses se retrouvent dans le HTML : filter, includeHeaders, includePostRequests, includeRequestsWithAuthHeaders… | optionnel |
withNoHttpTransferCache() | désactive le transfer cache : chaque GET est exécuté deux fois | optionnel |
withIncrementalHydration() | servait à activer l’hydration incrémentale | dépréciée depuis la v22 (suppression prévue en v24) |
Le provider doit aussi exister dans la config serveur — le app.config.server.ts généré par la CLI fait mergeApplicationConfig(appConfig, serverConfig), il y est donc automatiquement. S’il manque, le serveur n’annote pas le HTML, et dans le navigateur la page est rendue de zéro (en mode dev, avec l’avertissement NG0505). Les combinaisons contradictoires (par exemple withNoHttpTransferCache() avec withHttpTransferCacheOptions()) lèvent l’erreur NG05001 en mode dev.
Event replay
Le problème : le HTML du serveur est visible bien avant l’arrivée du JS (dans la timeline de la page SSR, c’est la bande jaune). L’utilisateur voit un bouton et appuie dessus — mais le bouton n’a pas encore de listener.
Le mécanisme repose sur JSAction, une bibliothèque de délégation d’événements intégrée à Angular :
- Marquage — pendant la sérialisation, le serveur place
jsaction="click:;"sur chaque élément qui a un listener dans le template. - Capture — un petit script inline, injecté juste après
<body>(l’event contract), écoute au niveau debodyles types d’événements utilisés dans la page et les garde en mémoire. - Relecture — après l’hydration, quand l’application devient stable, Angular envoie les événements en file, dans l’ordre, aux vrais handlers.
Cela fonctionne pour les événements natifs du navigateur (click, mouseover, focusin, etc.). Avec l’hydration incrémentale, le contract reste actif aussi après l’hydration, ce qui lui permet d’attraper les événements dans les blocs encore déshydratés.
- Appuyez sur « Recharger la page », puis cliquez sur les boutons tant que la barre JS n’est pas pleine.
Le replay sauvegarde l’action, mais le bouton ne réagit pas visuellement avant l’hydration. Les états CSS (:hover, :active, :focus-visible) fonctionnent sans JS — utilisez-les, pour que l’utilisateur sache que le clic a été « pris en compte ».
Hydration incrémentale avec @defer
Un @defer ordinaire ne rend que le @placeholder sur le serveur : les triggers ne s’exécutent pas sur le serveur. Un trigger hydrate change cela : le serveur charge les dépendances du bloc et rend le contenu principal. Dans le navigateur, le bloc reste déshydraté — le HTML est visible, mais le chunk JS des composants qu’il contient n’est même pas téléchargé — jusqu’au déclenchement du trigger. Alors Angular télécharge le chunk, hydrate le bloc et rejoue les événements en file.
Deux conséquences pratiques. Première : vous pouvez placer un @defer au-dessus de la ligne de flottaison sans décalage de mise en page, car le contenu final est déjà à l’écran, pas le placeholder. Seconde : un événement sur un élément avec listener dans un bloc déshydraté déclenche son hydration, quel que soit le trigger déclaré — le clic n’est pas perdu. L’exception est hydrate never : son sous-arbre n’est pas du tout annoté, il n’y a donc rien à rejouer.
<app-header /> <!-- pas de @defer : hydraté au bootstrap -->
@defer (hydrate on interaction) {
<app-gallery [images]="product().images" /> <!-- le JS de la galerie : seulement au premier clic -->
} @placeholder {
<div class="gallery-skeleton"></div> <!-- utilisé seulement lors d’une navigation côté client -->
}
@defer (hydrate on hover) {
<app-recommendations [productId]="id()" />
} @placeholder {
<div class="reco-skeleton"></div>
}
@defer (hydrate on idle) {
<app-support-chat />
} @placeholder {
<button>Chat</button>
}
@defer (hydrate on viewport) {
<app-reviews [productId]="id()" /> <!-- sous la ligne de flottaison : JS quand il entre à l’écran -->
} @placeholder {
<div class="reviews-skeleton"></div>
}
@defer (hydrate never) {
<app-footer /> <!-- HTML statique pour toujours -->
}pas de @defer (main.js)déshydraté48 KB, non téléchargéshydrate on interactiondéshydraté36 KB, non téléchargéshydrate on hoverdéshydraté22 KB, non téléchargéshydrate on idledéshydraté30 KB, non téléchargés↓ faites défiler jusqu’aux avis ↓
hydrate on viewportdéshydraté64 KB, non téléchargéshydrate neverstatique, sans JS0 KB (serait 18)- La simulation démarre quand la démo entre dans la zone visible (ou appuyez sur « Recharger »).
Les triggers d’hydration
@defer (hydrate on idle) { … } <!-- requestIdleCallback -->
@defer (hydrate on idle(500)) { … } <!-- idle, mais au plus tard après 500 ms -->
@defer (hydrate on viewport) { … } <!-- le contenu du bloc entre à l’écran -->
@defer (hydrate on viewport({ rootMargin: '200px' })) { … } <!-- options littérales uniquement -->
@defer (hydrate on interaction) { … } <!-- click / keydown dans le bloc -->
@defer (hydrate on hover) { … } <!-- mouseover / focusin dans le bloc -->
@defer (hydrate on immediate) { … } <!-- juste après le reste de la page -->
@defer (hydrate on timer(2s)) { … } <!-- ms ou s -->
@defer (hydrate when cartReady()) { … } <!-- condition issue du composant parent -->
@defer (hydrate never) { … } <!-- jamais ; aucun autre trigger hydrate -->
@defer (hydrate on viewport; hydrate on timer(5s)) { … } <!-- plusieurs = n’importe lequel (OU) -->
<!-- ✗ erreurs de compilation -->
@defer (hydrate on interaction(btn)) { … } <!-- interaction/hover n’acceptent pas de référence -->
@defer (hydrate on viewport({ trigger: el })) { … }
@defer (hydrate never; hydrate on idle) { … } <!-- never exclut tout autre trigger hydrate -->| Trigger | Se déclenche quand | Paramètres |
|---|---|---|
hydrate on idle | le navigateur est disponible (requestIdleCallback) | optionnel, un timeout : idle(500) |
hydrate on viewport | le contenu du bloc entre à l’écran | optionnel, options IntersectionObserver littérales ; pas de référence à un autre élément |
hydrate on interaction | click ou keydown dans le bloc | aucun |
hydrate on hover | mouseover ou focusin dans le bloc | aucun |
hydrate on immediate | dès que le reste de la page a fini de s’afficher | — |
hydrate on timer(…) | après un intervalle | obligatoire, en ms ou s |
hydrate when expr | l’expression devient truthy | ne fonctionne que sur le bloc déshydraté le plus externe |
hydrate never | jamais : HTML statique, sous-arbre entier compris | ne peut pas être combiné avec d’autres triggers hydrate |
Plusieurs triggers sur le même bloc se combinent avec « n’importe lequel » (OU). hydrate when a une limite : l’expression appartient au composant parent, qui doit lui-même être hydraté pour que l’expression soit évaluée.
Blocs imbriqués
L’arbre de composants est hiérarchique : un enfant ne peut pas être actif sans son parent. Si le trigger se déclenche sur un bloc imbriqué, Angular hydrate la chaîne de haut en bas — d’abord le bloc déshydraté le plus externe, puis un par un jusqu’à celui qui l’a demandé.
@defer (hydrate on interaction) {
<app-product-tabs /> <!-- 1. le parent est hydraté en premier… -->
@defer (hydrate on hover) {
<app-size-guide /> <!-- 2. …puis l’enfant, même si le survol était sur lui -->
}
}Les triggers hydrate en CSR et lors de la navigation
L’hydration est une optimisation du chargement initial du contenu rendu par le serveur. Quand l’utilisateur arrive sur la même page par une navigation côté client (un routerLink), ou quand l’application n’a pas de SSR du tout, les triggers hydrate sont ignorés : le bloc se comporte comme un @defer ordinaire, avec son @placeholder et les triggers normaux. Si vous n’avez mis aucun trigger normal, le compilateur ajoute on idle.
@defer (on interaction; hydrate on viewport) {
<app-reviews />
} @placeholder {
<button>Afficher les avis</button>
}
<!--
Chargement initial, page rendue par le serveur :
→ le serveur rend <app-reviews /> (pas le placeholder)
→ dans le navigateur, cela reste du HTML statique jusqu’à son entrée dans le viewport (hydrate on viewport)
Navigation côté client (routerLink) ou application sans SSR :
→ hydrate on viewport est ignoré
→ le @placeholder est affiché, le bloc se charge au clic (on interaction)
→ sans trigger normal, le défaut aurait été on idle
-->@placeholderreste nécessaire — il ne s’affiche pas au chargement initial avec SSR, mais il s’affiche lors de toute navigation côté client vers la page.hydrate neversignifie « jamais de JS au chargement initial ». Lors d’un rendu côté client ultérieur, le bloc télécharge quand même ses dépendances, avec le trigger normal.- Dans une application sans
provideClientHydration()(ou avecwithNoIncrementalHydration()), les blocs avec des triggershydrateproduisent l’avertissement NG0508 en mode dev.
Erreurs de correspondance (mismatch)
L’hydration part d’une promesse : le DOM dans le navigateur est exactement celui que le serveur a produit, commentaires et espaces compris. Tout ce qui rompt la promesse — le parseur HTML du navigateur, un script tiers, un CDN, une branche de template différente — aboutit à l’une de trois situations : une erreur, un avertissement ou, le plus sournois, aucune erreur mais un nouveau rendu visible.
| Code | Signifie | Cause typique |
|---|---|---|
NG0500 | le nœud trouvé n’est pas celui attendu (balise ou type différent) ou il est absent | HTML invalide, manipulation directe du DOM, script tiers |
NG0501 | des nœuds frères sont absents | nœuds supprimés en dehors d’Angular |
NG0502 | le nœud n’est pas trouvé à la position attendue | structure modifiée après le serveur |
NG0503 | nœuds projetés créés en dehors d’Angular | createComponent avec des projectableNodes issus du DOM natif |
NG0504 | ngSkipHydration sur un élément qui n’est pas un hôte de composant | l’attribut placé sur un div |
NG0505 | (avertissement) les annotations du serveur sont absentes | provideClientHydration() manque dans la config serveur |
NG0506 | (avertissement) l’application ne devient pas stable en 10 s | PendingTasks non terminées ; avec Zone.js, des timers dans la zone Angular |
NG0507 | le HTML a été modifié après le serveur | un CDN qui supprime les commentaires |
<p class="promo"> <div>Livraison gratuite aujourd’hui</div> </p>
<p class="promo"><div>Livraison gratuite aujourd’hui</div></p><p class="promo"></p><div>Livraison gratuite aujourd’hui</div><p></p>
NG0500: During hydration Angular expected <div> but the node was not found.
Angular expected this DOM:
<p class="promo">
<div>…</div> <-- AT THIS LOCATION
…
</p>
To fix this problem:
* check the "PromoBanner" component for hydration-related issues
* check to see if your template has valid HTML structure
…Ce qui se passe : Le serveur construit le DOM via des API, donc l’imbrication invalide survit jusqu’à la sérialisation. Le parseur HTML du navigateur, lui, ferme le <p> dès qu’il rencontre le <div>, et Angular ne retrouve plus le nœud attendu.
Correctif : HTML valide : <p> n’accepte que du contenu inline. Utilisez un <div class="promo"> comme parent, ou un <span> à l’intérieur.
@if (isPlatformBrowser(…)) dans un template ne lève pas d’erreur, mais c’est l’une des erreurs les plus coûteuses : Angular jette le DOM du serveur pour le bloc et le reconstruit, avec un décalage de mise en page. Gardez un rendu identique sur les deux plateformes et déplacez l’initialisation réservée au navigateur dans afterNextRender — ou utilisez @defer, qui rend le placeholder sur le serveur. Les variantes sont sur la page Code compatible serveur.
ngSkipHydration : la sortie de secours
Pour un composant qui ne peut pas être corrigé immédiatement (typiquement une bibliothèque qui dessine directement dans le DOM, comme D3), l’attribut ngSkipHydration sur son hôte le sort de l’hydration : le composant et ses enfants sont détruits et rendus de nouveau, exactement comme sans hydration. Il ne fonctionne que sur les hôtes de composants (sinon NG0504), et placé sur le composant racine, il désactive l’hydration pour toute l’application.
<!-- sur l’hôte du composant qui manipule directement le DOM (p. ex. un graphique D3) -->
<app-sales-chart ngSkipHydration [data]="sales()" />
<!-- ✗ NG0504 : seulement sur les hôtes de composants, pas sur les éléments ordinaires -->
<div ngSkipHydration><app-sales-chart [data]="sales()" /></div>Débogage
- La console, en mode dev : une fois l’application stable, un résumé apparaît. Si vous ne le voyez pas, l’hydration n’a pas démarré (vérifiez NG0505).
- Angular DevTools affiche l’état d’hydration de chaque composant et peut activer une surcouche sur les parties hydratées ; en cas d’erreur de mismatch, il met en évidence le composant fautif.
- Code source vs Éléments : le code source est le HTML du serveur, Éléments est le DOM actuel. Les différences entre les deux (un
tbodyen plus, une bannière injectée) sont exactement les causes de NG0500. - Désactivez JavaScript dans les DevTools et rechargez : vous voyez exactement ce que l’utilisateur reçoit avant l’hydration.
- NG0506 :
provideStabilityDebugging()liste les tâches qui maintiennent l’application instable (avec Zone.js, importez aussizone.js/plugins/task-tracking). preserveWhitespacesdoit avoir la même valeur danstsconfig.app.jsonettsconfig.server.json; la valeur par défaut (false) est la recommandée.
Angular hydrated 14 component(s) and 212 node(s), 0 component(s) were skipped.
5 defer block(s) were configured to use incremental hydration.
Learn more at https://angular.dev/guide/hydration.Quand OUI, quand NON
- Hydration : dès que vous avez du SSR ou du prerender. Sans elle, le SSR perd la moitié de son intérêt (le DOM est reconstruit).
- Event replay : activé par défaut ; il compte surtout sur les pages avec des actions rapides (panier, like, menu) et les réseaux lents.
hydrate on viewportpour tout ce qui est sous la ligne de flottaison : avis, produits similaires, un footer riche.hydrate on interaction/hoverpour les widgets lourds qui restent inactifs jusqu’au premier contact : galeries, filtres, sélecteurs de taille.hydrate neverpour du contenu vraiment statique : un article, des CGV, un footer sans logique.
- Applications sans SSR : les triggers
hydratene font rien. Utilisez @defer avec des triggers normaux. @defer (hydrate …)sur des composants de quelques KB : le chunk et la requête supplémentaires coûtent plus que ce que vous économisez.hydrate neversur quelque chose qui a des listeners ou un état : cela ne fonctionnera jamais au chargement initial.ngSkipHydrationcomme « correctif » permanent : corrigez la cause (HTML valide,afterNextRender, DOM manipulé via Angular).- Des branches
isPlatformBrowserdans le template pour masquer les problèmes : l’alternative est@deferouafterNextRender.
Pièges fréquents
nativeElement.appendChild, innerHTML, document.querySelector(…).remove() dans des composants qui s’exécutent aussi sur le serveur produisent des structures que l’hydration ne peut pas faire correspondre. Utilisez le template (@if, @for, bindings, Renderer2) et reportez le code strictement navigateur dans afterNextRender.
- HTML invalide : un
<div>dans un<p>, un<a>dans un<a>, une table sans<tbody>. Validez le template ; le navigateur « répare » le HTML autrement que le serveur ne l’a écrit. - Des valeurs différentes sur le serveur et le client (
Date.now(),Math.random(), identifiants générés, fuseaux horaires) : pas d’erreur, mais le texte change sous les yeux de l’utilisateur. Transférez la valeur avecTransferState. - Des scripts tiers (analytics, consentement, chat) qui modifient le DOM avant l’hydration : chargez-les après, avec
afterNextRender. withEventReplay()etwithIncrementalHydration()laissés « au cas où » en v22 : le premier est redondant, le second est déprécié. Un simpleprovideClientHydration()suffit.- Un
@placeholdersupprimé des blocs avechydrate, parce que « de toute façon on ne le voit pas » : lors d’une navigation côté client, on le voit. - Des données personnelles dans le transfer cache : les réponses se retrouvent dans le HTML. Les requêtes avec
Authorization/Cookiesont exclues par défaut — ne les incluez pas avecincludeRequestsWithAuthHeaderssans raison ; utilisezfilteroutransferCache: false. - Un CDN avec minification HTML qui supprime les commentaires → NG0507 sur chaque page.
Aide-mémoire
// ── Config (app.config.ts ; atteint aussi le serveur via mergeApplicationConfig)
provideClientHydration(); // v22 : DOM + transfer cache + incrémentale + replay
provideClientHydration(withNoIncrementalHydration(), withEventReplay());
provideClientHydration(withI18nSupport());
provideClientHydration(withHttpTransferCacheOptions({ filter, includeHeaders, includePostRequests }));
provideClientHydration(withNoHttpTransferCache());
http.get(url, { transferCache: false }); // désactivation requête par requête
// ── Template
// @defer (hydrate on idle | idle(ms) | viewport | viewport({ rootMargin }) | interaction | hover | immediate | timer(2s))
// @defer (hydrate when cond) @defer (hydrate never) @defer (on idle; hydrate on viewport)
// <app-chart ngSkipHydration /> host: { ngSkipHydration: 'true' } ← dernier recours
// ── Erreurs
// NG0500 nœud différent NG0501 nœuds frères manquants NG0502 nœud manquant NG0503 nœuds projetés créés en dehors d’Angular
// NG0504 ngSkipHydration sur un non-composant NG0505 annotations manquantes (provider absent sur le serveur)
// NG0506 l’application ne devient pas stable en 10 s NG0507 HTML modifié après le serveur (commentaires supprimés)
// NG0508 triggers hydrate sans hydration incrémentale active