Skip to content

Routing moderne — provideRouter, guards fonctionnels, input binding, signals

TL;DR — Le routing Angular moderne s'articule autour de provideRouter(routes, ...features) côté bootstrap, et de fonctions plutôt que de classes pour les guards et resolvers (CanActivateFn, CanMatchFn, ResolveFn). Les routes utilisent loadComponent / loadChildren pour le lazy-loading par composant (Angular 14+) sans NgModule. L'input binding (Angular 16+, opté via withComponentInputBinding()) permet de recevoir paramMap, queryParamMap et data directement comme des @Input() du composant, ce qui élimine 80 % du boilerplate ActivatedRoute. Depuis Angular 18, le router expose ses événements comme signal (router.eventstoSignal() ou direct), et la View Transitions API est activable par feature (withViewTransitions()). On compose enfin withInMemoryScrolling, withRouterConfig, et une preloading strategy (withPreloading) pour une expérience de navigation soyeuse.


🧠 Mental model — ASCII + analogie

Le router est une machine de matching qui transforme une URL en arbre d'ActivatedRoute, et un orchestrateur de navigation qui applique guards, resolvers, lazy-loading, puis active les composants dans les <router-outlet>.

   URL: /users/42/orders?sort=desc


   ┌──────────────── Router Matching ────────────────────┐
   │                                                     │
   │   routes = [                                        │
   │     { path: '', component: HomeCmp },               │
   │     { path: 'users/:id',                            │
   │       loadComponent: () => import('./users/...'),   │
   │       canMatch: [isLoggedIn],                       │
   │       resolve: { user: userResolver },              │
   │       children: [                                   │
   │         { path: 'orders',                           │
   │           loadComponent: () => import('./orders'),  │
   │           data: { breadcrumb: 'Orders' } }          │
   │       ]                                             │
   │     }                                               │
   │   ]                                                 │
   │                                                     │
   └─────────────────────┬───────────────────────────────┘

   ┌──────────── Activated tree ──────────────┐
   │                                          │
   │   ActivatedRoute(/)                      │
   │     └─ ActivatedRoute(users/:id)         │
   │          params:    { id: '42' }         │
   │          data:      { user: User }       │
   │          └─ ActivatedRoute(orders)       │
   │               queryParams: { sort: ... } │
   │                                          │
   └──────────────────────────────────────────┘


                   <router-outlet>


                   View Transition
                   (avec withViewTransitions)

Analogie : le router est un standardiste de central téléphonique. Il reçoit un numéro (URL), parcourt son annuaire (routes), filtre par guards (canMatch = "ce numéro est-il joignable ?", canActivate = "puis-je passer la communication ?"), pré-charge des infos (resolvers), puis connecte la ligne (<router-outlet>). Le tout en streaming, étape par étape, avec possibilité de rerouter (UrlTree).

Le cycle de vie d'une navigation (ce qu'un staff engineer a en tête)

Une navigation n'est pas atomique : c'est une pipeline asynchrone annulable. Comprendre son ordre exact est ce qui sépare le dev qui "fait marcher le routing" du dev qui debug un guard qui re-déclenche en boucle ou un resolver qui bloque l'UI.

navigateByUrl / routerLink / popstate


  NavigationStart ──────────────────────────────────┐
        │                                            │ à TOUT moment :
        ▼                                            │  NavigationCancel (guard → false/UrlTree)
  RouteConfigLoadStart  (lazy: import() en cours)    │  NavigationError  (resolver throw, import KO)
  RouteConfigLoadEnd    (chunk téléchargé)           │  → l'URL est restaurée (sauf urlUpdateStrategy)
        │                                            │
        ▼                                            │
  RoutesRecognized   (matching terminé → arbre)      │
        │                                            │
        ▼                                            │
  GuardsCheckStart                                   │
    canDeactivate (route quittée)                    │
    canActivateChild / canActivate (route entrée)    │
    canMatch s'est déjà joué PENDANT le matching ────┘ (avant RoutesRecognized)
  GuardsCheckEnd


  ResolveStart   (tous les ResolveFn en parallèle, on attend le dernier)
  ResolveEnd


  ActivationStart → composants instanciés dans les <router-outlet>
  NavigationEnd  ✅  (+ scroll restoration, view transition committée)

Trois conséquences directes :

  1. canMatch court avant RoutesRecognized : il participe au matching. S'il rejette, le router continue de chercher une autre route. canActivate court après — la route est déjà choisie, le chunk déjà téléchargé.
  2. Les resolvers sont parallèles mais bloquants : le router attend que tous les ResolveFn d'une activation émettent leur première valeur (un Observable qui n'émet jamais bloque la navigation à vie — pitfall classique). take(1) est implicite côté router.
  3. Tout est annulable : un second navigate() pendant une navigation en cours émet NavigationCancel (raison SupersededByNewNavigation) sur la première. C'est exactement le modèle d'un AbortController — utile mentalement quand on streamera des tokens LLM par route plus bas.
PhaseÉvénementHookAnnulable par
MatchingRoutesRecognizedCanMatchFn, redirectTocanMatch → false/UrlTree
SortieGuardsCheckStartCanDeactivateFnfalse (formulaire sale)
EntréeGuardsCheckStartCanActivateFn, CanActivateChildFnfalse/UrlTree
DonnéesResolveStart/EndResolveFnthrowNavigationError
ActivationActivationStartconstructeur du composant
FinNavigationEndeffect() sur router.events

🛠️ Code minimal (ts + html)

Bootstrap

ts
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import {
  provideRouter,
  withComponentInputBinding,
  withInMemoryScrolling,
  withRouterConfig,
  withPreloading,
  withViewTransitions,
  PreloadAllModules,
} from '@angular/router';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(
      routes,
      withComponentInputBinding(),
      withViewTransitions(),
      withInMemoryScrolling({
        scrollPositionRestoration: 'enabled',
        anchorScrolling: 'enabled',
      }),
      withRouterConfig({
        paramsInheritanceStrategy: 'always',
        onSameUrlNavigation: 'reload',
      }),
      withPreloading(PreloadAllModules),
    ),
  ],
});

Routes

ts
// app.routes.ts
import { Routes } from '@angular/router';
import { isLoggedIn } from './core/auth.guard';
import { userResolver } from './users/user.resolver';

export const routes: Routes = [
  {
    path: '',
    pathMatch: 'full',
    loadComponent: () => import('./home/home.component').then((m) => m.HomeComponent),
  },
  {
    path: 'login',
    loadComponent: () => import('./auth/login.component').then((m) => m.LoginComponent),
  },
  {
    path: 'users',
    canMatch: [isLoggedIn],
    loadChildren: () => import('./users/users.routes').then((m) => m.usersRoutes),
  },
  {
    path: '**',
    loadComponent: () => import('./shared/not-found.component').then((m) => m.NotFoundComponent),
  },
];
ts
// users/users.routes.ts
import { Routes } from '@angular/router';
import { userResolver } from './user.resolver';

