Which @angular/* package(s) are the source of the bug?
router
Is this a regression?
No
Description
Description
When a child route is detached and stored by a custom RouteReuseStrategy, while its component-bearing parent route is destroyed, reattaching the stored child under a newly-created parent leaves the child connected to the dependency-injection hierarchy of the previous parent instance.
Consider a parent component that provides a service:
@Component({
providers: [ParentScopedService],
template: '<router-outlet />',
})
class Parent {
readonly service = inject(ParentScopedService);
}
and a routed child that injects the same service:
@Component({...})
class Child {
readonly service = inject(ParentScopedService);
}
The reuse strategy intentionally behaves as follows:
parent: shouldDetach() === false
child: shouldDetach() === true
Navigation:
/parent/child
→ /home
→ /parent/child
On the first navigation away:
Child #1 is detached and stored.
Parent #1 is destroyed.
When navigating back:
Parent #2 is created.
- A new
ParentScopedService #2 is created for Parent #2.
- The stored
Child #1 is reattached below Parent #2.
The resulting component tree is therefore:
However, the DI state is:
Parent #2 → ParentScopedService #2
Child #1 → ParentScopedService #1
The reused child still uses the service instance belonging to the destroyed previous parent instead of the service provided by its current parent.
In other words:
secondParent.service !== firstParent.service;
// Child was successfully reused:
secondChild === firstChild;
// But the reused child still has the old parent's service:
secondChild.service === firstParent.service;
// Expected:
secondChild.service === secondParent.service;
This creates an inconsistent state where a routed parent and its current routed child operate on different instances of a service scoped to that parent.
It also means that the old parent injector hierarchy remains reachable through the reused child for as long as the child is retained.
Looking at the router implementation, this appears to happen because the component's injector hierarchy is established when RouterOutlet.activateWith() creates the component, while RouterOutlet.attach() later only inserts the existing hostView. Reattaching the view does not appear to update the injector ancestry of the existing component.
If reattaching a stored child underneath a recreated component-bearing ancestor is intentionally unsupported, it would be useful for the router to reject or explicitly document this combination. Currently, RouteReuseStrategy allows this configuration and the reattachment succeeds, but leaves the component with stale parent-scoped dependencies.
This appears closely related to #20072.
That issue demonstrated the same observable behavior: a reused child continued to use a service instance belonging to a previous parent after the parent had been destroyed and recreated.
#20072 was considered possibly related to #18374, but the scenarios are different:
There is also a potential memory-retention issue here. Because the reused child continues to reference the injector hierarchy of the destroyed parent, objects reachable from that hierarchy — including parent-scoped services and potentially parts of the destroyed parent view tree — may remain strongly reachable for as long as the reused child is alive. In applications that keep detached route handles for a long time, this can effectively behave as a memory leak: destroying and recreating parent routes does not necessarily make the previous parent injector graph eligible for garbage collection.
Please provide a link to a minimal reproduction of the bug
https://bolt.new/p/71539648
Please provide the exception or error you saw
AssertionError: expected { Object (id) } to be { Object (id) } // Object.is equality
- Expected
+ Received
ParentScopedService {
- "id": 0.5120725349564125,
+ "id": 0.5155806087407758,
}
❯ src/route-reuse.spec.ts:146:61
144| // FAILS:
145| // The reused child still resolves the service from the destroyed parent.
146| expect(reattachedChild.injector.get(ParentScopedService)).toBe(secondParent.injector.get(ParentScopedService));
| ^
147| });
148|
Please provide the environment you discovered this bug in (run ng version)
Anything else?
Related issues:
#20072 seems particularly relevant because it reproduces the same stale parent-scoped service behavior.
A likely workaround is to detach/reuse the highest component-bearing ancestor together with its subtree, rather than retaining a child while destroying one of its DI ancestors. Another workaround is to recreate the child and preserve its application state separately instead of retaining the child ComponentRef.
There does not appear to be a public API for updating the injector ancestry of an already-created component after it has been attached to a new routed parent.
Which @angular/* package(s) are the source of the bug?
router
Is this a regression?
No
Description
Description
When a child route is detached and stored by a custom
RouteReuseStrategy, while its component-bearing parent route is destroyed, reattaching the stored child under a newly-created parent leaves the child connected to the dependency-injection hierarchy of the previous parent instance.Consider a parent component that provides a service:
and a routed child that injects the same service:
The reuse strategy intentionally behaves as follows:
Navigation:
On the first navigation away:
Child #1is detached and stored.Parent #1is destroyed.When navigating back:
Parent #2is created.ParentScopedService #2is created forParent #2.Child #1is reattached belowParent #2.The resulting component tree is therefore:
However, the DI state is:
The reused child still uses the service instance belonging to the destroyed previous parent instead of the service provided by its current parent.
In other words:
This creates an inconsistent state where a routed parent and its current routed child operate on different instances of a service scoped to that parent.
It also means that the old parent injector hierarchy remains reachable through the reused child for as long as the child is retained.
Looking at the router implementation, this appears to happen because the component's injector hierarchy is established when
RouterOutlet.activateWith()creates the component, whileRouterOutlet.attach()later only inserts the existinghostView. Reattaching the view does not appear to update the injector ancestry of the existing component.If reattaching a stored child underneath a recreated component-bearing ancestor is intentionally unsupported, it would be useful for the router to reject or explicitly document this combination. Currently,
RouteReuseStrategyallows this configuration and the reattachment succeeds, but leaves the component with stale parent-scoped dependencies.This appears closely related to #20072.
That issue demonstrated the same observable behavior: a reused child continued to use a service instance belonging to a previous parent after the parent had been destroyed and recreated.
#20072 was considered possibly related to #18374, but the scenarios are different:
RouteReuseStrategyhas explicitly detached and successfully reattached a child while its parent was destroyed and recreated.There is also a potential memory-retention issue here. Because the reused child continues to reference the injector hierarchy of the destroyed parent, objects reachable from that hierarchy — including parent-scoped services and potentially parts of the destroyed parent view tree — may remain strongly reachable for as long as the reused child is alive. In applications that keep detached route handles for a long time, this can effectively behave as a memory leak: destroying and recreating parent routes does not necessarily make the previous parent injector graph eligible for garbage collection.
Please provide a link to a minimal reproduction of the bug
https://bolt.new/p/71539648
Please provide the exception or error you saw
Please provide the environment you discovered this bug in (run
ng version)Anything else?
Related issues:
RouteReuseStrategy doesn't reuse the parent tree componentsRouter not reusing parent component when changing only the child route#20072 seems particularly relevant because it reproduces the same stale parent-scoped service behavior.
A likely workaround is to detach/reuse the highest component-bearing ancestor together with its subtree, rather than retaining a child while destroying one of its DI ancestors. Another workaround is to recreate the child and preserve its application state separately instead of retaining the child
ComponentRef.There does not appear to be a public API for updating the injector ancestry of an already-created component after it has been attached to a new routed parent.