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
2 changes: 2 additions & 0 deletions packages/core/test/bundling/router/bundle.golden_symbols.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"chunks": {
"main": [
"ACTIVATED_ROUTE_INJECTOR_FEATURE",
"AFTER_RENDER_SEQUENCES_TO_ADD",
"ANIMATIONS",
"ANIMATION_QUEUE",
Expand Down Expand Up @@ -572,6 +573,7 @@
"diPublicInInjector",
"directiveHostEndFirstCreatePass",
"directiveHostFirstCreatePass",
"discardNewActivatedRoutes",
"documentSupported",
"domOnlyFirstCreatePass",
"elementAttributeInternal",
Expand Down
19 changes: 19 additions & 0 deletions packages/router/src/activated_route_injector_feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {InjectionToken} from '@angular/core';
import {OperatorFunction} from 'rxjs';
import type {NavigationTransition} from './navigation_transition';

export interface ActivatedRouteInjectorFeature {
operator(): OperatorFunction<NavigationTransition, NavigationTransition>;
}

export const ACTIVATED_ROUTE_INJECTOR_FEATURE = new InjectionToken<ActivatedRouteInjectorFeature>(
typeof ngDevMode === 'undefined' || ngDevMode ? 'ActivatedRoute injector feature' : '',
);
31 changes: 22 additions & 9 deletions packages/router/src/create_router_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,28 @@ export function createRouterState(
routeReuseStrategy: RouteReuseStrategy,
curr: RouterStateSnapshot,
prevState: RouterState,
): RouterState {
const root = createNode(routeReuseStrategy, curr._root, prevState ? prevState._root : undefined);
return new RouterState(root, curr);
): {newlyCreatedRoutes: Set<ActivatedRoute>; state: RouterState} {
const newlyCreatedRoutes = new Set<ActivatedRoute>();
const root = createNode(
routeReuseStrategy,
curr._root,
prevState ? prevState._root : undefined,
newlyCreatedRoutes,
);
return {newlyCreatedRoutes, state: new RouterState(root, curr)};
}

function createNode(
routeReuseStrategy: RouteReuseStrategy,
curr: TreeNode<ActivatedRouteSnapshot>,
prevState?: TreeNode<ActivatedRoute>,
prevState: TreeNode<ActivatedRoute> | undefined,
newlyCreatedRoutes: Set<ActivatedRoute>,
): TreeNode<ActivatedRoute> {
// reuse an activated route that is currently displayed on the screen
if (prevState && routeReuseStrategy.shouldReuseRoute(curr.value, prevState.value.snapshot)) {
const value = prevState.value;
value._futureSnapshot = curr.value;
const children = createOrReuseChildren(routeReuseStrategy, curr, prevState);
const children = createOrReuseChildren(routeReuseStrategy, curr, prevState, newlyCreatedRoutes);
return new TreeNode<ActivatedRoute>(value, children);
} else {
if (routeReuseStrategy.shouldAttach(curr.value)) {
Expand All @@ -44,13 +51,18 @@ function createNode(
if (detachedRouteHandle !== null) {
const tree = (detachedRouteHandle as DetachedRouteHandleInternal).route;
tree.value._futureSnapshot = curr.value;
tree.children = curr.children.map((c) => createNode(routeReuseStrategy, c));
tree.children = curr.children.map((c) =>
createNode(routeReuseStrategy, c, undefined, newlyCreatedRoutes),
);
return tree;
}
}

const value = createActivatedRoute(curr.value);
const children = curr.children.map((c) => createNode(routeReuseStrategy, c));
newlyCreatedRoutes.add(value);
const children = curr.children.map((c) =>
createNode(routeReuseStrategy, c, undefined, newlyCreatedRoutes),
);
return new TreeNode<ActivatedRoute>(value, children);
}
}
Expand All @@ -59,14 +71,15 @@ function createOrReuseChildren(
routeReuseStrategy: RouteReuseStrategy,
curr: TreeNode<ActivatedRouteSnapshot>,
prevState: TreeNode<ActivatedRoute>,
newlyCreatedRoutes: Set<ActivatedRoute>,
) {
return curr.children.map((child) => {
for (const p of prevState.children) {
if (routeReuseStrategy.shouldReuseRoute(child.value, p.value.snapshot)) {
return createNode(routeReuseStrategy, child, p);
return createNode(routeReuseStrategy, child, p, newlyCreatedRoutes);
}
}
return createNode(routeReuseStrategy, child);
return createNode(routeReuseStrategy, child, undefined, newlyCreatedRoutes);
});
}

Expand Down
34 changes: 31 additions & 3 deletions packages/router/src/navigation_transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import {UrlSerializer, UrlTree} from './url_tree';
import {abortSignalToObservable} from './utils/abort_signal_to_observable';
import {Checks, getAllRouteGuards} from './utils/preactivation';
import {CREATE_VIEW_TRANSITION} from './utils/view_transition';
import {ACTIVATED_ROUTE_INJECTOR_FEATURE} from './activated_route_injector_feature';

/**
* @description
Expand Down Expand Up @@ -327,6 +328,7 @@ export interface NavigationTransition {
targetRouterState: RouterState | null;
guards: Checks;
guardsResult: GuardResult | null;
newlyCreatedRoutes?: Set<ActivatedRoute>;

routesRecognizeHandler: {deferredHandle?: Promise<void>};
beforeActivateHandler: {deferredHandle?: Promise<void>};
Expand Down Expand Up @@ -367,6 +369,9 @@ export class NavigationTransitions {
private readonly urlHandlingStrategy = inject(UrlHandlingStrategy);
private readonly createViewTransition = inject(CREATE_VIEW_TRANSITION, {optional: true});
private readonly navigationErrorHandler = inject(NAVIGATION_ERROR_HANDLER, {optional: true});
private readonly activatedRouteInjectorFeature = inject(ACTIVATED_ROUTE_INJECTOR_FEATURE, {
optional: true,
});

navigationId = 0;
get hasRequestedNavigation() {
Expand Down Expand Up @@ -740,19 +745,28 @@ export class NavigationTransitions {
}),

switchMap((t: NavigationTransition) => {
const targetRouterState = createRouterState(
const {newlyCreatedRoutes, state} = createRouterState(
router.routeReuseStrategy,
t.targetSnapshot!,
t.currentRouterState,
);
this.currentTransition = overallTransitionState = t = {...t, targetRouterState};
this.currentTransition =
overallTransitionState =
t =
{
...t,
targetRouterState: state,
newlyCreatedRoutes,
};
this.currentNavigation.update((nav) => {
nav!.targetRouterState = targetRouterState;
nav!.targetRouterState = state;
return nav;
});
return of(t);
}),

this.activatedRouteInjectorFeature?.operator() ?? ((t) => t),

switchTap(() => this.afterPreactivation()),

// TODO(atscott): Move this into the last block below.
Expand Down Expand Up @@ -792,6 +806,9 @@ export class NavigationTransitions {
this.inputBindingEnabled,
).activate(this.rootContexts);

// Prevent any cleanup of newly created routes once activated.
t.newlyCreatedRoutes?.clear();

if (!shouldContinueNavigation()) {
return;
}
Expand Down Expand Up @@ -876,6 +893,7 @@ export class NavigationTransitions {
}),
catchError((e) => {
completedOrAborted = true;
discardNewActivatedRoutes(overallTransitionState);
// If the application is already destroyed, the catch block should not
// execute anything in practice because other resources have already
// been released and destroyed.
Expand Down Expand Up @@ -974,6 +992,7 @@ export class NavigationTransitions {
reason: string,
code: NavigationCancellationCode,
) {
discardNewActivatedRoutes(t);
const navCancel = new NavigationCancel(
t.id,
this.urlSerializer.serialize(t.extractedUrl),
Expand Down Expand Up @@ -1026,3 +1045,12 @@ export class NavigationTransitions {
export function isBrowserTriggeredNavigation(source: NavigationTrigger) {
return source !== IMPERATIVE_NAVIGATION;
}

function discardNewActivatedRoutes(t: NavigationTransition): void {
if (!t.newlyCreatedRoutes) {
return;
}
for (const r of t.newlyCreatedRoutes) {
r._localInjector?.destroy();
}
}
6 changes: 6 additions & 0 deletions packages/router/src/operators/activate_routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ export class ActivateRoutes {
context.attachRef = null;
context.route = null;
}
// Destroy `_localInjector` here when the route is
// unmounted by the Router. This method (`deactivateRouteAndOutlet`) is
// skipped when a route is being detached for `RouteReuseStrategy`, preserving
// its injector. Those preserved injectors are eventually managed and destroyed
// manually via `destroyDetachedRouteHandle()` or if the route is deactivated later rather than detached.
route.value._localInjector?.destroy();
}

private activateChildRoutes(
Expand Down
65 changes: 65 additions & 0 deletions packages/router/src/operators/setup_activated_route_injectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {OperatorFunction, pipe, tap} from 'rxjs';
import {ActivatedRoute, ActivatedRouteSnapshot} from '../router_state';
import {TreeNode} from '../utils/tree';
import {NavigationTransition} from '../navigation_transition';
import {createEnvironmentInjector} from '@angular/core';

export function setupActivatedRouteInjectors(): OperatorFunction<
NavigationTransition,
NavigationTransition
> {
return pipe(
tap(({newlyCreatedRoutes, targetRouterState}) => {
if (!newlyCreatedRoutes || !targetRouterState) {
return;
}

// Obviously the easier way would be to just iterate newlyCreatedRoutes
// and create injectors for them. However, the feature will eventually
// want to do things for routes that are being reused.
const traverse = (stateNode: TreeNode<ActivatedRoute>) => {
const route = stateNode.value;
if (route) {
processRoute(route, newlyCreatedRoutes);
}

for (const childState of stateNode.children) {
traverse(childState);
}
};

traverse(targetRouterState._root);
}),
);
}

function processRoute(route: ActivatedRoute, newlyCreatedRoutes: Set<ActivatedRoute>) {
// Only create injectors for routes with the feature enabled
const useActivatedRouteInjector = (route?.routeConfig as any)?.ɵUseActivatedRouteInjector;
if (!useActivatedRouteInjector) {
return;
}

if (newlyCreatedRoutes.has(route)) {
setupNewActivatedRouteInjector(route._futureSnapshot, route);
} else {
// TODO: Do something with injectors that already exist
}
}

function setupNewActivatedRouteInjector(snapshot: ActivatedRouteSnapshot, route: ActivatedRoute) {
if (ngDevMode && !!route._localInjector) {
throw new Error(
'invalid state: _localInjector should not exist on newly created ActivatedRoute yet',
);
}
route._localInjector = createEnvironmentInjector([], snapshot._environmentInjector);
}
1 change: 1 addition & 0 deletions packages/router/src/private_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export {RestoredState as ɵRestoredState} from './navigation_transition';
export {loadChildren as ɵloadChildren} from './router_config_loader';
export {ROUTER_PROVIDERS as ɵROUTER_PROVIDERS} from './router_module';
export {afterNextNavigation as ɵafterNextNavigation} from './utils/navigations';
export {withActivatedRouteInjectors as ɵwithActivatedRouteInjectors} from './provide_router';
16 changes: 16 additions & 0 deletions packages/router/src/provide_router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ import {
VIEW_TRANSITION_OPTIONS,
ViewTransitionsFeatureOptions,
} from './utils/view_transition';
import {ACTIVATED_ROUTE_INJECTOR_FEATURE} from './activated_route_injector_feature';
import {setupActivatedRouteInjectors} from './operators/setup_activated_route_injectors';

/**
* Sets up providers necessary to enable `Router` functionality for the application.
Expand Down Expand Up @@ -886,6 +888,20 @@ export function withViewTransitions(
return routerFeature(RouterFeatureKind.ViewTransitionsFeature, providers);
}

export type ActivatedRouteInjectorFeature =
RouterFeature<RouterFeatureKind.ViewTransitionsFeature /* temporary - not public API. Must reuse existing */>;
export function withActivatedRouteInjectors(): ActivatedRouteInjectorFeature {
const providers = [
{
provide: ACTIVATED_ROUTE_INJECTOR_FEATURE,
useValue: {
operator: setupActivatedRouteInjectors,
},
},
];
return routerFeature(RouterFeatureKind.ViewTransitionsFeature, providers);
}

/**
* A type alias that represents all Router features available for use with `provideRouter`.
* Features can be enabled by adding special functions to the `provideRouter` call.
Expand Down
6 changes: 6 additions & 0 deletions packages/router/src/route_reuse_strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ export function destroyDetachedRouteHandle(handle: DetachedRouteHandle): void {
const internalHandle = handle as DetachedRouteHandleInternal;
if (internalHandle && internalHandle.componentRef) {
internalHandle.componentRef.destroy();
// It is critical to destroy the `_localInjector` here. When a route is detached
// by the `RouteReuseStrategy`, the `_localInjector` is retained because the
// ActivatedRoute object is stored and can be attached later.
// When the developer drops the handle (e.g., deciding not to reuse it),
// they must manually invoke `destroyDetachedRouteHandle` to prevent a memory leak.
internalHandle.route.value._localInjector?.destroy();
}
}

Expand Down
8 changes: 8 additions & 0 deletions packages/router/src/router_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,14 @@ export class ActivatedRoute {
/** An observable of the static and resolved data of this route. */
public data: Observable<Data>;

/**
* Injector scoped to the lifetime of this ActivatedRoute object.
* Created only when features tied to ActivatedRoute lifetime are used.
*
* @internal
*/
_localInjector?: EnvironmentInjector;

/** @internal */
constructor(
/** @internal */
Expand Down
Loading
Loading