~8 min de lecture
Router Angular : les 17 events de navigation et pourquoi ton loader tourne dans le vide
Tu as branché un spinner global sur la navigation. Tu l'affiches sur NavigationStart, tu le caches sur NavigationEnd. Ça marche en démo. Puis un jour un guard refuse l'accès, la navigation est annulée, et ton spinner tourne dans le vide. Pour toujours. L'utilisateur est bloqué sur un écran de chargement qui ne finira jamais, parce que NavigationEnd n'a jamais été émis.
Le problème, c'est que tu raisonnes sur deux events alors que le Router en émet jusqu'à dix-sept par navigation. Et surtout, une navigation ne se termine pas toujours par NavigationEnd. Elle peut être annulée, sautée, ou partir en erreur, chacune avec son propre event terminal. Si tu ne les connais pas, tu écris des bugs de loader, tu rates des cancels silencieux, et tu débugges à l'aveugle.
Voici la séquence complète, ce que chaque event apporte, les trois façons d'interrompre une navigation, et les cas d'erreur. Avec à chaque fois le code qui va bien.
Valide Angular 17+.
NavigationSkippedexiste depuis Angular 14,NavigationCancellationCodeetwithNavigationErrorHandlerdepuis Angular 16, le retour d'un redirect depuis le handler d'erreur depuis Angular 19.
TL;DR
| Phase | Events émis | Ce qu'ils apportent |
|---|---|---|
| Démarrage | NavigationStart |
id, url, navigationTrigger |
| Lazy loading | RouteConfigLoadStart / RouteConfigLoadEnd |
le chunk en cours de chargement |
| Reconnaissance | RoutesRecognized |
l'URL parsée en RouterStateSnapshot |
| Guards | GuardsCheckStart / ChildActivationStart / ActivationStart / GuardsCheckEnd |
shouldActivate: boolean |
| Resolvers | ResolveStart / ResolveEnd |
les données résolues sont prêtes |
| Activation | ActivationEnd / ChildActivationEnd |
les composants vont s'instancier |
| Fin | un seul parmi NavigationEnd, NavigationCancel, NavigationError, NavigationSkipped |
l'issue réelle |
Règle d'or : une navigation se termine toujours par exactement un event terminal, et ce n'est pas forcément NavigationEnd.
La séquence complète d'une navigation qui réussit
Quand tout se passe bien sur une route lazy avec un guard et un resolver, le Router émet, dans cet ordre :
NavigationStart
RouteConfigLoadStart // début du chargement du chunk lazy
RouteConfigLoadEnd // chunk chargé
RoutesRecognized // l'URL est parsée, le RouterStateSnapshot existe
GuardsCheckStart
ChildActivationStart
ActivationStart
GuardsCheckEnd // shouldActivate: true
ResolveStart
ResolveEnd // les data des resolvers sont disponibles
ActivationEnd
ChildActivationEnd
NavigationEnd // event terminal: succès
Chaque event porte au minimum un id (le numéro de la navigation, incrémenté à chaque fois) et l'url cible. Trois d'entre eux portent une charge utile qui sert vraiment au quotidien :
NavigationStartexposenavigationTrigger('imperative','popstate'ou'hashchange'). C'est comme ça que tu distingues un clic dans l'app d'un bouton retour navigateur.RoutesRecognizedet tous les events suivants exposentstate: RouterStateSnapshot. C'est le premier moment où l'arbre de routes est connu : tu peux lire les params, lesdata, les segments, avant même que le composant existe.GuardsCheckEndexposeshouldActivate: boolean. Si c'estfalse, tu sais que la navigation va être annulée juste après.
Le détail qui compte : RouteConfigLoadStart/End n'apparaissent que pour les routes lazy, et seulement au premier passage. Une fois le chunk en cache, ces deux events disparaissent de la séquence. Si tu vois un RouteConfigLoadStart sans RouteConfigLoadEnd, c'est ton chunk qui n'a pas pu se charger. On y revient.
Le loader qui tourne dans le vide : le bug du terminal manquant
Voici le pattern qu'on voit dans 9 codebases sur 10 :
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { NavigationEnd, NavigationStart, Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `@if (loading()) { <app-spinner /> }`,
})
export class App {
private readonly router = inject(Router);
protected readonly loading = signal(false);
constructor() {
this.router.events.pipe(takeUntilDestroyed()).subscribe((event) => {
if (event instanceof NavigationStart) this.loading.set(true);
if (event instanceof NavigationEnd) this.loading.set(false);
});
}
}
Le bug : NavigationStart met le spinner à true, mais seul NavigationEnd le remet à false. Or une navigation peut finir par NavigationCancel, NavigationError ou NavigationSkipped. Dans ces trois cas, le spinner reste affiché à vie.
Le correctif tient en un type union qui couvre les quatre issues :
import {
NavigationCancel,
NavigationEnd,
NavigationError,
NavigationSkipped,
NavigationStart,
} from '@angular/router';
this.router.events.pipe(takeUntilDestroyed()).subscribe((event) => {
if (event instanceof NavigationStart) {
this.loading.set(true);
return;
}
if (
event instanceof NavigationEnd ||
event instanceof NavigationCancel ||
event instanceof NavigationError ||
event instanceof NavigationSkipped
) {
this.loading.set(false);
}
});
Retiens la liste des quatre events terminaux comme une seule entité. Dès que tu démarres une action sur NavigationStart, tu dois la fermer sur ces quatre-là, jamais sur NavigationEnd seul.
Ce qui interrompt la navigation : NavigationCancel
Quand le Router renonce volontairement à une navigation, il n'émet ni NavigationEnd ni NavigationError. Il émet NavigationCancel, avec une propriété code (un NavigationCancellationCode) qui te dit exactement pourquoi :
import { NavigationCancel, NavigationCancellationCode } from '@angular/router';
this.router.events.subscribe((event) => {
if (event instanceof NavigationCancel) {
switch (event.code) {
case NavigationCancellationCode.GuardRejected:
// un guard a retourné false
break;
case NavigationCancellationCode.Redirect:
// un guard a retourné un UrlTree -> une nouvelle nav part juste après
break;
case NavigationCancellationCode.SupersededByNewNavigation:
// une autre navigation a démarré avant la fin de celle-ci
break;
case NavigationCancellationCode.NoDataFromResolver:
// un resolver a complété sans jamais émettre
break;
}
}
});
Les quatre codes correspondent à quatre causes bien distinctes :
GuardRejected: tonCanActivateFnouCanMatchFna renvoyéfalse. La nav s'arrête net, l'utilisateur reste sur l'URL actuelle. C'est le cas du spinner bloqué vu plus haut.Redirect: ton guard a renvoyé unUrlTreeau lieu d'un booléen. La nav courante est annulée, et une nouvelle navigation vers l'URL duUrlTreedémarre immédiatement. Tu vas donc voir deuxNavigationStartpour ce que l'utilisateur perçoit comme un seul clic.SupersededByNewNavigation: l'utilisateur a cliqué deux fois, ou unrouter.navigate()est parti pendant qu'une nav lente était en cours. La première est abandonnée. C'est normal, ne le traite pas comme une erreur.NoDataFromResolver: le piège silencieux, qui mérite sa propre section.
Ce sont les quatre codes que tu rencontreras en pratique. Angular en expose un cinquième depuis les versions récentes, Aborted, pour les navigations stoppées programmatiquement via Navigation.abort() : beaucoup plus rare, mais ne sois pas surpris de le croiser.
Le cancel silencieux : un resolver qui retourne EMPTY
Voici le bug le plus vicieux du Router, parce qu'il ne fait rien. Pas d'erreur, pas de log, pas de page. L'utilisateur clique, et il ne se passe rien.
La cause : un resolver qui complète sans jamais émettre de valeur. Le grand classique, c'est le catchError qui renvoie EMPTY pour "gérer proprement" une erreur HTTP :
import { ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
import { catchError, EMPTY } from 'rxjs';
// Bug : si l'API échoue, le resolver complète sans émettre.
// Le Router annule la navigation avec le code NoDataFromResolver.
export const productResolver: ResolveFn<Product> = (route) => {
const id = route.paramMap.get('id')!;
return inject(ProductApi).getById(id).pipe(catchError(() => EMPTY));
};
Quand un observable de resolver complète sans rien émettre, le Router considère qu'il n'a pas les données nécessaires et annule la navigation (NoDataFromResolver). L'utilisateur reste sur sa page actuelle, sans le moindre indice de ce qui s'est passé.
Le correctif : ne jamais avaler l'erreur en silence. Soit tu rediriges vers une page d'erreur, soit tu laisses l'erreur remonter pour qu'elle devienne un NavigationError, soit tu émets une valeur de repli explicite :
import { ResolveFn, Router, RedirectCommand } from '@angular/router';
import { inject } from '@angular/core';
import { catchError, of } from 'rxjs';
export const productResolver: ResolveFn<Product | RedirectCommand> = (route) => {
const router = inject(Router);
const id = route.paramMap.get('id')!;
return inject(ProductApi).getById(id).pipe(
catchError(() => of(new RedirectCommand(router.parseUrl('/not-found')))),
);
};
Le RedirectCommand (Angular 18+) émis depuis un resolver redirige proprement au lieu d'annuler dans le vide. Au moins, l'utilisateur atterrit quelque part.
Les cas d'erreur : NavigationError
NavigationError est émis quand quelque chose throw pendant la navigation. Contrairement au cancel qui est volontaire, l'erreur est subie. L'event porte le url, l'id, et surtout l'error qui a été levée. Trois causes reviennent en boucle.
1. Un resolver ou un guard qui throw. Si ton ResolveFn lève une exception synchrone, ou si son observable émet une erreur (au lieu de compléter), tu obtiens un NavigationError et non un cancel. La distinction est nette : observable qui complète vide = cancel, observable qui error = error.
2. Le lazy chunk qui ne charge pas. C'est le cas le plus fréquent en prod. Tu déploies une nouvelle version, le hash des fichiers change, et un utilisateur qui avait l'ancien index.html ouvert depuis ce matin clique sur un lien lazy. Le chunk référencé n'existe plus sur ton CDN. Tu vois alors un RouteConfigLoadStart jamais suivi de RouteConfigLoadEnd, puis un NavigationError avec un ChunkLoadError.
3. Une erreur dans le canMatch ou la résolution de route. Plus rare, mais une exception levée pendant la reconnaissance de routes finit aussi en NavigationError.
Le pattern de production pour gérer ça proprement, c'est withNavigationErrorHandler (Angular 16+), un feature de provideRouter qui centralise le traitement :
import { provideRouter, withNavigationErrorHandler, Router, RedirectCommand } from '@angular/router';
import { inject } from '@angular/core';
import { routes } from './app.routes';
export const appConfig = {
providers: [
provideRouter(
routes,
withNavigationErrorHandler((event) => {
const router = inject(Router);
// Chunk périmé après un déploiement : on force un reload dur sur l'URL cible.
if (event.error?.name === 'ChunkLoadError') {
location.assign(event.url);
return;
}
// Tout le reste : page d'erreur, sans casser l'historique.
return new RedirectCommand(router.parseUrl('/error'));
}),
),
],
};
Le retour d'un RedirectCommand depuis le handler est dispo depuis Angular 19. Sur Angular 16 à 18, le handler ne fait que de l'effet de bord (log, redirect manuel via router.navigate), il ne peut pas retourner de redirect.
NavigationSkipped : quand le Router ne fait rien, exprès
Dernier event terminal, souvent ignoré : NavigationSkipped. Le Router l'émet quand il décide qu'il n'y a tout simplement rien à faire. Le cas le plus courant : tu navigues vers l'URL sur laquelle tu es déjà. Par défaut, le Router saute la navigation et émet NavigationSkipped avec le code IgnoredSameUrlNavigation.
C'est pour ça que ton NavigationStart puis NavigationEnd que tu attendais ne viennent jamais quand l'utilisateur reclique sur l'onglet actif. Si tu as besoin de re-déclencher (un bouton "rafraîchir" qui pointe vers la même URL), il faut configurer la stratégie :
provideRouter(routes, withRouterConfig({ onSameUrlNavigation: 'reload' }));
Avec 'reload', le Router rejoue la séquence complète (guards et resolvers compris) au lieu de sauter. Pratique, mais à manier avec précaution si tes resolvers font des appels coûteux.
Débugger sans deviner : withDebugTracing
Quand tu ne comprends pas pourquoi une navigation se comporte mal, arrête de poser des console.log. Active le tracing intégré, qui log chaque event du Router dans la console avec son timing :
import { provideRouter, withDebugTracing } from '@angular/router';
provideRouter(routes, withDebugTracing()); // à retirer avant la prod
Tu vois alors défiler toute la séquence. Un RouteConfigLoadStart orphelin t'indique un problème de chunk. Un GuardsCheckEnd avec shouldActivate: false te pointe le guard fautif. Un NavigationCancel avec NoDataFromResolver te dit que c'est un resolver qui avale ses erreurs. Le diagnostic devient une lecture, plus une enquête.
Récap actionnable
- Une navigation émet jusqu'à 17 events, mais se termine par exactement un parmi
NavigationEnd,NavigationCancel,NavigationError,NavigationSkipped. - Loader, analytics, états de transition : tout ce que tu ouvres sur
NavigationStart, ferme-le sur ces quatre events terminaux, jamais surNavigationEndseul. NavigationCancel.codete dit pourquoi : guard qui refuse, redirect parUrlTree, nav superseded, ou resolver vide.- Resolver qui retourne
EMPTY= cancel silencieux (NoDataFromResolver). Renvoie unRedirectCommandou laisse l'erreur remonter, ne l'avale jamais. NavigationErrorcouvre les throws : resolver/guard en erreur, et surtout leChunkLoadErroraprès déploiement. Centralise avecwithNavigationErrorHandler.NavigationSkippedexplique pourquoi reclic sur l'URL active ne fait rien.onSameUrlNavigation: 'reload'si tu veux forcer.- En cas de doute,
withDebugTracing()te donne la séquence réelle en deux minutes.
Le Router Angular ne te cache rien : il t'annonce chaque étape de sa décision. Le jour où tu lis ces events au lieu de les ignorer, tes bugs de navigation arrêtent d'être des mystères.