export const usersRoutes: Routes = [
  {
    path: '',
    loadComponent: () => import('./user-list.component').then((m) => m.UserListComponent),
  },
  {
    path: ':id',
    resolve: { user: userResolver },
    loadComponent: () => import('./user-detail.component').then((m) => m.UserDetailComponent),
    children: [
      {
        path: 'orders',
        data: { breadcrumb: 'Orders' },
        loadComponent: () => import('./user-orders.component').then((m) => m.UserOrdersComponent),
      },
    ],
  },
];

Guards fonctionnels

ts
// core/auth.guard.ts
import { inject } from '@angular/core';
import { CanMatchFn, Router, UrlTree } from '@angular/router';
import { AuthService } from './auth.service';

export const isLoggedIn: CanMatchFn = (): boolean | UrlTree => {
  const auth = inject(AuthService);
  const router = inject(Router);
  return auth.isAuthenticated() ? true : router.createUrlTree(['/login']);
};

Resolver fonctionnel

ts
// users/user.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn, Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { UserService, User } from './user.service';

export const userResolver: ResolveFn<User | null> = (route) => {
  const id = route.paramMap.get('id')!;
  const userService = inject(UserService);
  const router = inject(Router);
  return userService.getById(id).pipe(
    catchError(() => {
      router.navigate(['/users']);
      return of(null);
    }),
  );
};

Composant avec input binding

ts
// users/user-detail.component.ts
import { Component, Input } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { User } from './user.service';

@Component({
  selector: 'app-user-detail',
  standalone: true,
  imports: [RouterOutlet],
  template: `
    <h1>{{ user?.name }}</h1>
    <p>ID : {{ id }}</p>
    <p>Tri actuel : {{ sort ?? 'par défaut' }}</p>
    <router-outlet />
  `,
})
export class UserDetailComponent {
  // Param de route (ex: /users/:id)
  @Input() id!: string;
  // Query param (ex: ?sort=desc)
  @Input() sort?: string;
  // Données de resolver
  @Input() user?: User;
}

Avec withComponentInputBinding(), le router injecte automatiquement dans les inputs portant le même nom que route.paramMap, route.queryParamMap, et route.data. Pas besoin d'injecter ActivatedRoute.

Template avec <router-outlet> et liens

html
<nav>
  <a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
    Accueil
  </a>
  <a routerLink="/users" routerLinkActive="active">Utilisateurs</a>
  <a [routerLink]="['/users', 42, 'orders']" [queryParams]="{ sort: 'desc' }">
    Commandes #42
  </a>
</nav>

<main>
  <router-outlet />
</main>

🎯 Patterns courants

Lazy-load par composant (sans NgModule)

loadComponent: () => import('...').then(m => m.MyComponent) charge un unique composant à la demande. C'est l'approche moderne, plus granulaire que loadChildren qui charge un module ou un groupe de routes. On combine les deux : loadChildren pour une feature area (ex : users/*), et loadComponent pour chaque écran à l'intérieur.

Avantage majeur : avec les composants standalone, on ne paie plus le coût de modules wrapper qui ne servent qu'à déclarer un composant. Le bundle est plus petit et plus prévisible.

CanActivate vs CanMatch

C'est la question piège du routing moderne. CanActivateFn s'exécute après le matching de la route ; CanMatchFn s'exécute pendant le matching. Conséquence pratique :

  • Si CanActivate retourne false, le router a déjà résolu la route et son code éventuellement lazy-loaded est déjà téléchargé. La route ne s'active pas, mais le bundle a été consommé.
  • Si CanMatch retourne false, le router continue de chercher dans la liste des routes. Il peut donc tomber sur une route catch-all ** ou une autre route correspondant à la même URL.

Règle d'usage : CanMatch pour l'autorisation à charger (auth, feature flags) ; CanActivate pour les vérifications contextuelles fines (l'utilisateur a-t-il le droit à ce moment de voir cette ressource précise). En 2026, la pratique converge vers CanMatch pour à peu près tout — c'est plus efficace et plus sécurisé.

Composer et factoriser les guards (le pattern HOF)

Les guards fonctionnels étant des fonctions, on les compose comme n'importe quelle fonction. Trois techniques que tout le code de prod moderne utilise :

ts
// Higher-order guard : paramétré par un rôle (déjà vu plus haut)
export const requireRole =
  (role: Role): CanMatchFn =>
  () => {
    const auth = inject(AuthService);
    return auth.hasRole(role) ? true : inject(Router).createUrlTree(['/forbidden']);
  };

// Combinateur "ET" : tous les guards passent, court-circuite au premier refus.
// Chaque sous-guard peut renvoyer boolean | UrlTree | Observable | Promise → on normalise via `from`.
// IMPORTANT : `allOf(...)` est appelé AU MOMENT du matching, donc `inject()` y est légal
// (le router exécute les CanMatchFn dans un contexte d'injection). On reste donc dans la fonction.
export const allOf =
  (...guards: CanMatchFn[]): CanMatchFn =>
  (route, segments) =>
    guards.reduce<Observable<boolean | UrlTree>>(
      (acc, g) =>
        acc.pipe(
          switchMap((prev) =>
            prev === true
              ? from(Promise.resolve(g(route, segments))).pipe(
                  // un GuardResult peut être boolean | UrlTree | Observable | Promise :
                  switchMap((r) => (isObservable(r) ? r : of(r))),
                )
              : of(prev), // un refus (false/UrlTree) court-circuite la suite
          ),
        ),
      of(true),
    );

// Usage : canMatch: [allOf(requireAuth, requireRole('manager'), featureFlag('beta'))]

Subtilité : g(route, segments) doit être appelé dans le contexte d'injection du router. Comme switchMap est synchrone à la souscription et que le router souscrit immédiatement dans son propre contexte, les inject() internes des sous-guards résolvent correctement. Si vous différez l'appel (timer, setTimeout), vous perdez ce contexte — d'où la règle « pas d'async avant le premier inject() ».

En pratique, on garde souvent les guards en tableau (canMatch: [a, b, c]) : le router les exécute déjà séquentiellement et court-circuite au premier refus. Le combinateur allOf ne se justifie que pour réutiliser une combinaison nommée à plusieurs endroits. Ne pas sur-ingénierer.

Routes typées — fermer le trou de data

Route['data'] est typé Data = { [k: string]: any } — un trou de typage. Pour des breadcrumbs/permissions portés par data, on brand son propre type et on caste à la frontière :

ts
export interface AppRouteData {
  breadcrumb?: string;
  roles?: Role[];
  preload?: boolean;
}
// Helper qui force le typage sans rien changer au runtime
export const route = <T extends Route>(r: T & { data?: AppRouteData }) => r;

export const routes: Routes = [
  route({ path: 'admin', data: { roles: ['admin'], preload: true }, loadComponent: /* … */ }),
];
// Lecture typée dans un guard : (route.data as AppRouteData).roles

