Skip to content

RouteReuseStrategy: reattached child keeps providers from a destroyed parent injector #71007

Description

@MillerSvt

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:

  1. Child #1 is detached and stored.
  2. Parent #1 is destroyed.

When navigating back:

  1. Parent #2 is created.
  2. A new ParentScopedService #2 is created for Parent #2.
  3. The stored Child #1 is reattached below Parent #2.

The resulting component tree is therefore:

Parent #2
└── Child #1

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P5The team acknowledges the request but does not plan to address it, it remains open for discussionarea: docsRelated to the documentationarea: routergemini-triagedLabel noting that an issue has been triaged by gemini

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions