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 8227e5cf6d)
This commit is contained in:
arturovt
2026-06-28 17:02:59 +03:00
committed by Kristiyan Kostadinov
parent ef48630a14
commit 95c01e9cde
3 changed files with 234 additions and 3 deletions
@@ -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});
}
}
+13 -2
View File
@@ -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<string, OutletContext>();
/**
* Contexts for child outlets, keyed by outlet name.
* @internal
*/
contexts = new Map<string, OutletContext>();
/** @docs-private */
constructor(private rootInjector: EnvironmentInjector) {}
@@ -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: '<router-outlet></router-outlet>', standalone: false})
class Root {}
@Component({
selector: 'events-shell-cmp',
template: '<router-outlet></router-outlet>',
standalone: false,
})
class EventsShellCmp {}
@Component({
selector: 'event-list-cmp',
template: '<router-outlet></router-outlet>',
standalone: false,
})
class EventListCmp {}
@Component({
selector: 'event-detail-cmp',
template: '<router-outlet></router-outlet>',
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<object, DetachedRouteHandle>();
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: '<router-outlet></router-outlet>', standalone: false})
class Root {}
@Component({
selector: 'tabs-shell-cmp',
template: '<router-outlet></router-outlet>',
standalone: false,
})
class TabsShellCmp {}
@Component({
selector: 'tab1-cmp',
template: '<router-outlet></router-outlet>',
standalone: false,
})
class Tab1Cmp {}
@Component({selector: 'tab1-inner-cmp', template: 'tab1-inner', standalone: false})
class Tab1InnerCmp {}
@Component({
selector: 'tab2-cmp',
template: '<router-outlet></router-outlet>',
standalone: false,
})
class Tab2Cmp {}
@Component({selector: 'tab2-inner-cmp', template: 'tab2-inner', standalone: false})
class Tab2InnerCmp {}
class ReusableStrategy implements RouteReuseStrategy {
private handles = new Map<object, DetachedRouteHandle>();
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',