Angular 20 améliore l'inférence mais ne fournit pas encore de Routes génériques natifs — ce helper reste la parade pragmatique.

Resolve fonctionnel et streams

Un ResolveFn retourne T | Observable<T> | Promise<T>. Le router attend la résolution avant d'activer la route (par défaut). Pour ne pas bloquer la navigation, on désactive runGuardsAndResolvers ou on déplace la donnée dans un guard non bloquant ; mais en règle générale, on accepte le délai et on affiche un loading bar (router.events filtré sur NavigationStart/NavigationEnd).

Les resolvers brillent quand la donnée est indispensable au premier rendu (par exemple, le titre de page). Si la donnée peut arriver après le rendu, c'est un signal qu'elle ne devrait pas être dans un resolver — préférer un appel dans le composant avec un loading state.

Input binding et data binding

withComponentInputBinding() rend trois sources de données directement disponibles comme @Input() :

SourceCible @Input()
route.paramMapinputs nommés comme le paramètre (:idid)
route.queryParamMapinputs nommés comme le query param (?sort=sort)
route.data (incluant les resolvers)inputs nommés comme la clé ({ user: ... }user)

En cas de collision (ex : un paramètre id et une donnée id), la priorité est : data > params > queryParams. Cette feature seule remplace 80 % des ActivatedRoute historiques.

Trois pièges que seul un dev expérimenté anticipe :

  1. Le resolver vit sur la route parente, l'input est sur le composant enfant. L'input binding propage data/params au composant du même niveau d'ActivatedRoute. Si resolve: { user } est déclaré sur /users/:id mais que le composant qui veut user est un enfant /users/:id/orders, l'input user de l'enfant restera undefined — sauf si paramsInheritanceStrategy: 'always' (qui propage aussi data aux enfants). C'est exactement pourquoi on l'active globalement.
  2. L'input n'est pas réinitialisé quand le param disparaît au sens classique. Quand on passe de ?sort=desc à aucun query param, le router pousse undefined dans l'input sort — donc un input<string>() (non-required) repasse bien à undefined. Mais un @Input() sort!: string (non-signal, sans ?) garde l'ancienne valeur si le binding ne refire pas : préférer les input signals ou marquer optionnel.
  3. input.required + donnée resolver = course au premier rendu. input.required<Candidate>() throw si lu avant que le router ait poussé la valeur. Avec un resolver bloquant, la valeur est garantie présente à l'instanciation (le composant n'existe qu'après ResolveEnd) — c'est sûr. Mais ne jamais combiner input.required avec une donnée non resolver (un simple query param optionnel) : utiliser input<T>().

Couplée aux input signals (Angular 17+), on obtient une expérience encore plus fluide :

ts
@Component({ /* ... */ })
export class UserDetailComponent {
  // Signal d'entrée — automatiquement mis à jour à chaque changement de paramètre
  id = input.required<string>();
  sort = input<string>();
  // computed dérivé du paramètre
  numericId = computed(() => Number(this.id()));
}

Router events comme signal

Depuis Angular 18, on peut consommer le flux d'événements du router comme signal :

ts
import { toSignal } from '@angular/core/rxjs-interop';
import { Router, NavigationEnd } from '@angular/router';
import { filter, map } from 'rxjs/operators';

readonly currentUrl = toSignal(
  this.router.events.pipe(
    filter((e): e is NavigationEnd => e instanceof NavigationEnd),
    map((e) => e.urlAfterRedirects),
  ),
  { initialValue: this.router.url },
);

Cas d'usage : un breadcrumb, un titre de page, un tracking analytics, le tout dans un effect() propre :

ts
constructor() {
  effect(() => {
    const url = this.currentUrl();
    this.analytics.pageView(url);
  });
}

Preloading strategies

Le preloading charge les bundles lazy après le bootstrap mais avant qu'on les demande, ce qui rend les navigations ultérieures instantanées. Trois options :

  • NoPreloading (défaut) : aucun préchargement.
  • PreloadAllModules : tout précharger dès que possible.
  • Strategy custom : précharger sélectivement (ex : selon data: { preload: true } sur la route).
ts
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';

export class SelectivePreloadStrategy implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<any>): Observable<any> {
    return route.data?.['preload'] ? load() : of(null);
  }
}

Activation : withPreloading(SelectivePreloadStrategy). En production avec un bundle modeste, PreloadAllModules est souvent suffisant — la pénalité initiale est minime et le gain perçu énorme. Sur des apps massives, on bascule sur une stratégie custom.

Scroll restoration et anchor scrolling

ts
withInMemoryScrolling({
  scrollPositionRestoration: 'enabled', // restaure la position au back/forward
  anchorScrolling: 'enabled',            // gère les liens #anchor
})

C'est une feature à activer systématiquement — l'expérience par défaut (le scroll reste en haut au back) est mauvaise.

View Transitions API

withViewTransitions() câble le router sur l'API browser document.startViewTransition(), qui anime la transition entre deux états DOM. Sans une ligne de CSS, on obtient déjà un fade par défaut. Avec quelques sélecteurs ::view-transition-old / ::view-transition-new, on personnalise.

css
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 200ms;
}

C'est un progressive enhancement : les navigateurs sans support continuent à fonctionner sans animation. Sur Chrome/Edge récents, c'est l'amélioration UX la plus rentable du moment.

withRouterConfig

withRouterConfig({ ... }) permet de régler des comportements globaux du router :

  • paramsInheritanceStrategy: 'always' : les params des routes parentes sont visibles dans les routes enfants. C'est presque toujours ce qu'on veut.
  • onSameUrlNavigation: 'reload' : naviguer vers la même URL réexécute guards et resolvers (utile pour un bouton refresh).
  • urlUpdateStrategy: 'eager' : met à jour l'URL avant l'activation, utile pour des bookmarks instantanés mais attention aux erreurs de guard.
  • canceledNavigationResolution: 'replace' : remplace l'URL plutôt que de la pusher quand une navigation est annulée.

Génération de liens dynamiques

routerLink accepte une string ou un tableau. Pour des liens construits dynamiquement, on passe par le Router directement :

ts
this.router.navigate(['/users', userId, 'orders'], { queryParams: { sort: 'desc' } });

Pour rester sur la route courante et ne modifier que les query params :

ts
this.router.navigate([], {
  relativeTo: this.route,
  queryParams: { sort: 'asc' },
  queryParamsHandling: 'merge',
});

🔄 Versions — Angular 16 → 20

VersionÉvolutions majeures du router
Angular 14Standalone components, loadComponent.
Angular 15Guards et resolvers fonctionnels (CanActivateFn, ResolveFn, etc.).
Angular 16withComponentInputBinding() : params/data en @Input().
Angular 17provideRouter devient l'API canonique ; RouterModule.forRoot déprécié pour le standalone. View Transitions API (withViewTransitions). Input signals (input()) compatibles avec input binding.
Angular 18router.events consommable proprement en signal (toSignal). withDebugTracing pour tracer en dev.
Angular 19runGuardsAndResolvers: 'paramsOrQueryParamsChange' optimisé. Améliorations performance du matching.
Angular 20Stabilisation, meilleur interop avec defer (router-level deferring).

