Angular RouteReuseStrategy : guide complet

Front-end angularforall.com
Angular Routing Route Reuse Strategy Performance Angular Routing
Angular RouteReuseStrategy : guide complet

Maîtrisez RouteReuseStrategy Angular : 5 méthodes, cache de composants, stratégie personnalisée et zéro fuite mémoire.

Le problème du re-rendering à chaque navigation

Par défaut, Angular détruit et recrée chaque composant à chaque navigation. Ce comportement est sain pour la plupart des cas : il garantit un état propre, évite les données périmées et simplifie le cycle de vie. Mais dans certains contextes, il devient un vrai problème de performance et d'expérience utilisateur.

Imaginez un tableau de bord avec trois onglets : Commandes, Clients et Statistiques. Chaque onglet charge 500 lignes depuis une API. Chaque fois que l'utilisateur change d'onglet, Angular :

  1. Détruit le composant de l'onglet précédent (appel de ngOnDestroy)
  2. Crée le nouveau composant (appel de ngOnInit)
  3. Déclenche une requête HTTP vers l'API
  4. Attend la réponse, re-rend 500 lignes dans le DOM

Sur un réseau 4G, chaque changement d'onglet coûte 400ms à 1,2s. Sur mobile, le re-rendu de 500 lignes peut bloquer le thread principal pendant 80ms, provoquant un jank visible. Mesurons concrètement :

// Composant orders.component.ts — sans RouteReuseStrategy
@Component({ selector: 'app-orders', template: '...' })
export class OrdersComponent implements OnInit, OnDestroy {
    orders = signal<Order[]>([]);
    private ordersService = inject(OrdersService);

    ngOnInit(): void {
        const t0 = performance.now();
        // Requête HTTP déclenchée à CHAQUE navigation vers cet onglet
        this.ordersService.getOrders().subscribe(data => {
            this.orders.set(data);
            console.log(`Chargement : ${(performance.now() - t0).toFixed(0)}ms`);
            // Log: "Chargement : 847ms" — à chaque changement d'onglet !
        });
    }

    ngOnDestroy(): void {
        // Appelé à chaque navigation — composant détruit
        console.log('OrdersComponent détruit');
    }
}

Le problème s'aggrave avec les formulaires. Un utilisateur remplit un formulaire complexe de 20 champs, navigue sur une autre page pour vérifier une information, puis revient. Sans stratégie de réutilisation, tout le formulaire est réinitialisé. C'est une source majeure de frustration.

Cas où RouteReuseStrategy est indispensable : tableaux de bord multi-onglets, wizards multi-étapes, listes avec filtres/pagination/tri, formulaires longs avec navigation entre étapes.

La solution d'Angular est l'interface RouteReuseStrategy. Elle permet d'intercepter le cycle de vie des routes pour mettre en cache les composants au lieu de les détruire, puis les réattacher à la navigation suivante — comme si le composant n'avait jamais été quitté.

Deux types de réutilisation

Type Méthode concernée Description
Réutilisation simple shouldReuseRoute Même route, même paramètre — Angular garde le composant actif
Réutilisation par cache shouldDetach, store, shouldAttach, retrieve Route différente — Angular détache et stocke, puis réattache plus tard

L'interface RouteReuseStrategy : les 5 méthodes

L'interface RouteReuseStrategy est définie dans @angular/router. Elle expose 5 méthodes abstraites que vous devez implémenter. Angular les appelle dans un ordre précis à chaque navigation.

// Définition de l'interface (source Angular)
export abstract class RouteReuseStrategy {
    // 1. Décide si la route doit être mise en cache avant navigation
    abstract shouldDetach(route: ActivatedRouteSnapshot): boolean;

    // 2. Stocke le handle de la route détachée
    abstract store(
        route: ActivatedRouteSnapshot,
        handle: DetachedRouteHandle | null
    ): void;

    // 3. Décide si un handle en cache doit être réutilisé à l'arrivée
    abstract shouldAttach(route: ActivatedRouteSnapshot): boolean;

    // 4. Retourne le handle mis en cache (ou null)
    abstract retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null;

    // 5. Décide si la route ACTUELLE peut être réutilisée (navigation vers la même route)
    abstract shouldReuseRoute(
        future: ActivatedRouteSnapshot,
        curr: ActivatedRouteSnapshot
    ): boolean;
}

