From 95c01e9cdea251e09d321a01e9b982b2fe1b7873 Mon Sep 17 00:00:00 2001 From: arturovt Date: Sun, 28 Jun 2026 17:02:59 +0300 Subject: [PATCH] fix(router): keep detached route subtree contexts isolated and intact When a route is detached for `RouteReuseStrategy`, its component and child `RouterOutlet`s stay alive and keep referencing the `ChildrenOutletContexts` they were created with. `detachAndStoreRouteSubtree` used to call `onOutletDeactivated()`, which swaps that object's Map for an empty one, so after re-attaching, the inner outlet and the router read from two different context trees and deeper child routes (e.g. an `edit` route under a reused list) never rendered. Now, on detach: take the child contexts map as-is (destruction still calls `onOutletDeactivated()` to prune) and give the `OutletContext` a fresh `ChildrenOutletContexts`. This keeps the detached component rendering from its stored map once re-attached, and prevents whatever activates next in the same parent outlet (e.g. a sibling tab) from mutating or wiping that stored map. Fixes #57285 (cherry picked from commit 8227e5cf6d157d6e472f963da68a804813ad26ed) --- .../router/src/operators/activate_routes.ts | 6 +- packages/router/src/router_outlet_context.ts | 15 +- .../integration/route_reuse_strategy.spec.ts | 216 ++++++++++++++++++ 3 files changed, 234 insertions(+), 3 deletions(-) diff --git a/packages/router/src/operators/activate_routes.ts b/packages/router/src/operators/activate_routes.ts index 7f0569d3bfc..2e7018b2307 100644 --- a/packages/router/src/operators/activate_routes.ts +++ b/packages/router/src/operators/activate_routes.ts @@ -107,7 +107,11 @@ export class ActivateRoutes { if (context && context.outlet) { const componentRef = context.outlet.detach(); - const contexts = context.children.onOutletDeactivated(); + // Reset child contexts so subsequent activations in this outlet do not mutate the detached + // tree (see #57285). The detached outlets are still alive, so `onOutletDeactivated()` (which + // the destruction path uses to prune) is intentionally not called here. + const contexts = context.children.contexts; + context.resetChildren(); this.routeReuseStrategy.store(route.value.snapshot, {componentRef, route, contexts}); } } diff --git a/packages/router/src/router_outlet_context.ts b/packages/router/src/router_outlet_context.ts index 64b1ead8293..0eb12f713b6 100644 --- a/packages/router/src/router_outlet_context.ts +++ b/packages/router/src/router_outlet_context.ts @@ -28,6 +28,14 @@ export class OutletContext { constructor(private readonly rootInjector: EnvironmentInjector) { this.children = new ChildrenOutletContexts(this.rootInjector); } + + /** + * Replaces `children` with a fresh instance, keeping the same root injector. + * @internal + */ + resetChildren(): void { + this.children = new ChildrenOutletContexts(this.rootInjector); + } } /** @@ -37,8 +45,11 @@ export class OutletContext { */ @Injectable({providedIn: 'root'}) export class ChildrenOutletContexts { - // contexts for child outlets, by name. - private contexts = new Map(); + /** + * Contexts for child outlets, keyed by outlet name. + * @internal + */ + contexts = new Map(); /** @docs-private */ constructor(private rootInjector: EnvironmentInjector) {} diff --git a/packages/router/test/integration/route_reuse_strategy.spec.ts b/packages/router/test/integration/route_reuse_strategy.spec.ts index b781341ba57..178ed107226 100644 --- a/packages/router/test/integration/route_reuse_strategy.spec.ts +++ b/packages/router/test/integration/route_reuse_strategy.spec.ts @@ -433,6 +433,222 @@ export function routeReuseIntegrationSuite() { expect(createdComps).toEqual(['parent', 'child', 'child']); }); + it('should render child routes on reused list when outer shell is destroyed and recreated', async () => { + // https://github.com/angular/angular/issues/57285 + + @Component({selector: 'root', template: '', standalone: false}) + class Root {} + + @Component({ + selector: 'events-shell-cmp', + template: '', + standalone: false, + }) + class EventsShellCmp {} + + @Component({ + selector: 'event-list-cmp', + template: '', + standalone: false, + }) + class EventListCmp {} + + @Component({ + selector: 'event-detail-cmp', + template: '', + standalone: false, + }) + class EventDetailCmp {} + + @Component({selector: 'event-edit-cmp', template: 'edit', standalone: false}) + class EventEditCmp {} + + @Component({selector: 'chats-cmp', template: 'chats', standalone: false}) + class ChatsCmp {} + + class ReusableStrategy implements RouteReuseStrategy { + private handles = new Map(); + + shouldDetach(route: ActivatedRouteSnapshot): boolean { + return !!route.routeConfig?.data?.['reusable']; + } + store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null): void { + if (route.routeConfig && handle) { + this.handles.set(route.routeConfig, handle); + } else if (route.routeConfig) { + this.handles.delete(route.routeConfig); + } + } + shouldAttach(route: ActivatedRouteSnapshot): boolean { + return !!(route.routeConfig && this.handles.has(route.routeConfig)); + } + retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null { + return (route.routeConfig && this.handles.get(route.routeConfig)) ?? null; + } + shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean { + return future.routeConfig === curr.routeConfig; + } + } + + @NgModule({ + declarations: [Root, EventsShellCmp, EventListCmp, EventDetailCmp, EventEditCmp, ChatsCmp], + imports: [...ROUTER_DIRECTIVES], + providers: [ + {provide: RouteReuseStrategy, useClass: ReusableStrategy}, + provideRouter([ + { + path: 'events', + component: EventsShellCmp, + children: [ + { + path: '', + data: {reusable: true}, + component: EventListCmp, + children: [ + { + path: ':id', + data: {reusable: true}, + component: EventDetailCmp, + children: [{path: 'edit', component: EventEditCmp}], + }, + ], + }, + ], + }, + {path: 'chats', component: ChatsCmp}, + ]), + ], + }) + class TestModule {} + + TestBed.configureTestingModule({imports: [TestModule]}); + const router = TestBed.inject(Router); + const fixture = await createRoot(router, Root); + + router.navigateByUrl('/chats'); + await advance(fixture); + + router.navigateByUrl('/events'); + await advance(fixture); + expect(fixture.debugElement.query(By.directive(EventListCmp))).toBeTruthy(); + + router.navigateByUrl('/chats'); + await advance(fixture); + + // Shell is recreated and the stored list is re-attached into it. + router.navigateByUrl('/events'); + await advance(fixture); + expect(fixture.debugElement.query(By.directive(EventListCmp))).toBeTruthy(); + + router.navigateByUrl('/events/1/edit'); + await advance(fixture); + expect(fixture.debugElement.query(By.directive(EventEditCmp))).toBeTruthy(); + }); + + it('should render child routes of a reused tab after a sibling tab was shown in the same outlet', async () => { + // https://github.com/angular/angular/issues/57285 (sibling outlet case) + + @Component({selector: 'root', template: '', standalone: false}) + class Root {} + + @Component({ + selector: 'tabs-shell-cmp', + template: '', + standalone: false, + }) + class TabsShellCmp {} + + @Component({ + selector: 'tab1-cmp', + template: '', + standalone: false, + }) + class Tab1Cmp {} + + @Component({selector: 'tab1-inner-cmp', template: 'tab1-inner', standalone: false}) + class Tab1InnerCmp {} + + @Component({ + selector: 'tab2-cmp', + template: '', + standalone: false, + }) + class Tab2Cmp {} + + @Component({selector: 'tab2-inner-cmp', template: 'tab2-inner', standalone: false}) + class Tab2InnerCmp {} + + class ReusableStrategy implements RouteReuseStrategy { + private handles = new Map(); + + shouldDetach(route: ActivatedRouteSnapshot): boolean { + return !!route.routeConfig?.data?.['reusable']; + } + store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null): void { + if (route.routeConfig && handle) { + this.handles.set(route.routeConfig, handle); + } else if (route.routeConfig) { + this.handles.delete(route.routeConfig); + } + } + shouldAttach(route: ActivatedRouteSnapshot): boolean { + return !!(route.routeConfig && this.handles.has(route.routeConfig)); + } + retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null { + return (route.routeConfig && this.handles.get(route.routeConfig)) ?? null; + } + shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean { + return future.routeConfig === curr.routeConfig; + } + } + + @NgModule({ + declarations: [Root, TabsShellCmp, Tab1Cmp, Tab1InnerCmp, Tab2Cmp, Tab2InnerCmp], + imports: [...ROUTER_DIRECTIVES], + providers: [ + {provide: RouteReuseStrategy, useClass: ReusableStrategy}, + provideRouter([ + { + path: 'tabs', + component: TabsShellCmp, + children: [ + { + path: 'tab1', + data: {reusable: true}, + component: Tab1Cmp, + children: [{path: 'inner', component: Tab1InnerCmp}], + }, + { + path: 'tab2', + component: Tab2Cmp, + children: [{path: 'inner', component: Tab2InnerCmp}], + }, + ], + }, + ]), + ], + }) + class TestModule {} + TestBed.configureTestingModule({imports: [TestModule]}); + + const router = TestBed.inject(Router); + const fixture = await createRoot(router, Root); + + router.navigateByUrl('/tabs/tab1/inner'); + await advance(fixture); + expect(fixture.debugElement.query(By.directive(Tab1InnerCmp))).toBeTruthy(); + + router.navigateByUrl('/tabs/tab2/inner'); + await advance(fixture); + expect(fixture.debugElement.query(By.directive(Tab2InnerCmp))).toBeTruthy(); + expect(fixture.debugElement.query(By.directive(Tab1InnerCmp))).toBeNull(); + + router.navigateByUrl('/tabs/tab1/inner'); + await advance(fixture); + expect(router.url).toEqual('/tabs/tab1/inner'); + expect(fixture.debugElement.query(By.directive(Tab1InnerCmp))).toBeTruthy(); + }); + it('should not try to detach the outlet of a route that does not get to attach a component', async () => { @Component({ selector: 'root',