~9 min de lecture
Zoneless a coupé le filet : les erreurs que ton ErrorHandler ne voit plus
TL;DR
En zoneless, zone.js ne referme plus son filet autour de tes callbacks async : une erreur dans un setTimeout ou une promesse rejetée ne passe plus par ErrorHandler, donc n'atteint jamais ton monitoring. Le correctif existe depuis Angular 20 et tient en une ligne : provideBrowserGlobalErrorListeners(). Il est présent dans les apps générées par ng new, mais rien ne l'ajoute à une app migrée. Le piège est d'autant plus vicieux que les erreurs synchrones des templates, elles, remontent toujours : ton test rapide "je throw dans un click" passe, et tu conclus à tort que tout est couvert.
Tu as migré ton app en zoneless. La PR est propre, les tests passent, le runtime est plus léger, tu es content. Trois semaines plus tard, tu ouvres ton dashboard Sentry : calme plat. Deux erreurs par jour là où tu en avais trente.
Première hypothèse : la migration a corrigé des bugs. Flatteur, mais non. Tes utilisateurs rencontrent toujours les mêmes erreurs. Simplement, ton ErrorHandler ne les voit plus passer, et ton monitoring est branché dessus.
provideBrowserGlobalErrorListeners()existe depuis Angular 20. Le zoneless, expérimental pendant plusieurs majeures, est stable depuis la 20.2 et le défaut des nouvelles apps depuis la v21 (chronologie complète dans le guide de migration zoneless, qui couvre tout le parcours mais pas le piège de cet article). Tout ce que décrit cet article a été vérifié sur le code source d'Angular 22.
Le point de départ : un ErrorHandler branché sur ton monitoring
Le pattern classique, présent dans à peu près toutes les apps qui prennent la prod au sérieux : un ErrorHandler custom qui pousse vers Sentry, Datadog ou ton endpoint maison.
import { ErrorHandler, Injectable } from '@angular/core';
@Injectable()
export class MonitoringErrorHandler implements ErrorHandler {
handleError(error: unknown): void {
reportToMonitoring(error); // Sentry, Datadog, endpoint custom...
console.error(error);
}
}
Branché dans la config, aux côtés du zoneless :
import { ApplicationConfig, ErrorHandler, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes';
import { MonitoringErrorHandler } from './monitoring-error-handler';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideRouter(appRoutes),
{ provide: ErrorHandler, useClass: MonitoringErrorHandler },
],
};
Ce code compilait avant la migration, il compile après. Aucun warning, aucune erreur. C'est bien le problème : le trou est invisible au compilateur.
La démo : trois erreurs, une seule remonte
Prends ce composant. Trois boutons, trois façons de planter.
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-error-demo',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button (click)="syncCrash()">Erreur sync</button>
<button (click)="timerCrash()">Erreur setTimeout</button>
<button (click)="promiseCrash()">Erreur promesse</button>
`,
})
export class ErrorDemo {
syncCrash(): void {
throw new Error('sync error');
}
timerCrash(): void {
setTimeout(() => {
throw new Error('timer error');
}, 0);
}
async promiseCrash(): Promise<void> {
const res = await fetch('/api/nope');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}
}
Dans une app zone.js classique, les trois erreurs finissaient dans ton ErrorHandler. Zone.js patchait setTimeout, les promesses et les événements DOM ; tout ce qui explosait dans la zone Angular était intercepté et transmis via NgZone.onError, auquel Angular abonne ton ErrorHandler au bootstrap.
Dans une app zoneless sans correctif, voilà ce qui se passe réellement :
- Erreur sync : remonte dans
MonitoringErrorHandler. Ton monitoring la voit. - Erreur setTimeout : n'y passe jamais. Elle finit en
Uncaught Errordans la console, portée par l'événementerrordewindow. Ton monitoring est aveugle. - Erreur promesse : n'y passe jamais non plus. Elle finit en
unhandledrejection, même destin.
Le cas sync est le plus retors. Il remonte parce qu'Angular enveloppe chaque listener de template dans un try/catch qui route l'erreur vers ErrorHandler (la fonction executeListenerWithErrorHandling, dans le moteur de rendu (render3) d'@angular/core, si tu veux vérifier dans les sources). Ce mécanisme n'a jamais dépendu de zone.js, et il n'est pas isolé : tout ce qu'Angular exécute lui-même passe par le même garde-fou, y compris une expression de template qui throw pendant la change detection ou un effect() qui explose. Résultat : tu testes ta migration en lançant une erreur depuis un (click), elle arrive dans Sentry, tu valides. Et tu viens de vérifier un chemin qui n'était pas cassé : le synchrone orchestré par Angular remonte déjà sans aucun provider.
Or en prod, les erreurs intéressantes sont massivement asynchrones : un fetch qui répond 500, un timer qui lit un objet devenu null, un import dynamique qui échoue après un déploiement. Précisément celles qui passent sous le radar.
Pourquoi : le filet était un effet de bord de zone.js
Personne n'a "cassé" ton ErrorHandler. Ce qui a disparu, c'est un service que zone.js te rendait sans que tu le saches.
Zone.js monkey-patche (remplace à chaud) les APIs async du navigateur pour savoir quand relancer la change detection. Mais ce patch avait un effet de bord précieux : chaque callback exécuté dans la zone était encadré, et toute erreur non attrapée était transmise au handler d'Angular. Ce n'est pas grâce à sa propre magie que ton ErrorHandler voyait les erreurs async : il les voyait parce que zone.js les lui apportait.
En zoneless, Angular remplace NgZone par une implémentation vide (NoopNgZone) et se synchronise via les notifications des signals. Plus de patch, plus d'encadrement, plus de filet. Les erreurs async redeviennent ce qu'elles sont dans n'importe quelle app JavaScript : des événements globaux du navigateur, error et unhandledrejection sur window.
En dev, tu ne remarques rien : la console affiche toujours les erreurs, puisque c'est le comportement par défaut du navigateur. C'est en prod que la différence se paie, là où la console de l'utilisateur ne t'envoie pas de screenshot.
La solution : une ligne, fournie par Angular
Depuis Angular 20, le framework fournit exactement le provider qu'il te faut :
import {
ApplicationConfig,
ErrorHandler,
provideBrowserGlobalErrorListeners,
provideZonelessChangeDetection,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes';
import { MonitoringErrorHandler } from './monitoring-error-handler';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
{ provide: ErrorHandler, useClass: MonitoringErrorHandler },
],
};
Ce que fait provideBrowserGlobalErrorListeners(), très concrètement (c'est court, ça se lit dans les sources d'@angular/core) :
- il pose deux listeners sur
window: un surerror, un surunhandledrejection; - il transmet
event.errorouevent.reasonà tonErrorHandler(et depuis la v22, si l'événementerrorn'embarque aucun objet d'erreur, il fabrique uneErrorde repli avec l'événement d'origine placé dans sa propriétécause) ; - il appelle
event.preventDefault(), donc le logUncaughtpar défaut du navigateur est supprimé : c'est ton handler qui décide quoi en faire (l'ErrorHandlerpar défaut loggeconsole.error('ERROR', ...)) ; - il retire proprement les deux listeners à la destruction de l'app ;
- côté serveur (SSR), il ne fait rien du tout : pas de
window, pas de listeners. Les erreurs de rendu serveur restent l'affaire de ton process Node et de ton middleware d'erreur.
Avec ce provider, les trois boutons de la démo remontent dans MonitoringErrorHandler. Le filet est de retour, et cette fois tu sais qu'il existe et qui le tient.
Le vrai piège : la migration ne l'ajoute pas
Depuis la v20, toute app générée par ng new inclut provideBrowserGlobalErrorListeners() dans son app.config.ts de départ (ou son app-module.ts si tu génères en NgModule), zoneless ou pas. Mais si ton app existait avant, aucune migration ng update ne l'y a glissé. Tu es exactement dans la zone à risque : app née avant la v20, migrée consciencieusement version après version, passée en zoneless, et jamais dotée de ce provider.
Vérifie la tienne maintenant, ça prend dix secondes :
grep -rn "provideBrowserGlobalErrorListeners" src/
Zéro résultat sur une app zoneless : tu as trouvé ton trou de monitoring.
Ce que ce provider n'attrape toujours pas
Une ligne ne t'exonère pas de réfléchir. Trois angles morts restent ouverts.
Angle mort 1 : les erreurs capturées par resource() et httpResource()
Quand le loader d'un resource() throw, l'erreur ne part pas vers ErrorHandler : elle est capturée dans l'état du resource et exposée par son signal error(). C'est voulu, c'est même toute la valeur de l'API. Mais si ton template ne lit jamais error(), l'échec est silencieux pour l'utilisateur ET pour ton monitoring. Une erreur que l'API capture à dessein doit être affichée ou rapportée explicitement, à toi de choisir, mais choisis.
Angle mort 2 : les subscriptions RxJS sans callback d'erreur
Un subscribe() sans handler d'erreur laisse RxJS relancer l'erreur de façon asynchrone, en dehors de la stack du subscriber. Elle finit donc sur le canal global, où les nouveaux listeners la voient passer. Bonne nouvelle pour la couverture, mauvaise pour le contexte : tu reçois une stack trace RxJS anonyme au lieu d'une erreur attachée à l'appel métier qui l'a produite. Le listener global est un filet, pas une excuse pour subscriber les yeux fermés.
Angle mort 3 : ton ErrorHandler lui-même
Il devient le point de passage de toutes les erreurs de l'app, y compris les rafales (un setInterval qui plante toutes les secondes, une boucle de retry qui échoue en chaîne). Trois règles d'hygiène : ne jamais throw depuis handleError, éviter HttpClient pour expédier les rapports (une erreur d'interceptor qui repasse par le handler, et tu as construit une boucle), et préférer fetch ou navigator.sendBeacon avec deux garde-fous, à savoir une déduplication (ne pas réenvoyer la même signature message + stack dans la même minute) et un plafond d'envois (par exemple 10 rapports maximum par session).
Before / after
Une seule ligne sépare les deux configs (le provideBrowserGlobalErrorListeners() ajouté plus haut). Voilà ce qu'elle change, chemin d'erreur par chemin d'erreur :
| Chemin d'erreur | Zoneless sans le provider | Zoneless avec le provider |
|---|---|---|
Throw synchrone dans un handler (click) |
ErrorHandler |
ErrorHandler |
| Expression de template qui throw pendant la change detection | ErrorHandler |
ErrorHandler |
effect() qui throw |
ErrorHandler |
ErrorHandler |
Callback de setTimeout / setInterval |
Console uniquement | ErrorHandler |
| Promesse rejetée non gérée | Console uniquement | ErrorHandler |
| Erreur globale de script | Console uniquement | ErrorHandler |
Loader de resource() qui throw |
Signal error(), nulle part ailleurs |
Signal error(), nulle part ailleurs |
Autrement dit, la colonne de droite, c'est ce que zone.js te donnait, mais explicite et assumé.
Récap actionnable
- App zoneless ?
grep -rn "provideBrowserGlobalErrorListeners" src/tout de suite. Absent = monitoring partiel, et tu ne le sais que maintenant. - App née avant la v20 : pars du principe que le provider manque,
ng newl'ajoute maisng updatene l'a jamais fait. - Teste ta couverture avec les trois boutons de cet article : sync,
setTimeout, promesse rejetée. Les trois doivent arriver dans tonErrorHandler. Un seul qui manque, et c'est ton monitoring qui ment. - Ne valide jamais un monitoring d'erreurs avec une erreur synchrone de template : ce chemin marche déjà sans rien faire, il ne prouve rien sur l'async.
resource()ethttpResource()capturent leurs erreurs danserror(): affiche-les ou rapporte-les explicitement, le listener global ne les verra jamais.- Garde ton
handleErrordéfensif : pas de throw, pas deHttpClient, déduplication et plafond d'envois avant d'expédier quoi que ce soit.
La morale tient en une phrase : zone.js ne faisait pas que déclencher ta change detection, il portait aussi ton filet à erreurs. Le zoneless t'a rendu la performance, à toi de reprendre le filet. Une ligne suffit, encore faut-il savoir qu'elle existe.