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 utilisentloadComponent/loadChildrenpour le lazy-loading par composant (Angular 14+) sans NgModule. L'input binding (Angular 16+, opté viawithComponentInputBinding()) permet de recevoirparamMap,queryParamMapetdatadirectement comme des@Input()du composant, ce qui élimine 80 % du boilerplateActivatedRoute. Depuis Angular 18, le router expose ses événements comme signal (router.events→toSignal()ou direct), et la View Transitions API est activable par feature (withViewTransitions()). On compose enfinwithInMemoryScrolling,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 :
canMatchcourt avantRoutesRecognized: il participe au matching. S'il rejette, le router continue de chercher une autre route.canActivatecourt après — la route est déjà choisie, le chunk déjà téléchargé.- Les resolvers sont parallèles mais bloquants : le router attend que tous les
ResolveFnd'une activation émettent leur première valeur (unObservablequi n'émet jamais bloque la navigation à vie — pitfall classique).take(1)est implicite côté router. - Tout est annulable : un second
navigate()pendant une navigation en cours émetNavigationCancel(raisonSupersededByNewNavigation) sur la première. C'est exactement le modèle d'unAbortController— utile mentalement quand on streamera des tokens LLM par route plus bas.
| Phase | Événement | Hook | Annulable par |
|---|---|---|---|
| Matching | RoutesRecognized | CanMatchFn, redirectTo | canMatch → false/UrlTree |
| Sortie | GuardsCheckStart | CanDeactivateFn | false (formulaire sale) |
| Entrée | GuardsCheckStart | CanActivateFn, CanActivateChildFn | false/UrlTree |
| Données | ResolveStart/End | ResolveFn | throw → NavigationError |
| Activation | ActivationStart | constructeur du composant | — |
| Fin | NavigationEnd | effect() sur router.events | — |
🛠️ Code minimal (ts + html)
Bootstrap
// 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
// 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),
},
];// 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
// 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
// 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
// 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
<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
CanActivateretourne 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
CanMatchretourne 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 :
// 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. CommeswitchMapest synchrone à la souscription et que le router souscrit immédiatement dans son propre contexte, lesinject()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 premierinject()».
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 combinateurallOfne 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 :
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).rolesAngular 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() :
| Source | Cible @Input() |
|---|---|
route.paramMap | inputs nommés comme le paramètre (:id → id) |
route.queryParamMap | inputs 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 :
- Le resolver vit sur la route parente, l'input est sur le composant enfant. L'input binding propage
data/paramsau composant du même niveau d'ActivatedRoute. Siresolve: { user }est déclaré sur/users/:idmais que le composant qui veutuserest un enfant/users/:id/orders, l'inputuserde l'enfant resteraundefined— sauf siparamsInheritanceStrategy: 'always'(qui propage aussidataaux enfants). C'est exactement pourquoi on l'active globalement. - 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 pousseundefineddans l'inputsort— donc uninput<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. 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èsResolveEnd) — c'est sûr. Mais ne jamais combinerinput.requiredavec une donnée non resolver (un simple query param optionnel) : utiliserinput<T>().
Couplée aux input signals (Angular 17+), on obtient une expérience encore plus fluide :
@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 :
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 :
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).
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
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.
::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 :
this.router.navigate(['/users', userId, 'orders'], { queryParams: { sort: 'desc' } });Pour rester sur la route courante et ne modifier que les query params :
this.router.navigate([], {
relativeTo: this.route,
queryParams: { sort: 'asc' },
queryParamsHandling: 'merge',
});🔄 Versions — Angular 16 → 20
| Version | Évolutions majeures du router |
|---|---|
| Angular 14 | Standalone components, loadComponent. |
| Angular 15 | Guards et resolvers fonctionnels (CanActivateFn, ResolveFn, etc.). |
| Angular 16 | withComponentInputBinding() : params/data en @Input(). |
| Angular 17 | provideRouter devient l'API canonique ; RouterModule.forRoot déprécié pour le standalone. View Transitions API (withViewTransitions). Input signals (input()) compatibles avec input binding. |
| Angular 18 | router.events consommable proprement en signal (toSignal). withDebugTracing pour tracer en dev. |
| Angular 19 | runGuardsAndResolvers: 'paramsOrQueryParamsChange' optimisé. Améliorations performance du matching. |
| Angular 20 | Stabilisation, 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
- Routes catch-all qui mangent tout : un
path: '**'placé avant d'autres routes les rend inatteignables. Toujours placer le wildcard en dernier. CanActivateau lieu deCanMatchpour l'auth : le bundle lazy est téléchargé avant que le guard refuse. Fuite de code et de surface d'attaque. UtiliserCanMatch.- 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.
- Oublier
pathMatch: 'full'sur la route'': avecpath: ''sans pathMatch, toute URL matche (parce que''est un préfixe de tout). Conséquence : redirections en boucle ou matching incorrect. paramsInheritanceStrategynon'always': un composant enfant ne voit pas l'iddu parent et doit faire une remontée manuelle. Activer'always'globalement.withComponentInputBindingnon activé : on écrit du boilerplateroute.paramMap.subscribe(...)partout. Activer la feature et utiliser@Input().- Navigation en boucle avec un guard qui retourne
createUrlTree([currentUrl]): le router rejoue, le guard rejette à nouveau. Toujours vérifier vers où on redirige. relativeTooublié surrouter.navigate([...])à partir d'une route enfant : la navigation se fait par rapport à la racine, pas à la route courante. AjouterrelativeTo: this.route.- Fuites de souscription sur
router.eventsdansngOnInitsanstakeUntilDestroyed(). Solution moderne :toSignal()outakeUntilDestroyed(). - Preloading agressif sur mobile / data limité :
PreloadAllModulesconsomme 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 vianavigator.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
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
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
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.
Scénario 2 — E-commerce, route catalogue avec deep-link
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 : valueChanges → router.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).
Scénario 3 — Cabinet juridique, dossier deep-link avec guards conditionnels
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.
// 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; }
}// 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']);
};// 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}`);
};// 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),
},
],
},
];// 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>();
}// 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(),
),
],
});// 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.
// 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 ?');
};// route
{
path: 'edit/:id',
loadComponent: () => import('./editor.component').then((m) => m.EditorComponent),
canDeactivate: [confirmLeave],
}// 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.
@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 :
// 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);
}
});
}
}// 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é).
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 :
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 :
canMatchré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
loadChildrenqui ramène 600 KB annule l'intérêt du lazy. - Preloading adaptatif sur réseau contraint (cf. pitfall #10) :
navigator.connection?.saveDataoueffectiveType !== '4g'→ ne pas précharger. runGuardsAndResolvers: par défautparamsChange. Si un resolver re-fetch à chaque changement de query param alors que seul:idcompte, repasser sur'pathParamsChange'évite des appels réseau inutiles.bindToComponentInputsest 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
// 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
onCleanupplutôt quedestroyRef.onDestroy()+ abort manuel dans l'effect ? La fonction de nettoyage d'uneffectcourt automatiquement (a) avant chaque ré-exécution et (b) à la destruction du contexte d'injection. Un seulcontroller.abort()couvre donc changement de:idET 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 »). LedestroyRefreste 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.
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 :
| Besoin | EventSource | fetch + ReadableStream |
|---|---|---|
| POST + body | ❌ | ✅ |
| Headers (auth) | ❌ | ✅ |
Annulation (AbortController) | partielle (.close()) | ✅ native |
| Reconnexion auto | ✅ intégrée | à coder soi-même |
| Backpressure / parsing manuel | non | oui (à 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 :
RouteReuseStrategycustom 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 écoutereq.on('close')/ le signal d'@Sse(), et on passe unAbortSignalau 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 :
provideRouteravec au minimumwithComponentInputBinding(),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 vialoadChildrenretournant des routes standalone. - Input binding partout — éviter
ActivatedRouteinjecté sauf cas particulier (filets de fallback, navigation contextuelle).
À éviter :
RouterModule.forRootsur 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.
paramsInheritanceStrategypar 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 — FormGroup ⇄ queryParams
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
provideRouterAPI —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 ResolveFn—angular.dev/api/router/ResolveFn- Migration
RouterModule→provideRouter—angular.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.