📧 Reste informé(e) !

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

S'inscrire gratuitement

~11 min de lecture

NavigationSkipped : le pageview fantôme d'Angular Router qui casse tes analytics

Tu as branché un tracker de pageviews sur le Router. Standard, propre :

router.events
  .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
  .subscribe(e => analytics.pageview(e.urlAfterRedirects));

Ça compile et ça marche. Puis un jour, tu regardes ton tableau de bord analytics et tu vois qu'un tiers de tes interactions "je clique sur le lien de la page où je suis déjà" ne remonte pas. L'utilisateur clique sur "Dashboard" dans la sidebar alors qu'il est sur /dashboard. Rien ne se passe côté tracker. Pire, ton composant de page ne recharge pas ses données, ta liste de notifications reste sur le résultat d'il y a dix minutes.

Le Router a bien vu la navigation. Il l'a même documentée. Mais il l'a rangée dans un event que ton code n'écoute pas : NavigationSkipped.

Valide Angular 17+. NavigationSkipped existe depuis Angular 15.1, avec deux codes stables : IgnoredSameUrlNavigation et IgnoredByUrlHandlingStrategy. La config onSameUrlNavigation est disponible via withRouterConfig() depuis Angular 14.2.


TL;DR

Cas Event émis Ce que ton listener NavigationEnd en voit
Navigation vers une URL différente NavigationEnd Vu, pageview remonté
Navigation vers l'URL courante (par défaut) NavigationSkipped avec IgnoredSameUrlNavigation Rien, pageview perdu
Navigation ignorée par une UrlHandlingStrategy (mécanisme de cohabitation avec une autre app, rare) NavigationSkipped avec IgnoredByUrlHandlingStrategy Rien
Navigation avec redirect côté guard ou resolver Deux navigations : NavigationCancel (code Redirect) puis NavigationEnd Un seul pageview réel, sur le second

Trois réglages, pas deux : écoute NavigationSkipped en plus de NavigationEnd pour ne plus perdre le pageview ; ajoute onSameUrlNavigation: 'reload' pour rouvrir le cycle de navigation ; ajoute runGuardsAndResolvers: 'always' sur les routes dont tu veux vraiment que resolvers et guards se rejouent, 'reload' seul ne le fait pas.


Reproduire le fantôme en 30 secondes

Le composant fautif est banal :

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';

