Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions adev/src/content/ai/webmcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,15 @@ export const routes: Routes = [
];
```

NOTE: When registering tools to a particular route, consider configuring the router to use [`withExperimentalAutoCleanupInjectors`](api/router/withExperimentalAutoCleanupInjectors) to ensure tools are automatically _unregistered_ when the user navigates away from the route. Without this option, WebMCP tools declared on routes will remain accessible to AI agents even after the user has navigated to a different route.
NOTE: When registering tools to a particular route, consider configuring the router to use [`withAutoCleanupInjectors`](api/router/withAutoCleanupInjectors) to ensure tools are automatically _unregistered_ when the user navigates away from the route. Without this option, WebMCP tools declared on routes will remain accessible to AI agents even after the user has navigated to a different route.

```ts {header:"app.config.ts"}
import {ApplicationConfig} from '@angular/core';
import {provideRouter, withExperimentalAutoCleanupInjectors} from '@angular/router';
import {provideRouter, withAutoCleanupInjectors} from '@angular/router';
import {routes} from './routes';

export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes, withExperimentalAutoCleanupInjectors())],
providers: [provideRouter(routes, withAutoCleanupInjectors())],
};
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export class EagerView {

Lazy-loaded routes create child injectors that are only available after the route loads.

NOTE: By default, route injectors and their services persist even after navigating away from the route. They are not destroyed until the application is closed. For automatic cleanup of unused route injectors, see [customizing route behavior](guide/routing/customizing-route-behavior#experimental-automatic-cleanup-of-unused-route-injectors).
NOTE: By default, route injectors and their services persist even after navigating away from the route. They are not destroyed until the application is closed. For automatic cleanup of unused route injectors, see [customizing route behavior](guide/routing/customizing-route-behavior#automatic-cleanup-of-unused-route-injectors).

**Solution:** Use `@Service` for services that need to be shared across lazy boundaries.

Expand Down
10 changes: 5 additions & 5 deletions adev/src/content/guide/routing/customizing-route-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,17 +261,17 @@ if (this.handles.size > MAX_CACHE_SIZE) {

NOTE: Avoid using the route path as the key when `canMatch` guards are involved, as it may lead to duplicate entries.

### (Experimental) Automatic cleanup of unused route injectors
### Automatic cleanup of unused route injectors

By default, Angular does not destroy the injectors of detached routes, even if they are no longer stored by the `RouteReuseStrategy`. This is primarily because this level of memory management is not commonly needed for most applications.

To enable automatic cleanup of unused route injectors, you can use the `withExperimentalAutoCleanupInjectors` feature in your router configuration. This feature checks which routes are currently stored by the strategy after navigations and destroys the injectors of any detached routes that are not currently stored by the reuse strategy.
To enable automatic cleanup of unused route injectors, you can use the `withAutoCleanupInjectors` feature in your router configuration. This feature checks which routes are currently stored by the strategy after navigations and destroys the injectors of any detached routes that are not currently stored by the reuse strategy.

```ts
import {provideRouter, withExperimentalAutoCleanupInjectors} from '@angular/router';
import {provideRouter, withAutoCleanupInjectors} from '@angular/router';

export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes, withExperimentalAutoCleanupInjectors())],
providers: [provideRouter(routes, withAutoCleanupInjectors())],
};
```

Expand Down Expand Up @@ -303,7 +303,7 @@ export class CustomRouteReuseStrategy implements RouteReuseStrategy {
this.handles.set(route.routeConfig!, handle);
}

retrieveStoredRouteHandles(): DetachedRouteHandle {
retrieveStoredRouteHandles(): DetachedRouteHandle[] {
return Array.from(this.handles.values());
}

Expand Down
14 changes: 11 additions & 3 deletions goldens/public-api/router/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ export class ActivationStart {
readonly type = EventType.ActivationStart;
}

// @public
export type AutoCleanupInjectorsFeature = RouterFeature<RouterFeatureKind.AutoCleanupInjectorsFeature>;

// @public
export abstract class BaseRouteReuseStrategy implements RouteReuseStrategy {
retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null;
Expand Down Expand Up @@ -766,7 +769,9 @@ export type RouterConfigurationFeature = RouterFeature<RouterFeatureKind.RouterC
// @public
export abstract class RouteReuseStrategy {
abstract retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null;
retrieveStoredRouteHandles?(): Array<DetachedRouteHandle>;
abstract shouldAttach(route: ActivatedRouteSnapshot): boolean;
shouldDestroyInjector?(route: Route): boolean;
abstract shouldDetach(route: ActivatedRouteSnapshot): boolean;
abstract shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean;
abstract store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null): void;
Expand Down Expand Up @@ -794,7 +799,7 @@ export interface RouterFeature<FeatureKind extends RouterFeatureKind> {
}

// @public
export type RouterFeatures = PreloadingFeature | DebugTracingFeature | InitialNavigationFeature | InMemoryScrollingFeature | RouterConfigurationFeature | NavigationErrorHandlerFeature | ComponentInputBindingFeature | ViewTransitionsFeature | ExperimentalAutoCleanupInjectorsFeature | RouterHashLocationFeature | ExperimentalPlatformNavigationFeature;
export type RouterFeatures = PreloadingFeature | DebugTracingFeature | InitialNavigationFeature | InMemoryScrollingFeature | RouterConfigurationFeature | NavigationErrorHandlerFeature | ComponentInputBindingFeature | ViewTransitionsFeature | AutoCleanupInjectorsFeature | RouterHashLocationFeature | ExperimentalPlatformNavigationFeature;

// @public
export type RouterHashLocationFeature = RouterFeature<RouterFeatureKind.RouterHashLocationFeature>;
Expand Down Expand Up @@ -1139,6 +1144,9 @@ export interface ViewTransitionsFeatureOptions {
skipInitialTransition?: boolean;
}

// @public
export function withAutoCleanupInjectors(): AutoCleanupInjectorsFeature;

// @public
export function withComponentInputBinding(options?: ComponentInputBindingOptions): ComponentInputBindingFeature;

Expand All @@ -1151,8 +1159,8 @@ export function withDisabledInitialNavigation(): DisabledInitialNavigationFeatur
// @public
export function withEnabledBlockingInitialNavigation(): EnabledBlockingInitialNavigationFeature;

// @public
export function withExperimentalAutoCleanupInjectors(): ExperimentalAutoCleanupInjectorsFeature;
// @public @deprecated
export function withExperimentalAutoCleanupInjectors(): AutoCleanupInjectorsFeature;

// @public
export function withExperimentalPlatformNavigation(): ExperimentalPlatformNavigationFeature;
Expand Down
4 changes: 2 additions & 2 deletions packages/core/test/webmcp/provide_tools_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import {initializeWebMCPPolyfill, cleanupWebMCPPolyfill} from '@mcp-b/webmcp-polyfill';
import type {JsonSchemaForInference} from '../../third_party/@mcp-b/webmcp-types';
import {Component, createEnvironmentInjector, EnvironmentInjector} from '../../src/core';
import {provideRouter, Router, withExperimentalAutoCleanupInjectors} from '@angular/router';
import {provideRouter, Router, withAutoCleanupInjectors} from '@angular/router';
import {provideExperimentalWebMcpTools} from '../../src/webmcp/provide_tools';
import {Execute} from '../../src/webmcp/types';
import {TestBed} from '../../testing';
Expand Down Expand Up @@ -103,7 +103,7 @@ describe('provideExperimentalWebMcpTools', () => {
],
},
],
withExperimentalAutoCleanupInjectors(),
withAutoCleanupInjectors(),
),
],
});
Expand Down
38 changes: 32 additions & 6 deletions packages/router/docs/injector_cleanup.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,39 @@ The current parent-first (pre-order) traversal was chosen for its implementation

### Destruction During Activation Traversal

Another alternative considered was performing the destruction of injectors during the activation traversal itself. For instance, if a route is being deactivated and not stored for reuse, its injector could be destroyed immediately.
Another alternative considered was performing the destruction of injectors during route deactivation in `ActivateRoutes` (similar to how components and `_localInjector` are destroyed).

- **Pros**: This approach would not require any additional traversal logic, as the router is already traversing the route tree during activation.
- **Cons**:
- This approach is not viable because of how the router handles route reuse. A child route can be stored for reuse even if its parent is not. Since the activation traversal is top-down, the router might encounter a parent route that is not being reused and destroy its injector, only to later discover that one of its children _is_ being reused and requires the parent's injector to remain alive. By deferring cleanup until after navigation completes, we can accurately identify all necessary injectors by looking at the final state of both the active route tree and the stored handles.
- We could handle the problem of descendants detaching after the parent injector was destroyed by _not_ querying `shouldDetach` in the first place on any descendants if the a parent injector was destroyed. However, this couples the detach logic with the injector destroy. You now need to be careful about destroying parents if you're going to detach a child. Our current approach handles this for you so the concerns are kept separate and the Router handles the question of what even can be destroyed safely.
- Additionally, there would be no good, safe way to destroy the injector for a previously stored handle that is being disposed of. For example, if a developer decides to drop a stored handle, they would need to manually destroy its injector, which could easily be done at the wrong time or forgotten entirely.
- **Pros**: Would eliminate the need for an additional post-navigation traversal pass.
- **Cons**: This approach is not viable for injectors attached to static `Route` configurations:
1. **Re-activation of the Same Route Config**: When navigating between parameter changes where the route is not reused (e.g., `/detail/1` to `/detail/2` with `shouldReuseRoute` returning `false`), `/detail/2` binds to `detailRoute._injector` during recognition. If deactivating `/detail/1` destroyed `detailRoute._injector`, it would destroy the injector that `/detail/2` has already captured and is about to activate with.
2. **Cross-Navigation Detached Handles**: While deactivation within a single transition is bottom-up (children are deactivated before parents), a child route could have been detached and stored in `RouteReuseStrategy` in an earlier navigation. If the parent route is deactivated in a subsequent navigation, destroying the parent's injector would break the DI hierarchy of the stored child handle. A local deactivation pass cannot detect this dependency without inspecting the entire reuse cache.
3. **Handling Discarded Handles and Preloading**: Injectors created during route preloading, or injectors left behind when a handle is evicted from `RouteReuseStrategy`, never undergo deactivation. A post-navigation sweep is the only mechanism that can reconcile these unreferenced injectors.

### Fundamental Differences from `ActivatedRoute._localInjector`

With the introduction of route-level resources, the router created a local `EnvironmentInjector` on `ActivatedRoute` instances (`route._localInjector`). A natural question is why `Route._injector` (and `Route._loadedInjector`) cannot share the exact same destruction timing and lifecycle as `ActivatedRoute._localInjector`:

- `_localInjector` is destroyed immediately during deactivation in `ActivateRoutes` (`deactivateRouteAndOutlet`).
- `_localInjector` is rolled back on `NavigationCancel` or `NavigationError` in `rollbackState`.
- `_localInjector` is destroyed on handle eviction via `destroyDetachedRouteHandle(handle)`.

The reasons these two injector types cannot share the same destruction logic are fundamental to where they live and their role in the DI hierarchy:

1. **Storage Target & Cardinality (Static Definition vs. Activation Instance)**:
- `Route._injector` is attached directly to the **static `Route` definition**. When navigating between parameter changes of the same route config where the route is not reused (e.g., `/detail/1` to `/detail/2` with `shouldReuseRoute` returning `false`), both activations share the same static `Route` definition. During URL recognition, the incoming `/detail/2` snapshot captures the existing `detailRoute._injector`. If deactivation of `/detail/1` destroyed `detailRoute._injector`, it would destroy the injector that `/detail/2` has already captured and is about to activate its component with.
- `_localInjector` is an instance property on `ActivatedRoute`. `/detail/1` and `/detail/2` have completely distinct `ActivatedRoute` instances (`activatedRoute1._localInjector` vs. `activatedRoute2._localInjector`). Destroying instance 1 during deactivation has no impact on instance 2.

2. **DI Hierarchy Position (Ancestor vs. Leaf)**:
- `Route._injector` is an **ancestor injector** for all child routes and their component hierarchies. If a child route was detached and stored in `RouteReuseStrategy` in an earlier navigation, destroying a deactivated parent's `Route._injector` severs the DI hierarchy for that stored child handle. When the child is later reattached, any dependency lookups ascending to the parent injector will fail. A local deactivation step cannot detect this dependency without inspecting the entire reuse cache.
- `_localInjector` is strictly a **leaf injector** containing only route-level resources for that specific route. Descendant routes do not inherit from a parent route's `_localInjector` (they inherit from `parentSnapshot._environmentInjector`, which points to `Route._injector`). Therefore, destroying a deactivated parent's `_localInjector` never corrupts a child's DI chain.

3. **Creation Timing & Non-Activation Lifecycles**:
- `Route._injector` is created during URL recognition (`canMatch`) or preloading (`RouterPreloader`), well before activation. A preloaded route may never be activated, so it can never be deactivated; only a post-navigation sweep can reclaim it.
- `_localInjector` is created strictly during preactivation (`setupAndRunResources`) for a specific `ActivatedRoute`, so its lifecycle is 1:1 with that route's activation and deactivation.

4. **Detached Handle Disposal**:
- `destroyDetachedRouteHandle(handle)` can safely destroy `handle.route.value._localInjector` because that injector belongs exclusively to that handle's `ActivatedRoute` instance.
- It cannot destroy `handle.route.value.routeConfig._injector`, because that static `Route` config might currently be active in another outlet or shared by another cached handle.

### 3.2. Manual DetachedRouteHandle Cleanup

Expand Down
2 changes: 2 additions & 0 deletions packages/router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export * from './models_deprecated';
export {Navigation, NavigationExtras, UrlCreationOptions} from './navigation_transition';
export {DefaultTitleStrategy, TitleStrategy} from './page_title_strategy';
export {
AutoCleanupInjectorsFeature,
ComponentInputBindingFeature,
DebugTracingFeature,
DisabledInitialNavigationFeature,
Expand All @@ -91,6 +92,7 @@ export {
withDisabledInitialNavigation,
withEnabledBlockingInitialNavigation,
withExperimentalAutoCleanupInjectors,
withAutoCleanupInjectors,
withExperimentalPlatformNavigation,
withHashLocation,
withInMemoryScrolling,
Expand Down
31 changes: 21 additions & 10 deletions packages/router/src/provide_router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -732,15 +732,15 @@ export function withNavigationErrorHandler(
}

/**
* A type alias for providers returned by `withExperimentalAutoCleanupInjectors` for use with `provideRouter`.
* A type alias for providers returned by `withAutoCleanupInjectors` for use with `provideRouter`.
*
* @see {@link withExperimentalAutoCleanupInjectors}
* @see {@link withAutoCleanupInjectors}
* @see {@link provideRouter}
*
* @experimental 21.1
* @publicApi 22.2
*/
export type ExperimentalAutoCleanupInjectorsFeature =
RouterFeature<RouterFeatureKind.ExperimentalAutoCleanupInjectorsFeature>;
export type AutoCleanupInjectorsFeature =
RouterFeature<RouterFeatureKind.AutoCleanupInjectorsFeature>;

/**
* Enables automatic destruction of unused route injectors.
Expand All @@ -755,14 +755,25 @@ export type ExperimentalAutoCleanupInjectorsFeature =
* should also implement `retrieveStoredRouteHandles` to ensure injectors for handles that will be
* reattached are not destroyed.
*
* @experimental 21.1
* @publicApi 22.2
*/
export function withExperimentalAutoCleanupInjectors(): ExperimentalAutoCleanupInjectorsFeature {
return routerFeature(RouterFeatureKind.ExperimentalAutoCleanupInjectorsFeature, [
export function withAutoCleanupInjectors(): AutoCleanupInjectorsFeature {
return routerFeature(RouterFeatureKind.AutoCleanupInjectorsFeature, [
{provide: ROUTE_INJECTOR_CLEANUP, useValue: routeInjectorCleanup},
]);
}

/**
* Enables automatic destruction of unused route injectors.
*
* @deprecated Use `withAutoCleanupInjectors` instead.
* @see {@link withAutoCleanupInjectors}
* @publicApi
*/
export function withExperimentalAutoCleanupInjectors(): AutoCleanupInjectorsFeature {
return withAutoCleanupInjectors();
}

/**
* A type alias for providers returned by `withComponentInputBinding` for use with `provideRouter`.
*
Expand Down Expand Up @@ -954,7 +965,7 @@ export type RouterFeatures =
| NavigationErrorHandlerFeature
| ComponentInputBindingFeature
| ViewTransitionsFeature
| ExperimentalAutoCleanupInjectorsFeature
| AutoCleanupInjectorsFeature
| RouterHashLocationFeature
| ExperimentalPlatformNavigationFeature;

Expand All @@ -972,6 +983,6 @@ export const enum RouterFeatureKind {
NavigationErrorHandlerFeature,
ComponentInputBindingFeature,
ViewTransitionsFeature,
ExperimentalAutoCleanupInjectorsFeature,
AutoCleanupInjectorsFeature,
ExperimentalPlatformNavigationFeature,
}
11 changes: 3 additions & 8 deletions packages/router/src/route_injector_cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,7 @@
import {InjectionToken} from '@angular/core';

import {Route, Routes} from './models';
import {
DetachedRouteHandleInternal,
ExperimentalRouteReuseStrategy,
RouteReuseStrategy,
} from './route_reuse_strategy';
import {DetachedRouteHandleInternal, RouteReuseStrategy} from './route_reuse_strategy';
import {ActivatedRouteSnapshot, RouterState} from './router_state';

/**
Expand All @@ -37,8 +33,7 @@ export function routeInjectorCleanup(
}

// For stored routes, collect them and all their parents by iterating pathFromRoot.
const storedHandles =
(routeReuseStrategy as ExperimentalRouteReuseStrategy).retrieveStoredRouteHandles?.() || [];
const storedHandles = routeReuseStrategy.retrieveStoredRouteHandles?.() || [];
for (const handle of storedHandles) {
const internalHandle = handle as DetachedRouteHandleInternal;
if (internalHandle?.route?.value?.snapshot) {
Expand Down Expand Up @@ -75,7 +70,7 @@ function destroyUnusedInjectors(
!!(
(route._injector || route._loadedInjector) &&
!activeRoutes.has(route) &&
((strategy as ExperimentalRouteReuseStrategy).shouldDestroyInjector?.(route) ?? false)
(strategy.shouldDestroyInjector?.(route) ?? false)
);

if (route.children) {
Expand Down
Loading