Le flux d'exécution lors d'une navigation de /orders vers /clients est le suivant :

# Méthode appelée Route concernée Question posée
1 shouldReuseRoute orders → clients Même configuration de route ? (non → continue)
2 shouldDetach /orders (route quittée) Faut-il mettre orders en cache ?
3 store /orders Si oui → stocker le handle
4 shouldAttach /clients (route cible) Un handle en cache existe-t-il pour clients ?
5 retrieve /clients Si oui → retourner le handle stocké
DetachedRouteHandle est un objet opaque qu'Angular gère en interne. Il encapsule le composant instancié, son arbre de vue (ViewRef) et ses injections. Vous ne le manipulez jamais directement — vous le stockez et le retournez tel quel.

Ordre d'appel en résumé :

  • shouldReuseRoute → appelé en premier, sur TOUTE navigation
  • shouldDetach → store → appelés sur la route quittée (si shouldReuseRoute retourne false)
  • shouldAttach → retrieve → appelés sur la route cible (si shouldReuseRoute retourne false)

shouldReuseRoute : le comportement par défaut

C'est la méthode la plus simple mais la plus fréquemment appelée. Angular l'invoque à chaque navigation, même mineure. L'implémentation par défaut d'Angular (DefaultUrlSerializer) compare simplement les configurations de route :

// Implémentation par défaut Angular (BaseRouteReuseStrategy)
shouldReuseRoute(
    future: ActivatedRouteSnapshot,
    curr: ActivatedRouteSnapshot
): boolean {
    // Retourne true si c'est exactement la même définition de route
    // Cela signifie : même path pattern, même composant associé
    return future.routeConfig === curr.routeConfig;
}

Concrètement, ce comportement par défaut signifie :

Navigation shouldReuseRoute retourne Effet
/orders/1/orders/2 true Même route (orders/:id), composant CONSERVÉ, ngOnInit non rappelé
/orders/clients false Routes différentes → shouldDetach/shouldAttach déclenchés
/orders/orders true Même URL, composant CONSERVÉ (pas de re-render)

Le cas /orders/1/orders/2 est le plus délicat. Angular ne rappelle pas ngOnInit car il considère que c'est la même route. Pour détecter le changement de paramètre, vous devez observer ActivatedRoute.params ou ActivatedRoute.paramMap :

// Écouter les changements de paramètre dans la même route
@Component({ selector: 'app-order-detail', template: '...' })
export class OrderDetailComponent implements OnInit {
    private route = inject(ActivatedRoute);
    private ordersService = inject(OrdersService);
    order = signal<Order | null>(null);

    ngOnInit(): void {
        // paramMap émet à chaque changement d'ID même si shouldReuseRoute = true
        this.route.paramMap.pipe(
            map(params => params.get('id') ?? ''),
            switchMap(id => this.ordersService.getOrder(id))
        ).subscribe(order => this.order.set(order));
    }
}
Piège classique : Si vous lisez route.snapshot.params['id'] dans ngOnInit au lieu d'observer route.paramMap, vous ne verrez jamais les changements de paramètre quand shouldReuseRoute retourne true.

Forcer le re-rendering sur la même route

Si vous voulez qu'Angular détruise et recrée le composant même sur la même route (comportement "toujours neuf"), surchargez shouldReuseRoute pour retourner toujours false. À utiliser avec précaution :

// Stratégie "toujours recréer" — utile pour certains flux d'onboarding
@Injectable()
export class NeverReuseStrategy extends BaseRouteReuseStrategy {
    override shouldReuseRoute(
        future: ActivatedRouteSnapshot,
        curr: ActivatedRouteSnapshot
    ): boolean {
        // Vérifier un flag optionnel dans la config de route
        if (future.data['alwaysRefresh'] === true) {
            return false; // Forcer la recréation pour cette route
        }
        // Comportement par défaut pour les autres routes
        return future.routeConfig === curr.routeConfig;
    }
}
// Route configurée pour toujours se rafraîchir
export const routes: Routes = [
    {
        path: 'dashboard',
        component: DashboardComponent,
        data: { alwaysRefresh: true }, // Toujours recréer ce composant
    },
    {
        path: 'orders/:id',
        component: OrderDetailComponent,
        // Pas de alwaysRefresh → comportement par défaut
    },
];

Créer une stratégie personnalisée complète