@Component({
  selector: 'app-analytics',
  template: '',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Analytics {
  private router = inject(Router);

  constructor() {
    this.router.events
      .pipe(
        filter((e): e is NavigationEnd => e instanceof NavigationEnd),
        takeUntilDestroyed(),
      )
      .subscribe(e => console.log('[pageview]', e.urlAfterRedirects));
  }
}

Les snippets qui suivent se concentrent sur la logique de routage pour rester courts ; dans un vrai composant ou service, branche-les sur takeUntilDestroyed() comme ici.

Charge la page /dashboard. Console : [pageview] /dashboard. Bien.

Reclique sur ton lien <a routerLink="/dashboard"> dans la sidebar. Console : rien.

Ouvre router.events en debug, tu ne vois passer qu'un seul event :

NavigationSkipped { id: 2, url: '/dashboard', reason: 'Navigation to /dashboard was ignored because it is the same as the current Router URL.', code: 0 }

code: 0, c'est NavigationSkippedCode.IgnoredSameUrlNavigation. Attention à ce que ça implique côté trace : le Router n'a même pas ouvert son cycle d'events habituel. Pas de NavigationStart, pas de RoutesRecognized (la reconnaissance de route terminée), pas de GuardsCheckStart (le début des vérifications de guards) : rien de tout ça ne se déclenche, la détection "URL identique à celle en cours" coupe court avant même la reconnaissance de route. La séquence complète d'une navigation normale, événement par événement, est détaillée dans le cycle d'events du Router ; ici, la seule trace que tu obtiens est ce NavigationSkipped isolé. Si tu débugges en filtrant tes logs sur NavigationStart, tu ne verras absolument rien.

Ton composant Dashboard n'a pas été re-instancié, donc pas de nouveau fetch. Ton pageview n'a pas été appelé, donc pas de comptage. Deux bugs pour le prix d'une ligne de config manquante.


Ce que porte vraiment NavigationSkipped

L'event a deux codes, et la distinction compte :

export enum NavigationSkippedCode {
  IgnoredSameUrlNavigation,      // 0
  IgnoredByUrlHandlingStrategy,  // 1
}

IgnoredSameUrlNavigation : tu navigues vers l'URL courante et onSameUrlNavigation vaut 'ignore' (défaut). C'est l'écrasante majorité des cas et c'est le fantôme que tu cherches.

IgnoredByUrlHandlingStrategy : tu as installé une UrlHandlingStrategy custom (typiquement pour cohabiter avec AngularJS ou une app externe qui possède certaines URL) et cette stratégie a répondu "cette URL n'est pas pour moi". Rare, mais critique quand ça arrive : le Router laisse passer l'URL sans l'activer côté Angular, en supposant que quelqu'un d'autre va la prendre en charge. Concrètement, UrlHandlingStrategy.shouldProcessUrl(url) renvoie false à la fois sur l'URL cible et sur l'URL courante, et le Router émet NavigationSkipped avec ce code au lieu d'ouvrir le cycle. Si tu te retrouves à voir ce code sans avoir écrit toi-même de stratégie, cherche du côté d'un package tiers qui installe la sienne via un provider DI ({ provide: UrlHandlingStrategy, useClass: ... }), ou d'un vieux setup de migration AngularJS jamais retiré. La seule bonne réaction est de comprendre pourquoi cette URL est ignorée, pas de la recompter comme un pageview normal : ce n'est pas la même sémantique et tu vas noyer ton analytics.

Le tracker robuste distingue les deux :

import { NavigationEnd, NavigationSkipped, NavigationSkippedCode, Router } from '@angular/router';

router.events.subscribe(e => {
  if (e instanceof NavigationEnd) {
    analytics.pageview(e.urlAfterRedirects);
    return;
  }
  if (e instanceof NavigationSkipped && e.code === NavigationSkippedCode.IgnoredSameUrlNavigation) {
    analytics.pageview(e.url);
  }
});

C'est la version minimale qui restaure ton compteur. Elle traite un clic sur "aller à la page où je suis" comme un vrai pageview, ce qui correspond à ce que l'utilisateur croit avoir fait.


L'autre moitié du correctif : onSameUrlNavigation: 'reload', et ce qu'il ne fait pas tout seul

Le tracker qui remonte le pageview, c'est bien. Mais si l'utilisateur clique sur "Dashboard" pour rafraîchir ses notifications et que le composant Dashboard ne refetch rien, tu as juste changé un bug silencieux en pageview trompeur.

Premier réglage, côté Router :

import { provideRouter, withRouterConfig } from '@angular/router';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(
      routes,
      withRouterConfig({ onSameUrlNavigation: 'reload' }),
    ),
  ],
};

Avec 'reload', le Router rouvre le cycle d'events même quand l'URL est identique : NavigationStart ... NavigationEnd reviennent, donc ton listener NavigationEnd seul suffit à nouveau pour le pageview. Mais c'est tout ce que 'reload' fait. Vérifié sur Angular 22, repro Router avec compteurs d'appels à l'appui : sur une URL identique, 'reload' seul ne relance ni les guards ni les resolvers. La raison est dans le Router lui-même : ce qui décide de rejouer guards et resolvers, c'est la politique runGuardsAndResolvers, dont la valeur par défaut est paramsChange, "seulement si un paramètre d'URL a changé". Une URL strictement identique ne change aucun paramètre, donc rien ne se rejoue, 'reload' ou pas.

Pour que resolvers et guards se rejouent vraiment, il faut l'ajouter explicitement, sur la route :

{
  path: 'dashboard',
  component: Dashboard,
  resolve: { notifications: notificationsResolver },
  runGuardsAndResolvers: 'always',
}

'always' revient à dire "rejoue à chaque navigation vers cette route, même sans rien de changé". C'est le bon réglage pour un dashboard ou un feed où rafraîchir sur reclic est le comportement voulu ; c'est un mauvais réglage pour une route dont le resolver fait un appel coûteux à chaque changement de query param anodin.

