~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/core 22.2.0 en zoneless, avec des tests Vitest et une app bootstrapée. Le type ErrorDetails qui 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/core proposait déjà la 22.2, alors que @angular/cli et @angular/build étaient encore en 22.1.8 sur le tag latest.


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 vers ErrorHandler.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 :

  • declarationType n'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 binding host ou à la création, il désigne un ancêtre, jamais le fautif : son parent pour ngOnInit et effect(), et pour un constructeur, selon la structure du template, le parent ou le composant qui déclare la boundary.
  • .name ne survit pas au build de production. Le build minifie les noms de classe : declarationType.name vaut 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 private sont accessibles depuis le template (et depuis host). Jusqu'en 22.1, le compilateur les refusait (TS2341) et il fallait passer en protected (détail par version dans l'article sur les bindings host).
  • Option strictUnclaimedEventNames, désactivée par défaut, à activer dans les angularCompilerOptions du tsconfig. 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 comme my-event et 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) un RedirectCommand pour déclencher une redirection, au lieu de le retourner.
  • Le nettoyage automatique des injecteurs de route est stabilisé sous le nom withAutoCleanupInjectors(). withExperimentalAutoCleanupInjectors est dépréciée.
  • containsTree(container, containee, options) devient public : il indique si une UrlTree est 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 des resource() chargées pendant la navigation.

Signal Forms, tests et core

  • hidden(path) sans condition when marque un champ comme caché de façon permanente.
  • TestBed.createDirective() instancie la directive à tester et renvoie un DirectiveFixture.
  • L'Injector se lit depuis une requête viewChild ou contentChild.
  • animate.enter et animate.leave acceptent des fonctions et des signals.

Récap actionnable

  1. 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.
  2. Termine toujours par un @error sans condition. Sans lui, une erreur non prévue passe à travers avec fell through.
  3. N'attends rien de @boundary pour tes handlers (click). Gère l'erreur dans le handler.
  4. Ne boucle pas sur $reset(). Réessaie après un changement d'état, pas sur un timer.
  5. Ajoute onViewError à ton ErrorHandler pour distinguer les erreurs de rendu attrapées. Ne te fie ni à declarationType pour désigner le coupable, ni à .name en production.
  6. Garde en tête le statut developer preview : l'API de ErrorDetails peut encore changer.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.