Voici l'implémentation complète d'une stratégie de mise en cache sélective. Elle met en cache uniquement les routes marquées avec data: { reuse: true }, ce qui vous donne un contrôle granulaire route par route.

// src/app/core/strategies/custom-route-reuse.strategy.ts
import { Injectable } from '@angular/core';
import {
    RouteReuseStrategy,
    ActivatedRouteSnapshot,
    DetachedRouteHandle,
    Route,
} from '@angular/router';

@Injectable()
export class CustomRouteReuseStrategy implements RouteReuseStrategy {
    // Map pour stocker les handles — clé = configuration de route, valeur = handle
    private readonly cache = new Map<Route, DetachedRouteHandle>();

    // 1. Faut-il détacher (mettre en cache) la route quittée ?
    shouldDetach(route: ActivatedRouteSnapshot): boolean {
        // Seules les routes marquées reuse:true seront mises en cache
        return route.data['reuse'] === true;
    }

    // 2. Stocker le handle du composant détaché
    store(
        route: ActivatedRouteSnapshot,
        handle: DetachedRouteHandle | null
    ): void {
        if (handle === null) {
            // Angular nous demande de supprimer l'entrée (navigation de nettoyage)
            this.cache.delete(route.routeConfig!);
            return;
        }
        if (route.data['reuse'] === true && route.routeConfig) {
            this.cache.set(route.routeConfig, handle);
        }
    }

    // 3. La route cible doit-elle réutiliser un handle en cache ?
    shouldAttach(route: ActivatedRouteSnapshot): boolean {
        // Deux conditions : route marquée ET handle disponible en cache
        return (
            route.data['reuse'] === true &&
            route.routeConfig !== null &&
            this.cache.has(route.routeConfig)
        );
    }

    // 4. Retourner le handle mis en cache pour la route cible
    retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
        if (!route.routeConfig || !this.cache.has(route.routeConfig)) {
            return null;
        }
        return this.cache.get(route.routeConfig) ?? null;
    }

    // 5. Réutiliser le composant actuel si même routeConfig (comportement standard)
    shouldReuseRoute(
        future: ActivatedRouteSnapshot,
        curr: ActivatedRouteSnapshot
    ): boolean {
        return future.routeConfig === curr.routeConfig;
    }
}

Configurer les routes avec le flag reuse

// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { OrdersComponent } from './orders/orders.component';
import { ClientsComponent } from './clients/clients.component';
import { StatsComponent } from './stats/stats.component';
import { OrderDetailComponent } from './order-detail/order-detail.component';

export const routes: Routes = [
    {
        path: 'orders',
        component: OrdersComponent,
        data: { reuse: true }, // Mis en cache — liste volumineuse
    },
    {
        path: 'clients',
        component: ClientsComponent,
        data: { reuse: true }, // Mis en cache — grille filtrable
    },
    {
        path: 'stats',
        component: StatsComponent,
        // Pas de reuse → toujours recréé (données temps réel)
    },
    {
        path: 'orders/:id',
        component: OrderDetailComponent,
        // Pas de reuse → formulaire toujours propre à l'ouverture
    },
];

Enregistrer la stratégie dans l'application

L'enregistrement se fait via le provider RouteReuseStrategy. Deux approches selon votre architecture :

// Approche standalone (Angular 14+) — src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { RouteReuseStrategy } from '@angular/router';
import { routes } from './app.routes';
import { CustomRouteReuseStrategy } from './core/strategies/custom-route-reuse.strategy';

export const appConfig: ApplicationConfig = {
    providers: [
        provideRouter(routes),
        // Remplace l'implémentation par défaut d'Angular
        {
            provide: RouteReuseStrategy,
            useClass: CustomRouteReuseStrategy,
        },
    ],
};
// Approche NgModule (Angular classic) — src/app/app.module.ts
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { RouteReuseStrategy } from '@angular/router';
import { CustomRouteReuseStrategy } from './core/strategies/custom-route-reuse.strategy';
import { routes } from './app.routes';

@NgModule({
    imports: [RouterModule.forRoot(routes)],
    providers: [
        {
            provide: RouteReuseStrategy,
            useClass: CustomRouteReuseStrategy,
        },
    ],
})
export class AppModule {}
Conseil d'architecture : Placez votre stratégie dans src/app/core/strategies/. Créez un barrel index.ts dans ce dossier pour des imports propres. La stratégie est un service singleton — Angular l'injecte là où RouteReuseStrategy est requis (uniquement par le Router en interne).