Le router est l'un des sous-systèmes Angular les plus stables. Les évolutions 16-20 ajoutent du confort sans casser ce qui existe.


⚠️ Pitfalls — 6-10

  1. Routes catch-all qui mangent tout : un path: '**' placé avant d'autres routes les rend inatteignables. Toujours placer le wildcard en dernier.
  2. CanActivate au lieu de CanMatch pour l'auth : le bundle lazy est téléchargé avant que le guard refuse. Fuite de code et de surface d'attaque. Utiliser CanMatch.
  3. Resolver lent qui bloque la navigation : l'utilisateur clique, rien ne se passe pendant 800 ms, puis la nouvelle page apparaît. Soit afficher un loading bar (route progress), soit déplacer la donnée dans le composant avec un skeleton.
  4. Oublier pathMatch: 'full' sur la route '' : avec path: '' sans pathMatch, toute URL matche (parce que '' est un préfixe de tout). Conséquence : redirections en boucle ou matching incorrect.
  5. paramsInheritanceStrategy non 'always' : un composant enfant ne voit pas l'id du parent et doit faire une remontée manuelle. Activer 'always' globalement.
  6. withComponentInputBinding non activé : on écrit du boilerplate route.paramMap.subscribe(...) partout. Activer la feature et utiliser @Input().
  7. Navigation en boucle avec un guard qui retourne createUrlTree([currentUrl]) : le router rejoue, le guard rejette à nouveau. Toujours vérifier vers où on redirige.
  8. relativeTo oublié sur router.navigate([...]) à partir d'une route enfant : la navigation se fait par rapport à la racine, pas à la route courante. Ajouter relativeTo: this.route.
  9. Fuites de souscription sur router.events dans ngOnInit sans takeUntilDestroyed(). Solution moderne : toSignal() ou takeUntilDestroyed().
  10. Preloading agressif sur mobile / data limité : PreloadAllModules consomme tout le bundle au démarrage. Sur des marchés sensibles à la bande passante, écrire une strategy qui ne précharge que sur Wi-Fi ou via navigator.connection.effectiveType.

🧪 Testing

Le routing se teste à plusieurs niveaux : unitaire pour les guards/resolvers (fonctions pures), intégration pour le matching, end-to-end pour les flows complets.

Tester un guard fonctionnel

ts
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { isLoggedIn } from './auth.guard';
import { AuthService } from './auth.service';

describe('isLoggedIn guard', () => {
  it('returns true when authenticated', () => {
    TestBed.configureTestingModule({
      providers: [
        { provide: AuthService, useValue: { isAuthenticated: () => true } },
        { provide: Router, useValue: { createUrlTree: jasmine.createSpy() } },
      ],
    });
    const result = TestBed.runInInjectionContext(() => isLoggedIn({} as any, {} as any));
    expect(result).toBe(true);
  });

  it('redirects to /login when unauthenticated', () => {
    const router = { createUrlTree: jasmine.createSpy().and.returnValue('URL_TREE') };
    TestBed.configureTestingModule({
      providers: [
        { provide: AuthService, useValue: { isAuthenticated: () => false } },
        { provide: Router, useValue: router },
      ],
    });
    const result = TestBed.runInInjectionContext(() => isLoggedIn({} as any, {} as any));
    expect(router.createUrlTree).toHaveBeenCalledWith(['/login']);
    expect(result).toBe('URL_TREE');
  });
});

L'astuce centrale : TestBed.runInInjectionContext(() => guardFn(...)) permet d'exécuter une fonction qui utilise inject() à l'intérieur d'un contexte d'injection.

Tester un resolver

ts
import { TestBed } from '@angular/core/testing';
import { of, firstValueFrom } from 'rxjs';
import { convertToParamMap, ActivatedRouteSnapshot } from '@angular/router';
import { userResolver } from './user.resolver';
import { UserService } from './user.service';

describe('userResolver', () => {
  it('returns the user when API succeeds', async () => {
    const userService = { getById: () => of({ id: '42', name: 'Alice' }) };
    TestBed.configureTestingModule({
      providers: [{ provide: UserService, useValue: userService }],
    });
    // convertToParamMap fabrique un vrai ParamMap (get/getAll/has) au lieu d'un Map bricolé.
    const route = { paramMap: convertToParamMap({ id: '42' }) } as ActivatedRouteSnapshot;

    const result = TestBed.runInInjectionContext(() => userResolver(route, {} as any));
    const user = await firstValueFrom(result as any);
    expect(user?.id).toBe('42');
  });
});

Tester l'intégration router

ts
import { Location } from '@angular/common';
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { provideLocationMocks } from '@angular/common/testing';

describe('App routing', () => {
  let router: Router;
  let location: Location;

  beforeEach(async () => {
    TestBed.configureTestingModule({
      providers: [
        provideLocationMocks(),
        provideRouter([
          { path: '', component: HomeStub },
          { path: 'users/:id', component: UserDetailStub },
        ]),
      ],
    });
    router = TestBed.inject(Router);
    location = TestBed.inject(Location);
  });

  it('navigates to /users/42', async () => {
    await router.navigateByUrl('/users/42');
    expect(location.path()).toBe('/users/42');
  });
});

🎬 Cas d'usage concrets

Scénario 1 — SaaS RH multi-app, navigation entre modules