Un piège annexe : onSameUrlNavigation: 'reload' ne re-instancie pas le composant, même combiné à runGuardsAndResolvers: 'always'. Le Router rejoue la navigation, mais les composants restent montés. Si ton composant tire ses données d'un resolver (les fonctions déclarées dans resolve: {...}) ou d'un resource() (l'API Angular 19+ qui expose une requête async comme un signal réactif) branché sur un signal de route qui change réellement à chaque rerun, tout va bien. Si ton composant tire ses données dans constructor() sans les rebrancher sur un signal réactif, ni 'reload' ni runGuardsAndResolvers ne le toucheront.

Autre chose que 'reload' ne fait pas : le scroll top. Mesuré : ni 'reload' seul, ni combiné à withInMemoryScrolling({ scrollPositionRestoration: 'top' }), n'émet d'event Scroll sur une navigation vers l'URL courante. Si ton UI "aller à l'accueil" doit aussi remonter en haut de page, c'est un scrollTo à poser toi-même, en plus.

Autrement dit : 'reload' répare le flux d'events du Router. Rejouer les resolvers est un réglage à part, par route. Le scroll est encore un troisième réglage, manuel.


Piège : le pageview compté deux fois sur redirect

Un redirect produit deux navigations pour un seul clic utilisateur, donc deux events terminaux, un par navigation, jamais deux sur la même. C'est documenté, c'est logique, et c'est un piège seulement si tu ajoutes un tracker sur NavigationStart en plus de NavigationEnd.

Le scénario : ton AuthGuard renvoie un UrlTree (l'objet Router qui représente une URL cible parsée, produit par router.parseUrl(...) ou construit à la main) vers /login quand l'utilisateur n'est pas connecté. L'utilisateur, non connecté, tape /dashboard. Le Router émet :

NavigationStart      { id: 1, url: '/dashboard' }
NavigationCancel     { id: 1, url: '/dashboard', code: NavigationCancellationCode.Redirect }
NavigationStart      { id: 2, url: '/login' }
NavigationEnd        { id: 2, url: '/login', urlAfterRedirects: '/login' }

Un seul NavigationEnd, donc un seul pageview /login, pour deux navigations et deux events terminaux (le NavigationCancel de la première, le NavigationEnd de la seconde). Tant que tu écoutes uniquement NavigationEnd, ton compte est bon.

Le piège apparaît si tu ajoutes un tracker "je log toutes les URL que l'utilisateur a tenté d'atteindre" en écoutant NavigationStart : tu comptes alors /dashboard + /login. Ce n'est pas forcément faux, mais ce n'est pas un pageview au sens Google Analytics. Sépare les deux notions dans ton code, avec deux noms d'event distincts, ou tu vas passer un après-midi à défendre un chiffre d'engagement qui n'a jamais existé.

La version robuste, avec le fantôme et le redirect traités séparément :

import {
  NavigationCancel,
  NavigationCancellationCode,
  NavigationEnd,
  NavigationSkipped,
  NavigationSkippedCode,
  Router,
} from '@angular/router';

router.events.subscribe(e => {
  if (e instanceof NavigationEnd) {
    analytics.pageview(e.urlAfterRedirects);
    return;
  }

  if (e instanceof NavigationSkipped && e.code === NavigationSkippedCode.IgnoredSameUrlNavigation) {
    analytics.pageview(e.url);
    return;
  }

  if (e instanceof NavigationCancel && e.code === NavigationCancellationCode.Redirect) {
    analytics.blockedNavigation(e.url, e.reason);
  }
});

blockedNavigation n'est pas un pageview, c'est un signal de friction : l'utilisateur a essayé d'aller quelque part, on l'a envoyé ailleurs. C'est utile pour repérer les pages orphelines derrière un guard, ou les liens marketing qui pointent vers une route protégée. Ce n'est simplement pas la même métrique.


Ce que ça change pour ton composant, pas juste ton tracker

Le pageview fantôme est le symptôme le plus visible. Le vrai problème sous-jacent est plus large : par défaut, Angular suppose que cliquer sur un lien vers la page courante est du bruit. C'est un choix historique raisonnable pour les liens de navigation "aller à", mais il ne tient plus dès que ton app a des UI "rafraîchir" implicites.

Trois symptômes qui reviennent en review :

  1. Le lien logo qui ne rafraîchit pas la page d'accueil. L'utilisateur est sur /, il clique sur le logo pour revenir "au début" : remonter en haut, voir les derniers posts du feed. Rien ne se passe visuellement, ni scroll top ni refresh. Il reclique, croit à un bug, ferme l'onglet. Comme vu plus haut, ni l'un ni l'autre n'arrive gratuitement avec 'reload' : les deux se posent à la main.
  2. La recherche qui ne se relance pas sur la même requête. Ton input de recherche pousse la requête dans l'URL (?q=foo). L'utilisateur soumet foo, obtient un résultat, corrige la donnée en base ailleurs, revient et re-soumet foo. Même URL, NavigationSkipped, le resolver ne rejoue pas, l'utilisateur voit toujours l'ancien résultat, et il te tague sur Slack pour te dire que "vos résultats ne sont pas à jour". Le correctif complet ici, c'est onSameUrlNavigation: 'reload' et runGuardsAndResolvers: 'always' sur la route de recherche.
  3. Le tracker analytics qui sous-compte les pageviews d'une session. Les utilisateurs qui restent sur une seule route et cliquent plusieurs fois dessus (typique d'une page d'accueil avec plusieurs CTA pointant vers elle-même) voient chacun de ces clics ignoré par un tracker NavigationEnd-only. Ton nombre de pageviews par session est mécaniquement sous-estimé sur les sites à une page.

Les trois se règlent avec la même base — NavigationSkipped écouté et onSameUrlNavigation: 'reload' — complétée au cas par cas : runGuardsAndResolvers: 'always' sur les routes qui doivent vraiment rejouer leurs resolvers, un scroll posé à la main pour le symptôme 1. Si tu préfères ne pas toucher au comportement global du Router, l'alternative est de pousser un signal ("refresh count") dans un service et de le lire dans le composant concerné, au prix d'un peu de plomberie en plus.

Une nuance : ce n'est pas toujours ce que tu veux. Sur une page où revenir avec la même URL doit au contraire préserver l'état plutôt que le rafraîchir (retour depuis une fiche détail vers une liste filtrée, par exemple), c'est le réglage inverse qui s'applique : onSameUrlNavigation reste à 'ignore', et c'est runGuardsAndResolvers: 'paramsOrQueryParamsChange' qui évite de refetcher pour rien. C'est exactement le cas couvert par l'article sur le back button et RouteReuseStrategy : les deux articles ne se contredisent pas, ils répondent à deux intentions produit différentes. Choisis en fonction de ce que ton UI doit faire quand l'URL ne change pas, pas en fonction du dernier article lu.


Récap actionnable

  • Ton tracker pageview écoute NavigationEnd. Ajoute NavigationSkipped avec le code IgnoredSameUrlNavigation, sinon tu perds tous les clics "aller à la page où je suis".
  • onSameUrlNavigation: 'reload' rouvre le cycle d'events du Router, donc ton NavigationEnd seul suffit à nouveau, mais ne rejoue pas guards et resolvers à lui seul.
  • Pour que guards et resolvers se rejouent vraiment sur une route donnée, ajoute runGuardsAndResolvers: 'always' sur cette route. Sans ça, 'reload' répare le flux d'events, pas le refetch.
  • onSameUrlNavigation: 'reload' ne re-instancie jamais les composants et ne restaure pas le scroll tout seul. Prévois-les à part si ton UI en a besoin.
  • Les redirects produisent deux navigations pour un seul clic, donc deux events terminaux (un NavigationCancel puis un NavigationEnd), jamais deux sur la même navigation. Ne double-compte pas en écoutant NavigationStart.
  • NavigationSkipped avec le code IgnoredByUrlHandlingStrategy signale qu'une UrlHandlingStrategy (souvent héritée, installée via un provider DI classique) rejette l'URL : si tu n'en as jamais écrit, va chercher qui l'a fait avant de la compter comme pageview.
  • Si ta page doit au contraire préserver l'état plutôt que rafraîchir sur une URL identique, fais l'inverse : garde onSameUrlNavigation: 'ignore' et cible le refetch avec runGuardsAndResolvers: 'paramsOrQueryParamsChange'.

NavigationSkipped n'est pas un bug caché, c'est un event du Router bien documenté que la plupart des trackers n'écoutent simplement pas. Écoute-le, et arbitre onSameUrlNavigation en fonction de ce que ton URL identique doit vraiment déclencher : rafraîchir, ou ne rien faire.

📧 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.