shouldDetach, store, shouldAttach, retrieve en détail

Ces quatre méthodes forment le cœur du mécanisme de cache. Comprendre leur interaction précise est essentiel pour éviter les bugs.

shouldDetach : choisir ce qu'on met en cache

Cette méthode est appelée quand l'utilisateur quitte une route. Elle doit retourner true si le composant doit être préservé. Le paramètre route est un snapshot de la route que l'on quitte.

// Stratégie fine : reuse conditionnel selon l'état du composant
shouldDetach(route: ActivatedRouteSnapshot): boolean {
    // Option 1 : flag statique dans la config de route
    if (route.data['reuse'] === true) return true;

    // Option 2 : flag dynamique défini par le composant lui-même
    // Le composant peut définir ce flag selon son état interne
    if (route.data['reuseWhenDirty'] === true) {
        // Récupérer un service pour vérifier l'état du formulaire
        // (pattern avancé — voir section cas d'usage)
        return true;
    }

    return false; // Par défaut : ne pas mettre en cache
}

store : persister le handle

Appelée juste après que shouldDetach retourne true. Angular vous passe le DetachedRouteHandle — l'objet encapsulant le composant. Vous pouvez également recevoir null (Angular nettoie le cache).

// store avec logging pour le debug
store(
    route: ActivatedRouteSnapshot,
    handle: DetachedRouteHandle | null
): void {
    const key = route.routeConfig;
    if (handle === null) {
        // Nettoyage demandé par Angular (ex: navigation forcée ou guard de déactivation)
        if (key) this.cache.delete(key);
        return;
    }
    if (key) {
        this.cache.set(key, handle);
        // En dev : tracer quelles routes sont en cache
        if (isDevMode()) {
            console.log('[ReuseStrategy] Mis en cache :', route.routeConfig?.path);
            console.log('[ReuseStrategy] Taille du cache :', this.cache.size);
        }
    }
}

shouldAttach : décider le réattachement

Appelée quand l'utilisateur arrive sur une route. Retourner true signifie "utilise le composant en cache au lieu d'en créer un nouveau". Si vous retournez true mais que retrieve retourne null, Angular lancera une erreur.

// shouldAttach robuste avec vérification complète
shouldAttach(route: ActivatedRouteSnapshot): boolean {
    // Vérification 1 : la route est-elle marquée pour réutilisation ?
    if (route.data['reuse'] !== true) return false;

    // Vérification 2 : routeConfig valide ?
    if (!route.routeConfig) return false;

    // Vérification 3 : handle réellement présent en cache ?
    const hasHandle = this.cache.has(route.routeConfig);

    // IMPORTANT : ne retourner true que si retrieve() peut retourner un handle valide
    // Sinon Angular lève une erreur runtime
    return hasHandle;
}

retrieve : restituer le handle

Appelée immédiatement après shouldAttach si elle retourne true. Doit retourner le handle stocké dans store.

// retrieve avec protection contre null
retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
    // Double vérification — toujours defensive
    if (!route.routeConfig) return null;
    if (!this.cache.has(route.routeConfig)) return null;

    const handle = this.cache.get(route.routeConfig);

    // Retourner undefined comme null pour Angular
    return handle ?? null;
}
Règle d'or : Si shouldAttach retourne true, retrieve DOIT retourner un handle non-null. Ces deux méthodes doivent être logiquement cohérentes. Une façon simple de garantir cette cohérence : partagez la même condition de vérification du cache.

Clés de cache intelligentes : path, params, query

L'implémentation basique utilise route.routeConfig comme clé — un objet JavaScript. Cette approche a une limite critique : si vous naviguez de /orders/1 vers /orders/2 et que ces deux routes ont le même routeConfig (car c'est la même définition orders/:id), le cache ne distingue pas les deux.

Pour un cache basé sur l'URL complète (incluant les paramètres), construisez une clé de type string :

// Stratégie avec clés URL-based — gère les paramètres de route
@Injectable()
export class SmartRouteReuseStrategy implements RouteReuseStrategy {
    private readonly cache = new Map<string, DetachedRouteHandle>();