Une plateforme SaaS RH regroupe quatre apps fonctionnelles (recrutement, paie, congés, analytics) sous une seule shell Angular. Chaque module est lazy-loadé via loadChildren, chacun avec ses propres routes enfants, ses guards de droits (« seuls les RH peuvent voir la paie »), et son resolver de contexte (chargement de l'organisation courante).

L'équipe utilise provideRouter avec withComponentInputBinding() : les paramMap et queryParams sont injectés directement comme input() dans les composants, ce qui élimine 90 % des this.route.paramMap.subscribe(...) de l'ancienne base. Les guards sont fonctionnels : canActivate: [requireRole('hr_manager')]. Le preloading utilise PreloadAllModules pour les modules « probablement utilisés » et pas pour analytics (lourd, optionnel).

Le tech lead documente la règle : « toute donnée de paramètre URL est injectée via input() ; toute action déclenchée par la route est dans un effect() qui watch le signal du param ». Plus aucun ngOnInit dans les composants de page.

Un site e-commerce a une route /c/:slug qui affiche une catégorie avec filtres en queryParams (?priceMax=500&brand=acme&page=2). Les utilisateurs partagent leurs URLs Slack/email, donc les filtres doivent être deep-linkables et restaurables au reload.

L'équipe synchronise le FormGroup des filtres avec les queryParams : valueChangesrouter.navigate([], { queryParams, queryParamsHandling: 'merge' }), et au chargement, lecture initiale via route.snapshot.queryParamMap. Un resolver charge les facettes globales avant d'afficher la page ({ resolve: { facets: facetsResolver } }). Un guard canMatch route différemment selon le device (route mobile-optimisée vs desktop) — canMatch est plus efficace que canActivate car il évite même le bootstrap du composant.

Détail : withViewTransitions() ajoute une animation cross-page pendant la navigation, ce qui rend le changement de catégorie visuellement smooth sur Chrome. Fallback gracieux sur Safari (aucune anim, navigation instantanée).

Un portail juridique a une route /dossiers/:id/onglet/:tab (tab étant info | parties | docs | timeline | factures). Chaque onglet a ses propres droits : seuls les associés voient les factures. Les liens vers un onglet précis sont partagés en interne.

L'équipe modélise via routes enfants : /dossiers/:id charge un parent qui contient le router-outlet, et chaque tab est un enfant avec son propre canActivate. Le resolver dossierResolver charge le dossier au niveau parent ; les enfants utilisent ActivatedRoute.parent pour le récupérer (ou mieux, withComponentInputBinding() propage automatiquement). Si l'utilisateur n'a pas accès à /factures, le guard redirige vers /info, et un toast informe.

Le piège évité : ne pas mettre toute la sécurité en canActivate du parent. Si on cache l'onglet « factures » côté nav mais qu'on n'a pas de guard sur la route enfant, un utilisateur curieux peut entrer l'URL directement. Chaque route enfant a son propre guard, défense en profondeur.


🛠️ Exemple end-to-end

Use case : routing SaaS RH. Route /candidates/:id/:tab avec resolver, guards fonctionnels, withComponentInputBinding, lazy load des onglets, et test de navigation.

ts
// auth.service.ts
import { Injectable, signal } from '@angular/core';

export type Role = 'recruiter' | 'manager';

@Injectable({ providedIn: 'root' })
export class AuthService {
  readonly user = signal<{ id: string; role: Role } | null>({ id: 'u1', role: 'recruiter' });
  hasRole(role: Role): boolean { return this.user()?.role === role; }
}
ts
// guards.ts
import { inject } from '@angular/core';
import { Router, CanActivateFn, CanMatchFn } from '@angular/router';
import { AuthService, Role } from './auth.service';

export const requireAuth: CanMatchFn = () => {
  const auth = inject(AuthService);
  const router = inject(Router);
  return auth.user() ? true : router.createUrlTree(['/login']);
};

export const requireRole =
  (role: Role): CanActivateFn =>
  () => {
    const auth = inject(AuthService);
    const router = inject(Router);
    return auth.hasRole(role) ? true : router.createUrlTree(['/forbidden']);
  };
ts
// candidate.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { HttpClient } from '@angular/common/http';

export interface Candidate { id: string; firstName: string; lastName: string; }

export const candidateResolver: ResolveFn<Candidate> = (route) => {
  const id = route.paramMap.get('id')!;
  return inject(HttpClient).get<Candidate>(`/api/candidates/${id}`);
};
ts
// app.routes.ts
import { Routes } from '@angular/router';
import { requireAuth, requireRole } from './auth/guards';
import { candidateResolver } from './candidates/candidate.resolver';

export const routes: Routes = [
  {
    path: 'candidates/:id',
    canMatch: [requireAuth],
    resolve: { candidate: candidateResolver },
    loadComponent: () =>
      import('./candidates/candidate-shell.component').then((m) => m.CandidateShellComponent),
    children: [
      { path: '', redirectTo: 'info', pathMatch: 'full' },
      {
        path: 'info',
        loadComponent: () =>
          import('./candidates/tabs/info.tab').then((m) => m.InfoTab),
      },
      {
        path: 'notes',
        canActivate: [requireRole('manager')],
        loadComponent: () =>
          import('./candidates/tabs/notes.tab').then((m) => m.NotesTab),
      },
    ],
  },
];
ts
// candidate-shell.component.ts
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { Candidate } from './candidate.resolver';

@Component({
  selector: 'app-candidate-shell',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [RouterLink, RouterLinkActive, RouterOutlet],
  template: `
    <h1>{{ candidate().firstName }} {{ candidate().lastName }}</h1>
    <nav>
      <a routerLink="info" routerLinkActive="active">Infos</a>
      <a routerLink="notes" routerLinkActive="active">Notes</a>
    </nav>
    <router-outlet />
  `,
})
export class CandidateShellComponent {
  // Avec withComponentInputBinding(), Angular injecte la donnée resolved comme input
  readonly candidate = input.required<Candidate>();
}
ts
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import {
  provideRouter,
  withComponentInputBinding,
  withInMemoryScrolling,
  withPreloading,
  PreloadAllModules,
  withViewTransitions,
} from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';

bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(),
    provideRouter(
      routes,
      withComponentInputBinding(),
      withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
      withPreloading(PreloadAllModules),
      withViewTransitions(),
    ),
  ],
});
ts
// routing.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router, withComponentInputBinding } from '@angular/router';
import { Location } from '@angular/common';
import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';

it('navigates to candidate info tab by default', async () => {
  TestBed.configureTestingModule({
    providers: [
      provideHttpClient(),
      provideHttpClientTesting(),
      provideRouter(routes, withComponentInputBinding()),
    ],
  });
  const router = TestBed.inject(Router);
  const location = TestBed.inject(Location);
  await router.navigateByUrl('/candidates/42');
  expect(location.path()).toBe('/candidates/42/info');
});

Le routing est entièrement fonctionnel : guards en CanMatchFn/CanActivateFn, resolver typé, withComponentInputBinding pour propager les données comme input(), lazy loading par onglet, et tests de navigation simples.


🚪 CanDeactivate — protéger un formulaire sale

Le seul guard sortant. Il s'exécute quand on quitte une route et permet de bloquer la navigation (« vous avez des modifications non enregistrées »). En version moderne, c'est un CanDeactivateFn<T> générique sur le type du composant — pas une interface à implémenter.

ts
// core/can-deactivate.guard.ts
import { CanDeactivateFn } from '@angular/router';

export interface HasUnsavedChanges {
  hasUnsavedChanges(): boolean;
}

export const confirmLeave: CanDeactivateFn<HasUnsavedChanges> = (component) => {
  if (!component.hasUnsavedChanges()) return true;
  // En prod, remplacer confirm() par un dialog applicatif (CDK Dialog) qui retourne un Observable<boolean>.
  return confirm('Modifications non enregistrées. Quitter quand même ?');
};
ts
// route
{
  path: 'edit/:id',
  loadComponent: () => import('./editor.component').then((m) => m.EditorComponent),
  canDeactivate: [confirmLeave],
}
ts
// editor.component.ts — le composant expose son état "sale" via un signal
export class EditorComponent implements HasUnsavedChanges {
  readonly form = inject(FormBuilder).group({ title: [''] });
  hasUnsavedChanges() { return this.form.dirty; }
}

