~9 min de lecture
@boundary : Angular 22.2 isole enfin un composant qui plante
Ta page produit affiche un prix et un badge de panier. Un jour, l'API renvoie un prix null. Le
composant Price lève une erreur dans son template. Ton monitoring reçoit l'erreur, très bien.
Ce que ton monitoring ne te dit pas : le badge de panier, affiché juste après, ne bouge plus. L'utilisateur ajoute trois articles, le badge reste à zéro. Personne n'a touché au badge. Il est simplement rendu après le composant qui plante, dans la même passe de change detection.
Angular 22.2, sortie le 23 septembre 2026, ajoute un bloc de template pour ça : @boundary. Il
enferme l'erreur dans une portion de la page, affiche un fallback à la place, et laisse le reste
tourner.
Tout ce qui suit a été exécuté sur
@angular/core22.2.0 en zoneless, avec des tests Vitest et une app bootstrapée. Le typeErrorDetailsqui accompagne la fonctionnalité est marqué@developerPreview 22.2: l'API peut encore bouger dans les prochaines versions. Côté installation, le 23 septembre,ng update @angular/coreproposait déjà la 22.2, alors que@angular/cliet@angular/buildétaient encore en 22.1.8 sur le taglatest.
TL;DR
Le cas, dans le @boundary |
Résultat |
|---|---|
Une expression du template, au premier rendu ou après un changement d'input() |
Fallback @error affiché |
Le constructeur, ngOnInit, un effect() |
Fallback @error affiché |
Un handler d'événement (click) |
Pas attrapé : l'erreur part vers ErrorHandler.handleError, la vue reste en place |
Une valeur qui n'est pas une Error (throw 'plain string') |
Emballée dans une ErrorBoundaryWrappedError, l'original dans .cause |
Aucun @error ne correspond |
Angular lève Unhandled error in @boundary fell through., l'erreur sort de la boundary |
Le problème, mesuré : un voisin figé
Deux composants sans histoire. Price lève une erreur quand son montant est null. CartBadge
affiche un nombre.
@Component({
selector: 'app-price',
template: `{{ label() }}`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Price {
readonly amount = input.required<number | null>();
protected label(): string {
const amount = this.amount();
if (amount === null) throw new Error('price missing');
return `${amount} EUR`;
}
}
@Component({
selector: 'app-cart-badge',
template: ` | panier: {{ count() }}`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CartBadge {
readonly count = input(0);
}
Le parent les pose côte à côte, sans aucune protection :
<app-price [amount]="amount()" />
<app-cart-badge [count]="count()" />
On passe le montant à null et, dans le même temps, on incrémente le panier. Puis on l'incrémente
encore.
| Étape | DOM rendu |
|---|---|
| Rendu initial | 42 EUR | panier: 0 |
amount.set(null), count.set(1) |
42 EUR | panier: 0 |
count.set(2) |
42 EUR | panier: 0 |
Le badge est gelé à 0. Et Price affiche toujours 42 EUR, une valeur périmée, sans que rien à
l'écran ne signale le problème.
L'explication est mécanique. Une erreur levée pendant la change detection interrompt la passe en
cours, et tout ce qui devait être rafraîchi après le composant fautif est sauté. À la passe
suivante (le prochain rafraîchissement déclenché par un changement d'état), Price est toujours
dans le même état, il lève de nouveau son erreur, et CartBadge est de nouveau sauté. Et ainsi à
chaque rafraîchissement.
Si tu as lu l'article sur les erreurs que ton ErrorHandler ne voit plus en zoneless,
c'est le cas inverse. Ici l'erreur est synchrone, elle remonte très bien jusqu'à ErrorHandler, une
fois par passe. Le monitoring est au courant. C'est l'écran qui ment.
La solution : @boundary et @error
Même parent, même scénario, on entoure le composant fragile :
@boundary {
<app-price [amount]="amount()" />
} @error {
<p>Prix indisponible.</p>
}
<app-cart-badge [count]="count()" />
| Étape | DOM rendu |
|---|---|
| Rendu initial | 42 EUR | panier: 0 |
amount.set(null), count.set(1) |
Prix indisponible. | panier: 1 |
count.set(2) |
Prix indisponible. | panier: 2 |
La vue de Price est retirée, le fallback prend sa place, et CartBadge se remet à jour. L'erreur
passe une seule fois par ErrorHandler, au moment où la boundary l'attrape. Elle ne revient pas à
chaque rafraîchissement.
Lire l'erreur et réessayer : $error et $reset
Le bloc @error expose deux variables de contexte, que tu renommes avec let comme dans un
@for :
@boundary {
<app-price [amount]="amount()" />
} @error (let err = $error, retry = $reset) {
<p>Prix indisponible : {{ err.message }}</p>
<button type="button" (click)="retry()">Réessayer</button>
}
$error est l'erreur attrapée, typée Error dans le template. $reset() efface l'erreur et
relance le rendu du contenu principal.
Attention au mot "relance" : $reset() ne corrige rien. Mesuré : remettre amount à 42
avant de cliquer sur "Réessayer" réaffiche bien 42 EUR. Cliquer sans rien changer ramène le
fallback, et ton ErrorHandler reçoit une deuxième fois la même erreur. Ne branche donc jamais
$reset() sur un timer de retry automatique sans avoir changé l'état : tu fabriques une boucle qui
inonde ton monitoring.
Un fallback par type d'erreur : @error (when ...)
Tu peux chaîner plusieurs blocs @error, chacun avec sa condition when, sauf le dernier :
@boundary {
<app-price [amount]="amount()" />
} @error (when isNetworkError($error)) {
<p>Connexion perdue.</p>
} @error {
<p>Une erreur inattendue est survenue.</p>
}
isNetworkError est une méthode du composant, appelée avec l'erreur. Le bloc @error est
facultatif pour le compilateur, qui impose deux règles : au plus un @error sans condition, et
s'il existe, il est le dernier de la chaîne.
Le piège est le cas où aucun bloc ne correspond : pas de @error générique, et une erreur que
ta condition refuse. L'erreur n'est pas avalée. Angular lève une erreur
Unhandled error in @boundary fell through., qui remonte hors de la boundary. Même résultat avec
un @boundary sans aucun @error. En pratique, garde toujours un @error final sans condition.
Boundaries imbriquées
Si le bloc @error lui-même lève une erreur, elle remonte à la boundary parente. Mesuré : une
boundary interne dont le fallback plante laisse la boundary externe afficher le sien, et
ErrorHandler reçoit les deux erreurs, dans l'ordre.
Ce que @boundary attrape, et ce qu'il rate
Chaque cas a été testé sur un composant dédié, enfermé dans un @boundary :
- Constructeur qui lève une erreur : attrapé, fallback affiché.
ngOnInit: attrapé.effect(): attrapé. Les effets de composant tournent pendant la change detection de la vue, donc dans le périmètre de la boundary.- Expression de template, au premier rendu ou après un changement d'input : attrapé.
- Handler
(click): pas attrapé. L'erreur part versErrorHandler.handleError, la vue reste affichée, le bouton reste cliquable.
Ce dernier point est logique mais à connaître. Une boundary protège le rendu. Un handler
d'événement ne fait pas partie du rendu : quand il lève une erreur, il n'y a rien de cassé à
l'écran à remplacer. Si ton onSave() peut échouer, c'est toujours à toi de gérer l'erreur dans le
handler et d'en faire un état affichable.
Autre détail mesuré : un throw 'plain string' n'arrive pas tel quel. La boundary l'emballe dans
une ErrorBoundaryWrappedError, dont le message commence par
Error boundary caught an error that's not an Error instance. Un filtre d'égalité sur
error.message ne correspondra donc plus : ta chaîne est incluse dans le message d'emballage, et
l'original est dans .cause.
Côté monitoring : ErrorHandler.onViewError
Une erreur attrapée par une boundary n'est pas une erreur silencieuse. Angular la transmet à ton
ErrorHandler, via une nouvelle méthode optionnelle apparue en 22.2 :
import { ErrorDetails, ErrorHandler, Injectable } from '@angular/core';
@Injectable()
export class MonitoringErrorHandler implements ErrorHandler {
handleError(error: unknown): void {
reportToMonitoring(error);
}
onViewError(error: Error, details: ErrorDetails): void {
reportToMonitoring(error, {
kind: 'view-error',
declaredIn: details.declarationType.name, // readable in dev only, minified in prod
});
}
}
Si onViewError est défini, c'est lui qui reçoit les erreurs de rendu attrapées, et handleError
n'est pas appelé pour elles. Sinon, Angular se rabat sur handleError. Ton handler actuel continue
donc de tout voir sans modification.
details apporte ce que handleError n'a jamais eu : declarationType et declarationInstance
(la classe et l'instance d'un composant lié à l'erreur, voir plus bas), et boundary (le composant
qui déclare la boundary et une fonction reset, toujours présent dans onViewError). Deux limites
mesurées, à connaître avant de construire des alertes dessus :
declarationTypen'est pas toujours le coupable. Pour une erreur dans le template du composant, il désigne bien le composant fautif (Price). Pour une erreur dans un bindinghostou à la création, il désigne un ancêtre, jamais le fautif : son parent pourngOnIniteteffect(), et pour un constructeur, selon la structure du template, le parent ou le composant qui déclare la boundary..namene survit pas au build de production. Le build minifie les noms de classe :declarationType.namevaut alors une lettre comme"e". Lisible en dev, inexploitable dans ton monitoring de prod. Pour savoir quelle partie de la page a lâché, préfère un identifiant que tu poses toi-même.
La version programmatique
Pour les vues créées en TypeScript, la 22.2 ajoute une option onError à
ViewContainerRef.createComponent, à ViewContainerRef.createEmbeddedView et à la fonction
createComponent. Même signature que onViewError : (error, details) => void.
La documentation de l'API précise une limite : l'option attrape les erreurs de change detection et
de hooks de cycle de vie, pas celles levées pendant la construction du composant, ni pendant la
création de la vue pour createEmbeddedView. Mesuré : quand le constructeur lève une erreur, createComponent la relance
de façon synchrone, et onError n'est pas appelé. Le bloc
@boundary du template, lui, couvre aussi le constructeur.
Before / after
Avant la 22.2, isoler un composant fragile revenait à se protéger soi-même : un try/catch dans
chaque computed(), un @if défensif, ou un composant wrapper maison. Aucune de ces solutions
n'attrapait une erreur levée par un composant enfant que tu ne contrôles pas.
<!-- Avant : défense au cas par cas, rien si l'enfant lève une erreur quand même -->
@if (product(); as p) {
<app-price [amount]="p.price ?? 0" />
}
<!-- Après : le composant peut planter, la page tient -->
@boundary {
<app-price [amount]="product().price" />
} @error (let retry = $reset) {
<p>Prix indisponible.</p>
<button type="button" (click)="retry()">Réessayer</button>
}
Le ?? 0 de la version "avant" est d'ailleurs pire que le crash : il affiche un prix à zéro.
@boundary ne te dispense pas de valider tes données au bord de l'app, mais il ajoute le filet qui
manquait : un fallback propre au lieu d'une page figée.
Le reste du changelog 22.2
Voici l'essentiel du changelog officiel de la 22.2.0.
Compilateur
- Les membres
privatesont accessibles depuis le template (et depuishost). Jusqu'en 22.1, le compilateur les refusait (TS2341) et il fallait passer enprotected(détail par version dans l'article sur les bindingshost). - Option
strictUnclaimedEventNames, désactivée par défaut, à activer dans lesangularCompilerOptionsdutsconfig. Sur un élément porteur de directives, un(outputTypo)qu'aucune de ces directives n'émet devient une erreur de compilation. Seuls les noms camelCase sont vérifiés : les noms à tirets commemy-eventet les événements DOM natifs sont exemptés. - Les règles CSS imbriquées sont désormais encapsulées comme les autres règles du composant.
Router
- Un guard ou un resolver peut lever (
throw) unRedirectCommandpour déclencher une redirection, au lieu de le retourner. - Le nettoyage automatique des injecteurs de route est stabilisé sous le nom
withAutoCleanupInjectors().withExperimentalAutoCleanupInjectorsest dépréciée. containsTree(container, containee, options)devient public : il indique si uneUrlTreeest contenue dans une autre.- Les router resources entrent en developer preview : avec
withRouterResources(), une route peut déclarer une propriétéresources, une fonction qui renvoie desresource()chargées pendant la navigation.
Signal Forms, tests et core
hidden(path)sans conditionwhenmarque un champ comme caché de façon permanente.TestBed.createDirective()instancie la directive à tester et renvoie unDirectiveFixture.- L'
Injectorse lit depuis une requêteviewChildoucontentChild. animate.enteretanimate.leaveacceptent des fonctions et des signals.
Récap actionnable
- Repère les composants qui rendent des données externes (API, CMS, flux tiers) : ce sont eux
qui plantent en prod. Entoure-les d'un
@boundary, pas la page entière. - Termine toujours par un
@errorsans condition. Sans lui, une erreur non prévue passe à travers avecfell through. - N'attends rien de
@boundarypour tes handlers(click). Gère l'erreur dans le handler. - Ne boucle pas sur
$reset(). Réessaie après un changement d'état, pas sur un timer. - Ajoute
onViewErrorà tonErrorHandlerpour distinguer les erreurs de rendu attrapées. Ne te fie ni àdeclarationTypepour désigner le coupable, ni à.nameen production. - Garde en tête le statut developer preview : l'API de
ErrorDetailspeut encore changer.