    // Construire une clé unique à partir du path + params
    private buildKey(route: ActivatedRouteSnapshot): string {
        // Construire le path en remplaçant les segments de paramètres
        // Ex: "orders/:id" avec params {id: '42'} → "orders/42"
        const path = route.pathFromRoot
            .filter(r => r.routeConfig?.path)
            .map(r => {
                let segment = r.routeConfig!.path!;
                // Remplacer chaque paramètre :param par sa valeur réelle
                Object.entries(r.params).forEach(([key, value]) => {
                    segment = segment.replace(`:${key}`, String(value));
                });
                return segment;
            })
            .join('/');

        // Optionnel : inclure les query params pour différencier ?page=1 de ?page=2
        const queryString = Object.entries(route.queryParams)
            .sort(([a], [b]) => a.localeCompare(b)) // Tri pour stabilité
            .map(([k, v]) => `${k}=${v}`)
            .join('&');

        return queryString ? `${path}?${queryString}` : path;
    }

    shouldDetach(route: ActivatedRouteSnapshot): boolean {
        return route.data['reuse'] === true;
    }

    store(
        route: ActivatedRouteSnapshot,
        handle: DetachedRouteHandle | null
    ): void {
        const key = this.buildKey(route);
        if (handle === null) {
            this.cache.delete(key);
        } else if (route.data['reuse'] === true) {
            this.cache.set(key, handle);
        }
    }

    shouldAttach(route: ActivatedRouteSnapshot): boolean {
        return route.data['reuse'] === true && this.cache.has(this.buildKey(route));
    }

    retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
        return this.cache.get(this.buildKey(route)) ?? null;
    }

    shouldReuseRoute(
        future: ActivatedRouteSnapshot,
        curr: ActivatedRouteSnapshot
    ): boolean {
        return future.routeConfig === curr.routeConfig;
    }
}

Avec cette approche, /orders/1 et /orders/2 ont des clés distinctes ("orders/1" et "orders/2"). Chaque combinaison path/params a son propre slot dans le cache. C'est le comportement attendu pour une app de type "onglets de détail".

URL naviguée Clé générée (buildKey) Comportement
/orders "orders" Une seule entrée pour la liste
/orders/1 "orders/1" Cache distinct par ID
/orders/2 "orders/2" Cache distinct par ID
/orders?page=2 "orders?page=2" Cache distinct par page
Attention aux query params en cache : Inclure les query params dans la clé peut provoquer une explosion du cache (une entrée par combination de filtres). Évaluez si c'est réellement nécessaire ou si vous préférez ignorer les query params dans buildKey.

Gestion mémoire : TTL, limites et destroyHandle

Un cache sans gestion devient une fuite mémoire. Chaque DetachedRouteHandle conserve en mémoire le composant, son arbre DOM virtuel, ses subscriptions et ses données. Multipliez ça par 20 routes cachées, et votre application devient un gouffre de RAM.

Limiter la taille du cache (LRU simple)

// Stratégie avec limite LRU (Least Recently Used)
@Injectable()
export class BoundedCacheReuseStrategy implements RouteReuseStrategy {
    private readonly MAX_CACHE_SIZE = 5; // Au maximum 5 composants en cache

    // Map ordonnée : les entrées les plus récentes sont en fin de map
    private readonly cache = new Map<string, {
        handle: DetachedRouteHandle;
        timestamp: number;
    }>();

    store(
        route: ActivatedRouteSnapshot,
        handle: DetachedRouteHandle | null
    ): void {
        const key = route.routeConfig?.path ?? '';
        if (!key) return;

        if (handle === null) {
            this.cache.delete(key);
            return;
        }

        // Si cache plein, supprimer l'entrée la plus ancienne
        if (this.cache.size >= this.MAX_CACHE_SIZE) {
            const oldestKey = this.cache.keys().next().value;
            if (oldestKey) {
                this.destroyHandle(this.cache.get(oldestKey)!.handle);
                this.cache.delete(oldestKey);
            }
        }

        this.cache.set(key, {
            handle,
            timestamp: Date.now(),
        });
    }

    // Libérer proprement le composant (Angular 17+)
    private destroyHandle(entry: { handle: DetachedRouteHandle }): void {
        // destroyDetachedRouteHandle est disponible depuis Angular 17
        // pour libérer proprement la vue et déclencher ngOnDestroy
        const componentRef = (entry.handle as any)?.componentRef;
        if (componentRef?.destroy) {
            componentRef.destroy(); // Déclenche ngOnDestroy + libère la ViewRef
        }
    }