Piège staff : CanDeactivate ne protège pas du beforeunload natif (fermeture d'onglet, F5). Pour ça, il faut un HostListener('window:beforeunload') séparé. Le guard router et le beforeunload browser sont deux surfaces distinctes ; une vraie protection couvre les deux.

ts
@HostListener('window:beforeunload', ['$event'])
onBeforeUnload(e: BeforeUnloadEvent) {
  if (this.hasUnsavedChanges()) e.preventDefault(); // déclenche le prompt natif
}

📡 Observabilité & production — router events, perf, sécurité

Loading bar global (le pattern le plus rentable)

Un état de navigation exposé en signal, consommable partout :

ts
// core/navigation.state.ts
import { Injectable, inject, signal } from '@angular/core';
import { Router, NavigationStart, NavigationEnd, NavigationCancel, NavigationError } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

@Injectable({ providedIn: 'root' })
export class NavigationState {
  private readonly router = inject(Router);
  readonly loading = signal(false);

  constructor() {
    this.router.events.pipe(takeUntilDestroyed()).subscribe((e) => {
      if (e instanceof NavigationStart) this.loading.set(true);
      else if (e instanceof NavigationEnd || e instanceof NavigationCancel || e instanceof NavigationError) {
        this.loading.set(false);
      }
    });
  }
}
ts
// app.component.ts — barre de progression contrôlée par le signal
template: `
  @if (nav.loading()) { <div class="progress-bar"></div> }
  <router-outlet />
`

Tracer les erreurs de navigation (observabilité)

NavigationError est silencieux par défaut côté UX : l'URL est restaurée et l'utilisateur ne voit rien. En production, on doit logger ces événements vers Sentry/Datadog avec l'URL cible et l'erreur — c'est souvent un resolver qui a 500 ou un chunk lazy qui a 404 (déploiement en cours, hash de bundle périmé).

ts
constructor() {
  this.router.events.pipe(
    filter((e): e is NavigationError => e instanceof NavigationError),
    takeUntilDestroyed(),
  ).subscribe((e) => {
    // Cas classique : "Failed to fetch dynamically imported module" après un déploiement.
    if (/dynamically imported module/.test(String(e.error))) {
      // Le bundle a changé sous les pieds de l'utilisateur → recharger dur.
      window.location.assign(e.url);
      return;
    }
    this.observability.captureNavigationError(e.url, e.error);
  });
}

Le chunk-load-error après déploiement est LE bug de prod du lazy-loading : l'utilisateur a un index.html avec d'anciens hashes, le serveur ne sert plus ces chunks. La parade ci-dessus (hard reload ciblé) est standard ; certaines équipes y ajoutent un withNavigationErrorHandler() (Angular 17.1+) au lieu de s'abonner manuellement :

ts
provideRouter(routes, withNavigationErrorHandler((e) => inject(ErrorReporter).report(e)));

Sécurité — le guard n'est PAS une barrière de sécurité

Mental model crucial : un guard est une UX, pas une autorisation. Tout le bundle est sur le client ; un utilisateur peut désactiver le guard dans le devtools ou appeler l'API directement. La règle :

  • canMatch réduit la surface (ne télécharge même pas le chunk admin) — défense en profondeur, pas sécurité.
  • L'autorité finale est l'API. Chaque endpoint vérifie le rôle/scope côté serveur (NestJS @UseGuards(RolesGuard)). Le guard Angular évite juste d'afficher une page qui échouera de toute façon en 403.

Performance du matching et du lazy-loading

  • Budget de chunk : viser des routes lazy < 150 KB gz. Un loadChildren qui ramène 600 KB annule l'intérêt du lazy.
  • Preloading adaptatif sur réseau contraint (cf. pitfall #10) : navigator.connection?.saveData ou effectiveType !== '4g' → ne pas précharger.
  • runGuardsAndResolvers : par défaut paramsChange. Si un resolver re-fetch à chaque changement de query param alors que seul :id compte, repasser sur 'pathParamsChange' évite des appels réseau inutiles.
  • bindToComponentInputs est gratuit en perf — aucune raison de s'en priver.

🤖 Stack-fit — streamer un agent LLM à travers une route

Cas réel pour ce stack (Angular front, NestJS/Python qui sert l'agent) : une route /agents/:conversationId affiche un chat qui stream les tokens d'un agent (claude-opus-4-8 / claude-sonnet-4-6 côté backend, exposé en SSE). Le routing porte plusieurs responsabilités subtiles ici.

1. Le conversationId vient de la route, l'annulation suit la navigation

ts
// agent-chat.component.ts
import { Component, effect, inject, input, signal } from '@angular/core';

type AgentStep =
  | { kind: 'message'; role: 'assistant'; text: string }
  | { kind: 'tool_call'; name: string; status: 'running' | 'done' | 'error' };

@Component({ selector: 'app-agent-chat', standalone: true, template: `…` })
export class AgentChatComponent {
  // input binding : le router pousse :conversationId comme signal
  readonly conversationId = input.required<string>();

  private controller?: AbortController;

  readonly steps = signal<AgentStep[]>([]);
  readonly streaming = signal(false);

  constructor() {
    // Quand la route change de conversation → on annule le stream en cours et on repart.
    // IDIOMATIQUE : la fonction de nettoyage de l'effect (onCleanup) court AVANT
    // la prochaine exécution ET à la destruction → un seul endroit pour l'abort.
    effect((onCleanup) => {
      const id = this.conversationId();       // dépendance trackée → ré-exécute au changement d':id
      const controller = new AbortController();
      this.controller = controller;
      this.steps.set([]);
      this.streaming.set(true);
      void this.openHistoryStream(id, controller.signal);
      onCleanup(() => controller.abort());    // remplace le destroyRef.onDestroy manuel
    });
  }

  // Reprend l'historique persisté + se branche sur le live-stream (GET SSE).
  private async openHistoryStream(id: string, signal: AbortSignal) {
    try {
      const res = await fetch(`/api/agents/${id}/stream`, {
        headers: { authorization: `Bearer ${this.auth.token()}` },
        signal,
      });
      await this.consume(res.body!.getReader());
    } catch (e) {
      if ((e as Error).name !== 'AbortError') throw e;
    }
  }

  // Envoi d'un nouveau message — POST + body, réutilise le même AbortController de route.
  async send(prompt: string) {
    const signal = this.controller!.signal;   // lié à la route courante : navigation = abort
    this.streaming.set(true);
    const res = await fetch(`/api/agents/${this.conversationId()}/messages`, {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        authorization: `Bearer ${this.auth.token()}`,
      },
      body: JSON.stringify({ prompt }),
      signal,                                  // l'abort coupe le fetch ET (via le backend) l'appel LLM
    });
    await this.consume(res.body!.getReader());
  }

  stop() {
    this.controller?.abort();                  // bouton Stop : annule client + serveur
    this.streaming.set(false);
  }

  private readonly auth = inject(AuthService);

  // ... consume ci-dessous
}

Pourquoi onCleanup plutôt que destroyRef.onDestroy() + abort manuel dans l'effect ? La fonction de nettoyage d'un effect court automatiquement (a) avant chaque ré-exécution et (b) à la destruction du contexte d'injection. Un seul controller.abort() couvre donc changement de :id ET quitter la route — au lieu de dupliquer la logique d'annulation à deux endroits (la source de bugs « j'ai oublié d'annuler dans un des deux chemins »). Le destroyRef reste utile pour des ressources non liées à une dépendance signal.

2. Lire un flux SSE avec ReadableStream + coalescing rAF (zoneless-friendly)

Sous zoneless (provideZonelessChangeDetection()), chaque signal.set() planifie une détection de changement. Sur un stream qui émet 50 tokens/seconde, on coalesce les mises à jour par requestAnimationFrame pour ne pas re-rendre 50 fois/sec.

ts
private async consume(reader: ReadableStreamDefaultReader<Uint8Array>) {
  const decoder = new TextDecoder();
  let buffer = '';
  let pending = '';            // texte accumulé non encore flushé
  let frame: number | null = null;

  const flush = () => {
    frame = null;
    if (!pending) return;
    const chunk = pending; pending = '';
    this.steps.update((s) => appendAssistantText(s, chunk)); // append-only
  };

  try {
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      // SSE : événements séparés par \n\n, lignes "data: {...}"
      let idx: number;
      while ((idx = buffer.indexOf('\n\n')) !== -1) {
        const event = buffer.slice(0, idx); buffer = buffer.slice(idx + 2);
        const line = event.split('\n').find((l) => l.startsWith('data:'));
        if (!line) continue;
        const payload = JSON.parse(line.slice(5).trim());
        if (payload.type === 'token') {
          pending += payload.text;
          frame ??= requestAnimationFrame(flush);   // coalesce
        } else if (payload.type === 'tool_call') {
          this.steps.update((s) => [...s, { kind: 'tool_call', name: payload.name, status: 'running' }]);
        }
      }
    }
  } catch (e) {
    if ((e as Error).name !== 'AbortError') throw e;   // l'abort est attendu, pas une erreur
  } finally {
    if (frame) cancelAnimationFrame(frame);
    flush();
    this.streaming.set(false);
  }
}

3. Pourquoi EventSource ne suffit pas ici

EventSource est plus simple mais GET-only, sans headers custom (donc pas de Authorization: Bearer, pas de body) et non annulable proprement au niveau requête. Pour un agent on a besoin d'un POST (le prompt), d'un token d'auth, et d'un AbortController câblé au bouton Stop et à la navigation. D'où fetch + getReader(). Tableau de décision :

BesoinEventSourcefetch + ReadableStream
POST + body
Headers (auth)
Annulation (AbortController)partielle (.close())✅ native
Reconnexion auto✅ intégréeà coder soi-même
Backpressure / parsing manuelnonoui (à votre charge)

4. Garder l'état d'agent à travers les navigations

Quand l'utilisateur quitte /agents/:id puis revient, on ne veut pas re-streamer. Deux leviers routing :

  • RouteReuseStrategy custom pour détacher/réattacher le composant chat (garde le DOM et l'état signal vivant) — puissant mais subtil (fuites mémoire si mal géré).
  • Plus simple et recommandé : un store providedIn: 'root' (signal de conversations) ; le composant n'est qu'une vue sur le store, la route ne fait que sélectionner l'id. Le resolver charge l'historique persisté ; le live-stream alimente le même store. C'est l'architecture "route = sélecteur, store = source de vérité".

Côté serveur (rappel d'intégration) : le AbortController.abort() du client doit propager l'annulation jusqu'à l'appel LLM. En NestJS, on écoute req.on('close') / le signal d'@Sse(), et on passe un AbortSignal au SDK Anthropic (client.messages.stream({ ... }, { signal })) pour ne pas continuer à payer des tokens après que l'utilisateur a navigué ailleurs.


🔁 Quand utiliser / éviter

Architecture par défaut en 2026 :

  • provideRouter avec au minimum withComponentInputBinding(), withInMemoryScrolling() et une preloading strategy.
  • withViewTransitions() dès qu'on cible Chrome/Edge (et qu'on a un fallback gracieux ailleurs).
  • Guards et resolvers fonctionnels uniquement (les classes guards sont dépréciées dans l'esprit, gardées pour rétrocompat).
  • Lazy par composant via loadComponent ; lazy par feature via loadChildren retournant des routes standalone.
  • Input binding partout — éviter ActivatedRoute injecté sauf cas particulier (filets de fallback, navigation contextuelle).

À éviter :

  • RouterModule.forRoot sur nouvelle app — déprécié pour les apps standalone.
  • Classes CanActivate / Resolve — préférer les fonctions.
  • Resolvers pour des données non bloquantes — préférer le pattern "charger dans le composant avec skeleton".
  • Routes wildcard placées trop tôt dans le tableau.
  • paramsInheritanceStrategy par défaut (emptyOnly) — basculer sur 'always' globalement.

🏋️ Exercices

Progression : implémenter → rendre production-grade → casser puis réparer. Fais-les dans l'ordre, chacun s'appuie sur le précédent.

1. Guards & matching — canMatch vs canActivate (échauffement)

Objectif : prouver expérimentalement la différence de coût entre canMatch et canActivate. Crée deux routes lazy /admin-match (guard en canMatch) et /admin-activate (guard en canActivate), toutes deux refusées. Ouvre l'onglet Network, navigue vers chacune. Indice/Solution : sur /admin-activate, le chunk admin-activate.component.js apparaît dans Network avant la redirection ; sur /admin-match, il n'apparaît jamais. Ajoute un console.log dans RouteConfigLoadStart pour le visualiser. Conclusion : canMatch pour l'autorisation à charger.

2. Filtres deep-linkables — FormGroupqueryParams

Objectif : synchroniser un formulaire de filtres avec l'URL, bidirectionnellement, sans boucle infinie. Route /products avec un FormGroup { search, brand, page }. Les valueChanges poussent dans l'URL (queryParamsHandling: 'merge', replaceUrl: true), et route.queryParamMap réhydrate le form au reload et au back/forward. Indice/Solution : le piège est la boucle (form → URL → form → URL…). Casse-la avec debounceTime(300) + distinctUntilChanged côté form, et { emitEvent: false } sur le patchValue venant de l'URL. Utilise toSignal(route.queryParamMap) pour la lecture. replaceUrl: true évite de polluer l'historique à chaque frappe.

3. CanDeactivate production-grade

Objectif : remplacer le confirm() bloquant par un vrai dialog asynchrone, et couvrir le beforeunload. Le guard doit retourner un Observable<boolean> issu d'un CDK Dialog. Couvre les 3 sorties : navigation interne (guard), fermeture d'onglet/F5 (beforeunload), et le cas "form propre → pas de prompt". Indice/Solution : canDeactivate accepte Observable<boolean> ; ouvre le dialog, mappe son résultat. Le beforeunload ne peut afficher qu'un prompt natif générique (les browsers ignorent le texte custom depuis 2016) — appelle juste e.preventDefault(). Teste que naviguer avec un form propre ne déclenche aucun dialog.

4. Agent chat streamé par route + bouton Stop

Objectif : route /agents/:id qui stream des tokens et s'annule à la navigation ET au clic Stop. Implémente le composant de la section Stack-fit : input.required pour :id, fetch + getReader(), coalescing rAF, AbortController câblé à destroyRef.onDestroy() et au bouton Stop. Backend mockable avec un ReadableStream qui émet un token toutes les 30 ms. Indice/Solution : le bug que tout le monde fait — naviguer de /agents/1 à /agents/2 continue à streamer la conversation 1 dans la 2. Le effect() sur conversationId() doit abort() l'ancien controller avant de repartir. Vérifie qu'un AbortError dans catch n'est pas re-throw. Bonus : passe le composant en zoneless et vérifie au profiler que le rAF coalesce bien (≈ 60 CD/s max, pas 1 par token).

5. Casse-le : le bug du chunk-load après déploiement

Objectif : reproduire puis corriger le ChunkLoadError de prod. Build l'app, sers le dist, charge la page. Puis change le code d'une route lazy, rebuild (nouveaux hashes), et SANS recharger la page, clique sur le lien lazy. La navigation échoue (Failed to fetch dynamically imported module). Indice/Solution : abonne-toi à NavigationError, détecte le pattern dynamically imported module, et fais window.location.assign(e.url) pour un hard reload ciblé. Ou utilise withNavigationErrorHandler(). C'est LE incident classique des SPA lazy : l'utilisateur a un index.html périmé, le serveur ne sert plus les anciens chunks.

6. (Architecte) RouteReuseStrategy sélective + détection de fuite

Objectif : conserver l'état d'un composant lourd entre navigations, sans fuite mémoire. Implémente une RouteReuseStrategy qui détache/réattache le composant de la route /dashboard (graphes coûteux) mais re-crée tout le reste. Marque les routes à réutiliser via data: { reuse: true }. Indice/Solution : shouldDetach/store/shouldAttach/retrieve/shouldReuseRoute. Le danger : un Map<string, DetachedRouteHandle> qui grossit sans fin → fuite. Plafonne le cache (LRU), et dispose les handles évincés (sinon les souscriptions/timers du composant détaché tournent en fond). Vérifie au Memory profiler que naviguer 100× ne fait pas exploser le heap.


🎤 En entretien

Q : Différence concrète entre CanActivate et CanMatch ? Lequel pour l'auth ?CanMatch s'exécute pendant le matching (avant RoutesRecognized) ; s'il rejette, le router continue de chercher une autre route et le chunk lazy n'est jamais téléchargé. CanActivate s'exécute après le matching, donc le code lazy est déjà chargé. Pour l'auth/feature-flags → CanMatch (moins de surface, moins de bundle gaspillé) ; CanActivate pour des vérifs contextuelles fines une fois la route choisie.

Q : Un guard Angular sécurise-t-il une route ? Non. Un guard est une UX côté client : tout le bundle est sur le navigateur, l'utilisateur peut le contourner via devtools. La sécurité réelle est côté API (chaque endpoint vérifie le rôle/scope serveur). Le guard évite juste d'afficher une page qui échouerait en 403 — défense en profondeur, pas autorité.

Q : Pourquoi un resolver peut "geler" l'application, et quand l'éviter ? Le router attend la première émission de tous les ResolveFn d'une activation avant NavigationEnd ; un Observable qui n'émet jamais bloque la navigation indéfiniment. On réserve les resolvers à la donnée indispensable au premier rendu (titre, données critiques). Si la donnée peut arriver après, on charge dans le composant avec un skeleton — sinon l'utilisateur clique et l'UI reste figée sans feedback.

Q : Avec withComponentInputBinding(), pourquoi un resolver déclaré sur la route parente n'arrive pas dans l'@Input() du composant enfant ? Parce que l'input binding propage data/params au composant de son propre niveau d'ActivatedRoute. Pour qu'une donnée resolved sur /users/:id atteigne le composant de /users/:id/orders, il faut paramsInheritanceStrategy: 'always' (qui propage aussi data vers les enfants). Sans ça, l'enfant doit lire route.parent.data manuellement. En cas de collision de noms, l'ordre de priorité est data > params > queryParams.

Q : RouteReuseStrategy — quel est le risque principal et comment le contenir ? La fuite mémoire : store() met le DetachedRouteHandle dans une Map qui grossit sans borne, et le composant détaché continue de vivre (ses souscriptions/timers tournent en fond, ses signals restent abonnés). Il faut plafonner le cache (LRU), disposer les handles évincés (détruire la ComponentRef : handle.componentRef.destroy()), et ne réutiliser que des routes explicitement marquées (data: { reuse: true }) — jamais réutiliser globalement, sinon on sert un état périmé sur une nouvelle entité.

Q : Comment annuler proprement un stream LLM lié à une route quand l'utilisateur navigue ailleurs ? Un AbortController par requête, son signal passé au fetch. On abort() dans destroyRef.onDestroy() (quitter la route détruit le composant) et dans l'effect() qui watch le param de route (changer d':id annule l'ancien stream). Côté serveur, l'abort doit propager jusqu'au SDK Anthropic ({ signal } sur messages.stream) via req.on('close'), pour arrêter de facturer des tokens. Le modèle mental : une navigation est elle-même une pipeline annulable (NavigationCancel), exactement comme un AbortController.


🔗 Liens

  • provideRouter API — angular.dev/api/router/provideRouter
  • Router features (withComponentInputBinding, etc.) — angular.dev/api/router
  • Functional guards — angular.dev/guide/router/route-guards#using-functional-guards
  • View Transitions — angular.dev/guide/router#view-transitions
  • ResolveFnangular.dev/api/router/ResolveFn
  • Migration RouterModuleprovideRouterangular.dev/guide/standalone-migration

Récap final

Le routing moderne tient en quelques choix structurants : provideRouter au bootstrap avec withComponentInputBinding, withViewTransitions, withInMemoryScrolling, withRouterConfig({ paramsInheritanceStrategy: 'always' }) et une preloading strategy. Les routes utilisent loadComponent (composant unique) et loadChildren (feature area en standalone routes). Les guards et resolvers sont des fonctions (CanMatchFn, CanActivateFn, ResolveFn) qui consomment inject() ; on préfère CanMatch à CanActivate pour l'auth. L'input binding remplace ActivatedRoute dans 80 % des cas, et se marie naturellement avec les input signals. Le flux router.events se consomme en signal pour un breadcrumb ou un analytics tracking. Le testing repose sur TestBed.runInInjectionContext pour les guards/resolvers, et provideRouter + provideLocationMocks pour l'intégration.

Bibliothèque tech perso — Achref