Skip to content

Commit 344b10f

Browse files
atscottthePunderWoman
authored andcommitted
refactor(router): clean up route resource implementation and test helpers
Clean up router resource handling: - Simplify reload method handling on router resource wrappers - Clean up resources access in outlet input binding effects and component input binder - Extract waitForResource helper for tracking blocking resource readiness - Simplify setupNewRouterResources and updateExistingResources - Remove redundant fixture.whenStable() calls in router resource tests
1 parent 3719605 commit 344b10f

4 files changed

Lines changed: 84 additions & 111 deletions

File tree

‎packages/router/src/directives/router_outlet.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ export class RoutedComponentInputBinder {
516516
...queryParams,
517517
...params,
518518
...data,
519-
...(activatedRoute.resources || {}),
519+
...activatedRoute.resources,
520520
};
521521
// Get the first result from the data subscription synchronously so it's available to
522522
// the component as soon as possible (and doesn't require a second change detection).

‎packages/router/src/operators/setup_and_run_resources.ts‎

Lines changed: 65 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ import {
1111
Resource,
1212
effect,
1313
DestroyRef,
14+
EnvironmentInjector,
1415
} from '@angular/core';
1516
import {OperatorFunction, pipe} from 'rxjs';
16-
import {ResourceContext, ResourceResult} from '../models';
17+
import {ResourceContext, ResourceResult, Route} from '../models';
1718
import {NavigationTransition} from '../navigation_transition';
1819
import {ActivatedRoute, ActivatedRouteSnapshot, initializeActivatedRoute} from '../router_state';
1920
import {TreeNode} from '../utils/tree';
@@ -77,24 +78,20 @@ function processRoute(
7778
if (newlyCreatedRoutes.has(route)) {
7879
// This route is new. We need to run its resources function once.
7980
resourceSetupPromises.push(
80-
setupNewRouterResources(route._futureSnapshot, route, abortSignal, blockingResourcePromises),
81+
setupNewRouterResources(route, resources, abortSignal, blockingResourcePromises),
8182
);
8283
} else {
8384
updateExistingResources(route, blockingResourcePromises, abortSignal);
8485
}
8586
}
8687

