Skip to content

Commit 1e79dd3

Browse files
atscottthePunderWoman
authored andcommitted
refactor(router): Add handling for ActivatedRoute-scoped injector
Add handling in navigation for creating and destroying injectors scoped to `ActivatedRoute` life. The code for creating the injectors is certainly more complicated than it _could_ be since there's no actual feature built around this yet. Keeps as much implementation code tree-shakeable as possible: Raw size: +764 bytes Gzipped size: +182 bytes
1 parent 327744a commit 1e79dd3

12 files changed

Lines changed: 459 additions & 19 deletions

packages/core/test/bundling/router/bundle.golden_symbols.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"chunks": {
33
"main": [
4+
"ACTIVATED_ROUTE_INJECTOR_FEATURE",
45
"AFTER_RENDER_SEQUENCES_TO_ADD",
56
"ANIMATIONS",
67
"ANIMATION_QUEUE",
@@ -572,6 +573,7 @@
572573
"diPublicInInjector",
573574
"directiveHostEndFirstCreatePass",
574575
"directiveHostFirstCreatePass",
576+
"discardNewActivatedRoutes",
575577
"documentSupported",
576578
"domOnlyFirstCreatePass",
577579
"elementAttributeInternal",
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {InjectionToken} from '@angular/core';
10+
import {OperatorFunction} from 'rxjs';
11+
import type {NavigationTransition} from './navigation_transition';
12+
13+
export interface ActivatedRouteInjectorFeature {
14+
operator(): OperatorFunction<NavigationTransition, NavigationTransition>;
15+
}
16+
17+
export const ACTIVATED_ROUTE_INJECTOR_FEATURE = new InjectionToken<ActivatedRouteInjectorFeature>(
18+
typeof ngDevMode === 'undefined' || ngDevMode ? 'ActivatedRoute injector feature' : '',
19+
);

packages/router/src/create_router_state.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,28 @@ export function createRouterState(
2121
routeReuseStrategy: RouteReuseStrategy,
2222
curr: RouterStateSnapshot,
2323
prevState: RouterState,
24-
): RouterState {
25-
const root = createNode(routeReuseStrategy, curr._root, prevState ? prevState._root : undefined);
26-
return new RouterState(root, curr);
24+
): {newlyCreatedRoutes: Set<ActivatedRoute>; state: RouterState} {
25+
const newlyCreatedRoutes = new Set<ActivatedRoute>();
26+
const root = createNode(
27+
routeReuseStrategy,
28+
curr._root,
29+
prevState ? prevState._root : undefined,
30+
newlyCreatedRoutes,
31+
);
32+
return {newlyCreatedRoutes, state: new RouterState(root, curr)};
2733
}
2834

2935
function createNode(
3036
routeReuseStrategy: RouteReuseStrategy,
3137
curr: TreeNode<ActivatedRouteSnapshot>,
32-
prevState?: TreeNode<ActivatedRoute>,
38+
prevState: TreeNode<ActivatedRoute> | undefined,
39+
newlyCreatedRoutes: Set<ActivatedRoute>,
3340
): TreeNode<ActivatedRoute> {
3441
// reuse an activated route that is currently displayed on the screen
3542
if (prevState && routeReuseStrategy.shouldReuseRoute(curr.value, prevState.value.snapshot)) {
3643
const value = prevState.value;
3744
value._futureSnapshot = curr.value;
38-
const children = createOrReuseChildren(routeReuseStrategy, curr, prevState);
45+
const children = createOrReuseChildren(routeReuseStrategy, curr, prevState, newlyCreatedRoutes);
3946
return new TreeNode<ActivatedRoute>(value, children);
4047
} else {
4148
if (routeReuseStrategy.shouldAttach(curr.value)) {
@@ -44,13 +51,18 @@ function createNode(
4451
if (detachedRouteHandle !== null) {
4552
const tree = (detachedRouteHandle as DetachedRouteHandleInternal).route;
4653
tree.value._futureSnapshot = curr.value;
47-
tree.children = curr.children.map((c) => createNode(routeReuseStrategy, c));
54+
tree.children = curr.children.map((c) =>
55+
createNode(routeReuseStrategy, c, undefined, newlyCreatedRoutes),
56+
);
4857
return tree;
4958
}
5059
}
5160

5261
const value = createActivatedRoute(curr.value);
53-
const children = curr.children.map((c) => createNode(routeReuseStrategy, c));
62+
newlyCreatedRoutes.add(value);
63+
const children = curr.children.map((c) =>
64+
createNode(routeReuseStrategy, c, undefined, newlyCreatedRoutes),
65+
);
5466
return new TreeNode<ActivatedRoute>(value, children);
5567
}
5668
}
@@ -59,14 +71,15 @@ function createOrReuseChildren(
5971
routeReuseStrategy: RouteReuseStrategy,
6072
curr: TreeNode<ActivatedRouteSnapshot>,
6173
prevState: TreeNode<ActivatedRoute>,
74+
newlyCreatedRoutes: Set<ActivatedRoute>,
6275
) {
6376
return curr.children.map((child) => {
6477
for (const p of prevState.children) {
6578
if (routeReuseStrategy.shouldReuseRoute(child.value, p.value.snapshot)) {
66-
return createNode(routeReuseStrategy, child, p);
79+
return createNode(routeReuseStrategy, child, p, newlyCreatedRoutes);
6780
}
6881
}
69-
return createNode(routeReuseStrategy, child);
82+
return createNode(routeReuseStrategy, child, undefined, newlyCreatedRoutes);
7083
});
7184
}
7285

packages/router/src/navigation_transition.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import {UrlSerializer, UrlTree} from './url_tree';
8383
import {abortSignalToObservable} from './utils/abort_signal_to_observable';
8484
import {Checks, getAllRouteGuards} from './utils/preactivation';
8585
import {CREATE_VIEW_TRANSITION} from './utils/view_transition';
86+
import {ACTIVATED_ROUTE_INJECTOR_FEATURE} from './activated_route_injector_feature';
8687

8788
/**
8889
* @description
@@ -327,6 +328,7 @@ export interface NavigationTransition {
327328
targetRouterState: RouterState | null;
328329
guards: Checks;
329330
guardsResult: GuardResult | null;
331+
newlyCreatedRoutes?: Set<ActivatedRoute>;
330332

331333
routesRecognizeHandler: {deferredHandle?: Promise<void>};
332334
beforeActivateHandler: {deferredHandle?: Promise<void>};
@@ -367,6 +369,9 @@ export class NavigationTransitions {
367369
private readonly urlHandlingStrategy = inject(UrlHandlingStrategy);
368370
private readonly createViewTransition = inject(CREATE_VIEW_TRANSITION, {optional: true});
369371
private readonly navigationErrorHandler = inject(NAVIGATION_ERROR_HANDLER, {optional: true});
372+
private readonly activatedRouteInjectorFeature = inject(ACTIVATED_ROUTE_INJECTOR_FEATURE, {
373+
optional: true,
374+
});
370375

371376
navigationId = 0;
372377
get hasRequestedNavigation() {
@@ -740,19 +745,28 @@ export class NavigationTransitions {
740745
}),
741746

742747
switchMap((t: NavigationTransition) => {
743-
const targetRouterState = createRouterState(
748+
const {newlyCreatedRoutes, state} = createRouterState(
744749
router.routeReuseStrategy,
745750
t.targetSnapshot!,
746751
t.currentRouterState,
747752
);
748-
this.currentTransition = overallTransitionState = t = {...t, targetRouterState};
753+
this.currentTransition =
754+
overallTransitionState =
755+
t =
756+
{
757+
...t,
758+
targetRouterState: state,
759+
newlyCreatedRoutes,
760+
};
749761
this.currentNavigation.update((nav) => {
750-
nav!.targetRouterState = targetRouterState;
762+
nav!.targetRouterState = state;
751763
return nav;
752764
});
753765
return of(t);
754766
}),
755767

768+
this.activatedRouteInjectorFeature?.operator() ?? ((t) => t),
769+
756770
switchTap(() => this.afterPreactivation()),
757771

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

809+
// Prevent any cleanup of newly created routes once activated.
810+
t.newlyCreatedRoutes?.clear();
811+
795812
if (!shouldContinueNavigation()) {
796813
return;
797814
}
@@ -876,6 +893,7 @@ export class NavigationTransitions {
876893
}),
877894
catchError((e) => {
878895
completedOrAborted = true;
896+
discardNewActivatedRoutes(overallTransitionState);
879897
// If the application is already destroyed, the catch block should not
880898
// execute anything in practice because other resources have already
881899
// been released and destroyed.
@@ -974,6 +992,7 @@ export class NavigationTransitions {
974992
reason: string,
975993
code: NavigationCancellationCode,
976994
) {
995+
discardNewActivatedRoutes(t);
977996
const navCancel = new NavigationCancel(
978997
t.id,
979998
this.urlSerializer.serialize(t.extractedUrl),
@@ -1026,3 +1045,12 @@ export class NavigationTransitions {
10261045
export function isBrowserTriggeredNavigation(source: NavigationTrigger) {
10271046
return source !== IMPERATIVE_NAVIGATION;
10281047
}
1048+
1049+
function discardNewActivatedRoutes(t: NavigationTransition): void {
1050+
if (!t.newlyCreatedRoutes) {
1051+
return;
1052+
}
1053+
for (const r of t.newlyCreatedRoutes) {
1054+
r._localInjector?.destroy();
1055+
}
1056+
}

packages/router/src/operators/activate_routes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,12 @@ export class ActivateRoutes {
139139
context.attachRef = null;
140140
context.route = null;
141141
}
142+
// Destroy `_localInjector` here when the route is
143+
// unmounted by the Router. This method (`deactivateRouteAndOutlet`) is
144+
// skipped when a route is being detached for `RouteReuseStrategy`, preserving
145+
// its injector. Those preserved injectors are eventually managed and destroyed
146+
// manually via `destroyDetachedRouteHandle()` or if the route is deactivated later rather than detached.
147+
route.value._localInjector?.destroy();
142148
}
143149

144150
private activateChildRoutes(
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {OperatorFunction} from 'rxjs';
10+
import {ActivatedRoute, ActivatedRouteSnapshot} from '../router_state';
11+
import {TreeNode} from '../utils/tree';
12+
import {NavigationTransition} from '../navigation_transition';
13+
import {createEnvironmentInjector} from '@angular/core';
14+
import {tap} from 'rxjs/operators';
15+
16+
export function setupActivatedRouteInjectors(): OperatorFunction<
17+
NavigationTransition,
18+
NavigationTransition
19+
> {
20+
return tap(({newlyCreatedRoutes, targetRouterState}) => {
21+
if (!newlyCreatedRoutes || !targetRouterState) {
22+
return;
23+
}
24+
25+
// Obviously the easier way would be to just iterate newlyCreatedRoutes
26+
// and create injectors for them. However, the feature will eventually
27+
// want to do things for routes that are being reused.
28+
const traverse = (stateNode: TreeNode<ActivatedRoute>) => {
29+
const route = stateNode.value;
30+
if (route) {
31+
processRoute(route, newlyCreatedRoutes);
32+
}
33+
34+
for (const childState of stateNode.children) {
35+
traverse(childState);
36+
}
37+
};
38+
39+
traverse(targetRouterState._root);
40+
});
41+
}
42+
43+
function processRoute(route: ActivatedRoute, newlyCreatedRoutes: Set<ActivatedRoute>) {
44+
// Only create injectors for routes with the feature enabled
45+
const useActivatedRouteInjector = (route?.routeConfig as any)?.ɵUseActivatedRouteInjector;
46+
if (!useActivatedRouteInjector) {
47+
return;
48+
}
49+
50+
if (newlyCreatedRoutes.has(route)) {
51+
setupNewActivatedRouteInjector(route._futureSnapshot, route);
52+
} else {
53+
// TODO: Do something with injectors that already exist
54+
}
55+
}
56+
57+
function setupNewActivatedRouteInjector(snapshot: ActivatedRouteSnapshot, route: ActivatedRoute) {
58+
if (ngDevMode && !!route._localInjector) {
59+
throw new Error(
60+
'invalid state: _localInjector should not exist on newly created ActivatedRoute yet',
61+
);
62+
}
63+
route._localInjector = createEnvironmentInjector([], snapshot._environmentInjector);
64+
}

packages/router/src/private_export.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export {RestoredState as ɵRestoredState} from './navigation_transition';
1111
export {loadChildren as ɵloadChildren} from './router_config_loader';
1212
export {ROUTER_PROVIDERS as ɵROUTER_PROVIDERS} from './router_module';
1313
export {afterNextNavigation as ɵafterNextNavigation} from './utils/navigations';
14+
export {withActivatedRouteInjectors as ɵwithActivatedRouteInjectors} from './provide_router';

packages/router/src/provide_router.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ import {
6363
VIEW_TRANSITION_OPTIONS,
6464
ViewTransitionsFeatureOptions,
6565
} from './utils/view_transition';
66+
import {ACTIVATED_ROUTE_INJECTOR_FEATURE} from './activated_route_injector_feature';
67+
import {setupActivatedRouteInjectors} from './operators/setup_activated_route_injectors';
6668

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

891+
export type ActivatedRouteInjectorFeature =
892+
RouterFeature<RouterFeatureKind.ViewTransitionsFeature /* temporary - not public API. Must reuse existing */>;
893+
export function withActivatedRouteInjectors(): ActivatedRouteInjectorFeature {
894+
const providers = [
895+
{
896+
provide: ACTIVATED_ROUTE_INJECTOR_FEATURE,
897+
useValue: {
898+
operator: setupActivatedRouteInjectors,
899+
},
900+
},
901+
];
902+
return routerFeature(RouterFeatureKind.ViewTransitionsFeature, providers);
903+
}
904+
889905
/**
890906
* A type alias that represents all Router features available for use with `provideRouter`.
891907
* Features can be enabled by adding special functions to the `provideRouter` call.

packages/router/src/route_reuse_strategy.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ export function destroyDetachedRouteHandle(handle: DetachedRouteHandle): void {
4949
const internalHandle = handle as DetachedRouteHandleInternal;
5050
if (internalHandle && internalHandle.componentRef) {
5151
internalHandle.componentRef.destroy();
52+
// It is critical to destroy the `_localInjector` here. When a route is detached
53+
// by the `RouteReuseStrategy`, the `_localInjector` is retained because the
54+
// ActivatedRoute object is stored and can be attached later.
55+
// When the developer drops the handle (e.g., deciding not to reuse it),
56+
// they must manually invoke `destroyDetachedRouteHandle` to prevent a memory leak.
57+
internalHandle.route.value._localInjector?.destroy();
5258
}
5359
}
5460

packages/router/src/router_state.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ export class ActivatedRoute {
154154
/** An observable of the static and resolved data of this route. */
155155
public data: Observable<Data>;
156156

157+
/**
158+
* Injector scoped to the lifetime of this ActivatedRoute object.
159+
* Created only when features tied to ActivatedRoute lifetime are used.
160+
*
161+
* @internal
162+
*/
163+
_localInjector?: EnvironmentInjector;
164+
157165
/** @internal */
158166
constructor(
159167
/** @internal */

0 commit comments

Comments
 (0)