    // ... autres méthodes (shouldDetach, shouldAttach, retrieve, shouldReuseRoute)
}

TTL : expirer les composants après un délai

// Ajout d'un TTL de 5 minutes par entrée de cache
store(
    route: ActivatedRouteSnapshot,
    handle: DetachedRouteHandle | null
): void {
    const key = this.buildKey(route);
    if (handle === null) {
        this.cache.delete(key);
        return;
    }

    // Expirer les entrées périmées avant d'en ajouter une nouvelle
    const TTL_MS = 5 * 60 * 1000; // 5 minutes
    const now = Date.now();
    for (const [cacheKey, entry] of this.cache.entries()) {
        if (now - entry.timestamp > TTL_MS) {
            this.cache.delete(cacheKey); // Supprimer les entrées expirées
        }
    }

    if (route.data['reuse'] === true) {
        this.cache.set(key, { handle, timestamp: now });
    }
}

shouldAttach(route: ActivatedRouteSnapshot): boolean {
    if (route.data['reuse'] !== true) return false;
    const key = this.buildKey(route);
    const entry = this.cache.get(key);
    if (!entry) return false;

    const TTL_MS = 5 * 60 * 1000;
    // Ne réutiliser que si le cache n'a pas expiré
    if (Date.now() - entry.timestamp > TTL_MS) {
        this.cache.delete(key); // Nettoyer l'entrée périmée
        return false;
    }

    return true;
}

ngOnDestroy et les composants en cache

Un composant mis en cache par RouteReuseStrategy n'a pas son ngOnDestroy appelé lors de la mise en cache. Il sera appelé uniquement quand le handle est détruit (fin de session, LRU eviction, ou destruction manuelle). Cela a des implications importantes :

  • RxJS subscriptions : utilisez takeUntilDestroyed() ou DestroyRef pour vous désabonner proprement
  • Intervals / setTimeout : stockez la référence et nettoyez dans ngOnDestroy
  • Event listeners : toujours les retirer dans ngOnDestroy
  • Données temps réel : implémenter ngOnInit + ActivatedRoute pour détecter le retour sur la route
  • Web Sockets : gérer la reconnexion via un service partagé, pas dans le composant
// Pattern correct pour un composant qui peut être mis en cache
@Component({ selector: 'app-orders', template: '...' })
export class OrdersComponent implements OnInit, OnDestroy {
    private destroyRef = inject(DestroyRef);
    private route = inject(ActivatedRoute);
    orders = signal<Order[]>([]);

    ngOnInit(): void {
        // takeUntilDestroyed se désabonne automatiquement quand DestroyRef émet
        // Fonctionne correctement même avec RouteReuseStrategy
        this.ordersService.getOrders()
            .pipe(takeUntilDestroyed(this.destroyRef))
            .subscribe(data => this.orders.set(data));
    }

    ngOnDestroy(): void {
        // Appelé quand le handle est vraiment détruit (pas lors du détachement)
        console.log('OrdersComponent définitivement détruit');
    }
}

Tests unitaires et checklist de production

Tester une RouteReuseStrategy est straightforward : c'est une classe pure avec des méthodes qui reçoivent des snapshots et retournent des booleans ou des handles. Pas besoin de TestBed pour les tests unitaires des méthodes de décision.

// custom-route-reuse.strategy.spec.ts
import { CustomRouteReuseStrategy } from './custom-route-reuse.strategy';
import { ActivatedRouteSnapshot, Route } from '@angular/router';