8788
async function setupNewRouterResources(
88-
snapshot: ActivatedRouteSnapshot,
8989
route: ActivatedRoute,
90+
resourcesFn: NonNullable<Route['resources']>,
9091
abortSignal: AbortSignal,
9192
blockingResourcePromises: Promise<void>[],
9293
) {
93-
const resourcesFn = snapshot?.routeConfig?.resources;
94-
const parentInjector = snapshot?._environmentInjector;
95-
if (!resourcesFn || !parentInjector) {
96-
return;
97-
}
94+
const parentInjector = route._futureSnapshot._environmentInjector;
9895

9996
let childInjector = route._localInjector;
10097
if (!childInjector) {
@@ -109,37 +106,27 @@ async function setupNewRouterResources(
109106
data: route.dataSignal,
110107
};
111108

112-
const resourceResultRaw = runInInjectionContext(childInjector, () => resourcesFn(context));
113-
let resourceResult: ResourceResult;
114-
if (resourceResultRaw instanceof Promise) {
115-
resourceResult = await resourceResultRaw;
116-
// Bail out if the router cancelled the navigation (and destroyed our injector!)
117-
// while we were waiting.
118-
if (abortSignal.aborted) return;
119-
} else {
120-
resourceResult = resourceResultRaw as ResourceResult;
121-
}
122-
123-
if (!resourceResult) return;
109+
const resourceResult = await runInInjectionContext(childInjector, () => resourcesFn(context));
110+
if (abortSignal.aborted || !resourceResult) return;
124111

125112
const wrappedResult: ResourceResult = {};
126-
for (const [key, res] of Object.entries(resourceResult)) {
127-
if (typeof ngDevMode === 'undefined' || ngDevMode) {
113+
runInInjectionContext(childInjector, () => {
114+
for (const [key, res] of Object.entries(resourceResult)) {
128115
if (
129-
!res ||
130-
typeof res !== 'object' ||
131-
typeof (res as Partial<Resource<unknown>>).snapshot !== 'function'
116+
(typeof ngDevMode === 'undefined' || ngDevMode) &&
117+
(!res ||
118+
typeof res !== 'object' ||
119+
typeof (res as Partial<Resource<unknown>>).snapshot !== 'function')
132120
) {
133121
throw new Error(
134122
`Invalid resource returned for key "${key}". Expected a Resource, but got ${res === null ? 'null' : typeof res}.`,
135123
);
136124
}
125+
wrappedResult[key] = routerResource(res);
137126
}
127+
});
138128

139-
wrappedResult[key] = runInInjectionContext(childInjector, () => routerResource(res));
140-
}
141-
142-
route.resources = route._futureSnapshot.resources = snapshot.resources = wrappedResult;
129+
route.resources = route._futureSnapshot.resources = wrappedResult;
143130
setupBlocking(route, wrappedResult, blockingResourcePromises, abortSignal);
144131
}
145132

@@ -155,15 +142,15 @@ function updateExistingResources(
155142
return;
156143
}
157144

158-
Object.values(currentResources).forEach((r) => {
145+
for (const r of Object.values(currentResources)) {
159146
const underlyingRes = (r as InternalRouterResource)[SOURCE_RESOURCE_SYMBOL];
160147
if (underlyingRes.status() === 'error') {
161148
// If a resource previously failed and the route is reused identically,
162149
// the parameter signals won't change, meaning the internal effect won't automatically refetch.
163150
// We must manually trigger a reload to ensure the new navigation attempts a retry.
164-
(underlyingRes as unknown as {reload?: () => boolean}).reload?.();
151+
(underlyingRes as Partial<InternalRouterResource>).reload?.();
165152
}
166-
});
153+
}
167154

168155
route._futureSnapshot.resources = currentResources;
169156
setupBlocking(route, currentResources, blockingResourcePromises, abortSignal);
@@ -179,52 +166,53 @@ function setupBlocking(
179166
const childInjector = route._localInjector;
180167
if (!childInjector || !resourceResult) return;
181168

182-
for (const r of Object.values(resourceResult)) {
183-
const res = r as InternalRouterResource;
184-
if (res[BLOCKING_SYMBOL] === false) {
185-
continue;
169+
for (const res of Object.values(resourceResult)) {
170+
const internalRes = res as InternalRouterResource;
171+
if (internalRes[BLOCKING_SYMBOL] !== false) {
172+
blockingResourcePromises.push(waitForResource(internalRes, childInjector, abortSignal));
186173
}
187-
const promise = new Promise<void>((resolve, reject) => {
188-
const underlyingRes = res[SOURCE_RESOURCE_SYMBOL];
189-
let isDestroyed = false;
190-
let unregisterOnDestroy: (() => void) | undefined;
191-
192-
const cleanup = () => {
193-
isDestroyed = true;
194-
blockingEffect.destroy();
195-
unregisterOnDestroy?.();
196-
abortSignal.removeEventListener('abort', onAbort);
197-
};
174+
}
175+
}
198176

199-
const onAbort = () => {
200-
cleanup();
201-
resolve();
202-
};
177+
function waitForResource(
178+
resource: InternalRouterResource,
179+
injector: EnvironmentInjector,
180+
abortSignal: AbortSignal,
181+
): Promise<void> {
182+
return new Promise<void>((resolve, reject) => {
183+
const underlyingRes = resource[SOURCE_RESOURCE_SYMBOL];
184+
let isDestroyed = false;
185+
let unregisterDestroy: (() => void) | undefined;
186+
187+
const cleanup = () => {
188+
isDestroyed = true;
189+
blockingEffect.destroy();
190+
unregisterDestroy?.();
191+
abortSignal.removeEventListener('abort', onDone);
192+
};
193+
194+
const onDone = () => {
195+
cleanup();
196+
resolve();
197+
};
198+
199+
abortSignal.addEventListener('abort', onDone, {once: true});
200+
201+
const blockingEffect = effect(
202+
() => {
203+
if (isDestroyed) return;
204+
const status = underlyingRes.status();
205+
if (status === 'error') {
206+
cleanup();
207+
reject(underlyingRes.error());
208+
} else if (!underlyingRes.isLoading()) {
209+
cleanup();
210+
resolve();
211+
}
212+
},
213+
{injector, manualCleanup: true},
214+
);
203215

204-
abortSignal.addEventListener('abort', onAbort, {once: true});
205-
206-
const blockingEffect = effect(
207-
() => {
208-
if (isDestroyed) {
209-
return;
210-
}
211-
const status = underlyingRes.status();
212-
if (status === 'error') {
213-
cleanup();
214-
reject(underlyingRes.error());
215-
} else if (!underlyingRes.isLoading()) {
216-
cleanup();
217-
resolve();
218-
}
219-
},
220-
{injector: childInjector, manualCleanup: true},
221-
);
222-
223-
unregisterOnDestroy = childInjector.get(DestroyRef).onDestroy(() => {
224-
cleanup();
225-
resolve();
226-
});
227-
});
228-
blockingResourcePromises.push(promise);
229-
}
216+
unregisterDestroy = injector.get(DestroyRef).onDestroy(onDone);
217+
});
230218
}

‎packages/router/src/router_resource.ts‎

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -80,22 +80,18 @@ export function routerResource<T>(source: Resource<T>): Resource<T> & {reload():
8080
res[BLOCKING_SYMBOL] =
8181
(source as unknown as InternalRouterResource<T>)[BLOCKING_SYMBOL] !== false;
8282

83-
if (typeof (source as any).reload === 'function') {
84-
res.reload = function (): boolean {
85-
// If the resource is currently frozen (e.g., during an active navigation transition
86-
// or while recovering from a cancelled navigation), we reject manual reload requests.
87-
// Triggering a reload during an active navigation (where the resource may already be
88-
// reactively loading new parameters behind the scenes) would disrupt the router's resource
89-
// tracking for the transition. Similarly, during a rollback recovery, the router is
90-
// already managing the resource reload to restore the previous state.
91-
if (frozenSnapshot() !== null) {
92-
return false;
93-
}
94-
return (source as any).reload();
95-
};
96-
} else {
97-
res.reload = () => false;
98-
}
83+
res.reload = function (): boolean {
84+
// If the resource is currently frozen (e.g., during an active navigation transition
85+
// or while recovering from a cancelled navigation), we reject manual reload requests.
86+
// Triggering a reload during an active navigation (where the resource may already be
87+
// reactively loading new parameters behind the scenes) would disrupt the router's resource
88+
// tracking for the transition. Similarly, during a rollback recovery, the router is
89+
// already managing the resource reload to restore the previous state.
90+
if (frozenSnapshot() !== null) {
91+
return false;
92+
}
93+
return (source as any).reload?.() ?? false;
94+
};
9995

10096
return res;
10197
}
@@ -193,12 +189,13 @@ export function createResourceOutletBindingEffects(
193189
const createdEffects: EffectRef[] = [];
194190
const handledKeys: string[] = [];
195191
const mirror = route.component ? reflectComponentType(route.component) : null;
196-
if (!mirror) {
192+
const resources = route.resources;
193+
if (!mirror || !resources) {
197194
return {createdEffects, handledKeys};
198195
}
199196

200197
for (const {templateName} of mirror.inputs) {
201-
const resource = route.resources?.[templateName];
198+
const resource = resources[templateName];
202199
if (!resource || !(resource as InternalRouterResource)[BLOCKING_SYMBOL]) {
203200
continue;
204201
}

‎packages/router/test/router_resource_spec.ts‎

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,6 @@ describe('Router resources integration', () => {
128128
]);
129129

130130
await harness.navigateByUrl('/test');
131-
await harness.fixture.whenStable();
132131

133132
const route = router.routerState.root.firstChild;
134133
const resourceRef = route?.resources?.['data'];
@@ -163,7 +162,6 @@ describe('Router resources integration', () => {
163162
// 2. Now resolve the blocking resource loader
164163
loaderDeferred.resolve('resolved data');
165164
await navPromise;
166-
await harness.fixture.whenStable();
167165

168166
expect(router.url).toBe('/test');
169167
const route = router.routerState.root.firstChild;
@@ -313,7 +311,6 @@ describe('Router resources integration', () => {
313311
]);
314312

315313
await harness.navigateByUrl('/parent/componentless/child');
316-
await harness.fixture.whenStable();
317314
await timeout(20);
318315

319316
const parentRoute = router.routerState.root.firstChild!;
@@ -534,7 +531,6 @@ describe('Router resources integration', () => {
534531

535532
// Navigate without query params -> params() is undefined -> resource is idle
536533
await harness.navigateByUrl('/search');
537-
await harness.fixture.whenStable();
538534

539535
expect(router.url).toBe('/search');
540536
const resourceRef = router.routerState.root.firstChild?.resources?.['data'];
@@ -562,8 +558,7 @@ describe('Router resources integration', () => {
562558
]);
563559

564560
await harness.navigateByUrl('/test');
565-
await harness.fixture.whenStable();
566-
const resourceRef = router.routerState.root.firstChild?.resources?.['data'];
561+
const resourceRef = router.routerState.root.firstChild?.resources?.['data'] as any;
567562

568563
await timeout(20);
569564
expect(resourceRef?.value()).toEqual({name: 'user 123'});
@@ -601,7 +596,6 @@ describe('Router resources integration', () => {
601596
// Fail next navigation
602597
canActivate = false;
603598
await harness.navigateByUrl('/test/2');
604-
await harness.fixture.whenStable();
605599

606600
// The navigation is cancelled so the resource should retain the old value without loading flicker.
607601
expect(resourceRef?.value()).toBe('1');
@@ -740,7 +734,6 @@ describe('Router resources integration', () => {
740734

741735
p3.resolve('loaded-3');
742736
await nav3;
743-
await harness.fixture.whenStable();
744737

745738
const resourceRef = router.routerState.root.firstChild?.resources?.['data'];
746739
expect(resourceRef?.value()).toBe('loaded-3');
@@ -878,7 +871,6 @@ describe('Router resources integration', () => {
878871
expect(resourceRef?.value()).toBeUndefined();
879872

880873
await nav;
881-
await harness.fixture.whenStable();
882874

883875
expect(resourceRef?.isLoading()).toBe(false);
884876
expect(resourceRef?.value()).toBe('rx loaded 123');
@@ -973,7 +965,6 @@ describe('Router resources integration', () => {
973965
// Resolve user
974966
resolveUser({id: 'u1', teamId: 't42'});
975967
await nav;
976-
await harness.fixture.whenStable();
977968

978969
expect(router.url).toBe('/user-team');
979970
const resources = router.routerState.root.firstChild?.resources;
@@ -1036,7 +1027,6 @@ describe('Router resources integration', () => {
10361027
// Resolve child
10371028
resolveChild({role: 'admin', permissions: ['read', 'write']});
10381029
await nav;
1039-
await harness.fixture.whenStable();
10401030

10411031
expect(router.url).toBe('/user/1/details');
10421032
const parentRoute = router.routerState.root.firstChild!;
@@ -1063,7 +1053,7 @@ describe('Router resources integration', () => {
10631053
}
10641054
}
10651055

1066-
const {harness, router} = await setupRouter([
1056+
const {harness} = await setupRouter([
10671057
{
10681058
path: 'user',
10691059
component: UserProfileCmp,
@@ -1076,14 +1066,13 @@ describe('Router resources integration', () => {
10761066
]);
10771067

10781068
const instance = await harness.navigateByUrl('/user', UserProfileCmp);
1079-
await harness.fixture.whenStable();
10801069

10811070
expect(fetchCount).toBe(1);
10821071
expect(instance.userResource?.value()).toBe('User #1');
10831072

10841073
const reloaded = instance.refreshUser();
10851074
expect(reloaded).toBe(true);
1086-
await harness.fixture.whenStable();
1075+
await timeout();
10871076

10881077
expect(fetchCount).toBe(2);
10891078
expect(instance.userResource?.value()).toBe('User #2');
@@ -1136,7 +1125,6 @@ describe('Router resources integration', () => {
11361125
]);
11371126

11381127
await harness.navigateByUrl('/custom');
1139-
await harness.fixture.whenStable();
11401128

11411129
const route = router.routerState.root.firstChild!;
11421130
expect(route.resources?.['custom']?.reload()).toBe(false);

0 commit comments

Comments
 (0)