describe('CustomRouteReuseStrategy', () => {
    let strategy: CustomRouteReuseStrategy;

    // Fabrique un ActivatedRouteSnapshot minimal pour les tests
    function makeRoute(config: Partial<Route> = {}, data: Record<string, unknown> = {}): ActivatedRouteSnapshot {
        const snapshot = new ActivatedRouteSnapshot();
        (snapshot as any).routeConfig = config;
        (snapshot as any).data = data;
        (snapshot as any).pathFromRoot = [snapshot];
        (snapshot as any).params = {};
        (snapshot as any).queryParams = {};
        return snapshot;
    }

    beforeEach(() => {
        strategy = new CustomRouteReuseStrategy();
    });

    describe('shouldDetach', () => {
        it('retourne true pour une route avec reuse:true', () => {
            const route = makeRoute({ path: 'orders' }, { reuse: true });
            expect(strategy.shouldDetach(route)).toBe(true);
        });

        it('retourne false sans flag reuse', () => {
            const route = makeRoute({ path: 'orders' });
            expect(strategy.shouldDetach(route)).toBe(false);
        });
    });

    describe('store et shouldAttach', () => {
        it('shouldAttach retourne false pour une route non mise en cache', () => {
            const route = makeRoute({ path: 'orders' }, { reuse: true });
            expect(strategy.shouldAttach(route)).toBe(false); // Cache vide
        });

        it('shouldAttach retourne true après store', () => {
            const routeConfig: Route = { path: 'orders' };
            const route = makeRoute(routeConfig, { reuse: true });
            const fakeHandle = {} as any; // Handle fictif

            // Simuler le cycle store → shouldAttach
            strategy.store(route, fakeHandle);
            expect(strategy.shouldAttach(route)).toBe(true);
        });

        it('retrieve retourne le handle stocké', () => {
            const routeConfig: Route = { path: 'orders' };
            const route = makeRoute(routeConfig, { reuse: true });
            const fakeHandle = { key: 'test' } as any;

            strategy.store(route, fakeHandle);
            expect(strategy.retrieve(route)).toBe(fakeHandle);
        });

        it('store avec null supprime l\'entrée du cache', () => {
            const routeConfig: Route = { path: 'orders' };
            const route = makeRoute(routeConfig, { reuse: true });
            const fakeHandle = {} as any;

            strategy.store(route, fakeHandle);
            strategy.store(route, null); // Nettoyage
            expect(strategy.shouldAttach(route)).toBe(false);
        });
    });

    describe('shouldReuseRoute', () => {
        it('retourne true pour la même routeConfig', () => {
            const config: Route = { path: 'orders/:id' };
            const future = makeRoute(config);
            const curr = makeRoute(config);
            expect(strategy.shouldReuseRoute(future, curr)).toBe(true);
        });

        it('retourne false pour des configs différentes', () => {
            const future = makeRoute({ path: 'orders' });
            const curr = makeRoute({ path: 'clients' });
            expect(strategy.shouldReuseRoute(future, curr)).toBe(false);
        });
    });
});

Test d'intégration avec le Router

// Test d'intégration — vérifie que le composant n'est pas recréé
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router, RouteReuseStrategy } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';

describe('RouteReuseStrategy — intégration', () => {
    it('ne recrée pas le composant après navigation retour', async () => {
        await TestBed.configureTestingModule({
            providers: [
                provideRouter(routes),
                { provide: RouteReuseStrategy, useClass: CustomRouteReuseStrategy },
            ],
        }).compileComponents();

        const harness = await RouterTestingHarness.create('/orders');
        const ordersComp = harness.routeNativeElement;
        const spy = jest.spyOn(console, 'log');

        // Naviguer ailleurs
        await harness.navigateByUrl('/clients');
        // Revenir sur orders
        await harness.navigateByUrl('/orders');

        // ngOnInit ne doit pas être rappelé (composant réattaché depuis le cache)
        expect(spy).not.toHaveBeenCalledWith(expect.stringContaining('ngOnInit'));
    });
});

Checklist avant mise en production

  • Stratégie enregistrée dans appConfig ou AppModule providers
  • Routes à mettre en cache marquées avec data: { reuse: true }
  • Cohérence entre shouldAttach et retrieve : même condition de vérification
  • Taille du cache limitée (LRU ou TTL) pour éviter les fuites mémoire
  • ngOnDestroy propre dans tous les composants cachés (takeUntilDestroyed pour RxJS)
  • Données temps réel gérées via ActivatedRoute.params (pas uniquement dans ngOnInit)
  • Tests unitaires des 5 méthodes avec cas positifs et négatifs
  • Test d'intégration vérifiant que ngOnInit n'est pas rappelé à la réattachement
  • Profil mémoire testé dans Chrome DevTools (heap snapshot avant/après navigation)
  • Comportement validé en mode production (ng build --configuration production)
Recommandation finale : Commencez avec la stratégie la plus simple (flag reuse: true + routeConfig comme clé). Ajoutez la complexité — clés URL-based, TTL, LRU — uniquement si les cas d'usage l'exigent. Une stratégie over-engineered est plus difficile à déboguer qu'un re-render de 200ms.

Partager