refactor(router): add support for blocking router resources

Extends router resource integration to support blocking resources during navigation transitions.

(cherry picked from commit fa2aca969f)
This commit is contained in:
Andrew Scott
2026-08-21 11:38:19 -07:00
committed by leonsenft
parent 34f0e1315b
commit d2e3bac33b
4 changed files with 631 additions and 604 deletions
@@ -5,13 +5,25 @@
* 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 {createEnvironmentInjector, runInInjectionContext, Resource} from '@angular/core';
import {
createEnvironmentInjector,
runInInjectionContext,
Resource,
effect,
DestroyRef,
} from '@angular/core';
import {OperatorFunction, pipe} from 'rxjs';
import {ResourceContext, ResourceResult} from '../models';
import {NavigationTransition} from '../navigation_transition';
import {ActivatedRoute, ActivatedRouteSnapshot, initializeActivatedRoute} from '../router_state';
import {TreeNode} from '../utils/tree';
import {BLOCKING_SYMBOL, InternalRouterResource, routerResource} from '../router_resource';
import {
BLOCKING_SYMBOL,
hasValueOrResolved,
InternalRouterResource,
routerResource,
SOURCE_RESOURCE_SYMBOL,
} from '../router_resource';
import {switchTap} from './switch_tap';
export function setupAndRunResources(
@@ -19,17 +31,24 @@ export function setupAndRunResources(
): OperatorFunction<NavigationTransition, NavigationTransition> {
return pipe(
switchTap(({newlyCreatedRoutes, targetRouterState}) => {
if (!newlyCreatedRoutes || !targetRouterState) {
if (!newlyCreatedRoutes || !targetRouterState || abortSignal.aborted) {
return;
}
const resourceSetupPromises: Array<Promise<void>> = [];
const blockingResourcePromises: Array<Promise<void>> = [];
const traverse = (stateNode: TreeNode<ActivatedRoute>) => {
const route = stateNode.value;
if (route) {
initializeActivatedRoute(route);
processRoute(route, newlyCreatedRoutes, resourceSetupPromises, abortSignal);
processRoute(
route,
newlyCreatedRoutes,
resourceSetupPromises,
abortSignal,
blockingResourcePromises,
);
}
for (const childState of stateNode.children) {
@@ -39,8 +58,7 @@ export function setupAndRunResources(
traverse(targetRouterState._root);
return Promise.all(resourceSetupPromises);
// TODO: wait for blocking resources
return Promise.all(resourceSetupPromises).then(() => Promise.all(blockingResourcePromises));
}),
);
}
@@ -50,6 +68,7 @@ function processRoute(
newlyCreatedRoutes: Set<ActivatedRoute>,
resourceSetupPromises: Array<Promise<void>>,
abortSignal: AbortSignal,
blockingResourcePromises: Array<Promise<void>>,
) {
const resources = route.routeConfig?.resources;
if (!resources) {
@@ -58,9 +77,11 @@ function processRoute(
if (newlyCreatedRoutes.has(route)) {
// This route is new. We need to run its resources function once.
resourceSetupPromises.push(setupNewRouterResources(route._futureSnapshot, route, abortSignal));
resourceSetupPromises.push(
setupNewRouterResources(route._futureSnapshot, route, abortSignal, blockingResourcePromises),
);
} else {
updateExistingResources(route);
updateExistingResources(route, blockingResourcePromises, abortSignal);
}
}
@@ -68,6 +89,7 @@ async function setupNewRouterResources(
snapshot: ActivatedRouteSnapshot,
route: ActivatedRoute,
abortSignal: AbortSignal,
blockingResourcePromises: Promise<void>[],
) {
const resourcesFn = snapshot?.routeConfig?.resources;
const parentInjector = snapshot?._environmentInjector;
@@ -120,10 +142,14 @@ async function setupNewRouterResources(
}
route.resources = route._futureSnapshot.resources = snapshot.resources = wrappedResult;
prohibitBlockingResources(route, wrappedResult);
setupBlocking(route, wrappedResult, blockingResourcePromises, abortSignal);
}
function updateExistingResources(route: ActivatedRoute) {
function updateExistingResources(
route: ActivatedRoute,
blockingResourcePromises: Promise<void>[],
abortSignal: AbortSignal,
) {
// This route is reused. We must eagerly update the resource context signals
// so that resources can react and fetch new data during the pending navigation.
const currentResources = route.snapshot?.resources;
@@ -131,11 +157,27 @@ function updateExistingResources(route: ActivatedRoute) {
return;
}
Object.values(currentResources).forEach((r) => {
const underlyingRes = (r as InternalRouterResource)[SOURCE_RESOURCE_SYMBOL];
if (underlyingRes.status() === 'error') {
// If a resource previously failed and the route is reused identically,
// the parameter signals won't change, meaning the internal effect won't automatically refetch.
// We must manually trigger a reload to ensure the new navigation attempts a retry.
(underlyingRes as unknown as {reload?: () => boolean}).reload?.();
}
});
route._futureSnapshot.resources = currentResources;
prohibitBlockingResources(route, currentResources);
setupBlocking(route, currentResources, blockingResourcePromises, abortSignal);
}
function prohibitBlockingResources(route: ActivatedRoute, resourceResult: ResourceResult) {
function setupBlocking(
route: ActivatedRoute,
resourceResult: ResourceResult,
blockingResourcePromises: Array<Promise<void>>,
abortSignal: AbortSignal,
) {
if (abortSignal.aborted) return;
const childInjector = route._localInjector;
if (!childInjector || !resourceResult) return;
@@ -144,6 +186,47 @@ function prohibitBlockingResources(route: ActivatedRoute, resourceResult: Resour
if (res[BLOCKING_SYMBOL] === false) {
continue;
}
throw new Error('blocking resources not implemented yet');
const promise = new Promise<void>((resolve, reject) => {
const underlyingRes = res[SOURCE_RESOURCE_SYMBOL];
let isDestroyed = false;
let unregisterOnDestroy: (() => void) | undefined;
const cleanup = () => {
isDestroyed = true;
blockingEffect.destroy();
unregisterOnDestroy?.();
abortSignal.removeEventListener('abort', onAbort);
};
const onAbort = () => {
cleanup();
resolve();
};
abortSignal.addEventListener('abort', onAbort, {once: true});
const blockingEffect = effect(
() => {
if (isDestroyed) {
return;
}
const status = underlyingRes.status();
if (status === 'error') {
cleanup();
reject(underlyingRes.error());
} else if (hasValueOrResolved(underlyingRes)) {
cleanup();
resolve();
}
},
{injector: childInjector, manualCleanup: true},
);
unregisterOnDestroy = childInjector.get(DestroyRef).onDestroy(() => {
cleanup();
resolve();
});
});
blockingResourcePromises.push(promise);
}
}
+16 -11
View File
@@ -32,11 +32,23 @@ import {
export const BLOCKING_SYMBOL: unique symbol = Symbol(
typeof ngDevMode === 'undefined' || ngDevMode ? '__isBlocking' : '',
);
export const SOURCE_RESOURCE_SYMBOL: unique symbol = Symbol(
typeof ngDevMode === 'undefined' || ngDevMode ? '__sourceResource' : '',
);
/**
* Checks if a resource has a value or has transitioned to a resolved/non-loading status.
*/
export function hasValueOrResolved(res: Resource<unknown>): boolean {
const status = res.status();
return res.hasValue() || (status !== 'loading' && status !== 'reloading');
}
/**
* @internal
*/
export interface InternalRouterResource<T = unknown> extends Resource<T> {
[SOURCE_RESOURCE_SYMBOL]: Resource<T>;
[BLOCKING_SYMBOL]?: boolean;
reload(): boolean;
}
@@ -68,11 +80,9 @@ export function routerResource<T>(source: Resource<T>): Resource<T> & {reload():
const res = resourceFromSnapshots(snapshotSignal) as unknown as InternalRouterResource<T>;
if ((source as unknown as InternalRouterResource<T>)[BLOCKING_SYMBOL] === false) {
res[BLOCKING_SYMBOL] = false;
} else {
res[BLOCKING_SYMBOL] = true;
}
res[SOURCE_RESOURCE_SYMBOL] = source;
res[BLOCKING_SYMBOL] =
(source as unknown as InternalRouterResource<T>)[BLOCKING_SYMBOL] !== false;
if (typeof (source as any).reload === 'function') {
res.reload = function (): boolean {
@@ -155,12 +165,7 @@ function createTransactionalSnapshot<T>(
effect(
() => {
if (
isRollbackRecoveryPending() &&
// TODO(consider): should this be hasValue || status !== loading
// Some stream implementations may retain loading status after first item resolves
!source.isLoading()
) {
if (isRollbackRecoveryPending() && hasValueOrResolved(source)) {
isRollbackRecoveryPending.set(false);
frozenSnapshot.set(null);
}
@@ -2,11 +2,21 @@
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license $can be
* 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 {Component, signal, WritableSignal, resource, ɵpromiseWithResolvers} from '@angular/core';
import {
Component,
computed,
Resource,
ResourceStatus,
Signal,
signal,
WritableSignal,
resource,
ɵpromiseWithResolvers,
} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {provideRouter, Router, UrlTree} from '@angular/router';
import {RouterTestingHarness} from '@angular/router/testing';
@@ -455,5 +465,48 @@ describe('routerResource behavior tests', () => {
await harness.fixture.whenStable();
expect(wrapped.value()).toBe('updated-2');
});
it('should complete rollback recovery when a resource has a value even while remaining in loading state', async () => {
const valueSignal = signal<string | undefined>('initial');
const hasValueSignal = signal<boolean>(true);
const customResource: Resource<string> = {
value: valueSignal as Signal<string>,
status: signal<ResourceStatus>('loading').asReadonly(),
isLoading: signal(true).asReadonly(),
hasValue: (() => hasValueSignal()) as any,
error: signal<Error | undefined>(undefined).asReadonly(),
snapshot: computed(() => ({
status: 'loading' as const,
value: valueSignal()!,
})),
};
const wrapped = TestBed.runInInjectionContext(() => routerResource(customResource));
expect(wrapped.value()).toBe('initial');
// Start navigation to route2 with a failing guard to trigger rollback
guardPromise2 = Promise.reject(new Error('Navigation failed'));
try {
await harness.navigateByUrl('/route2');
} catch {}
// Reset value and set hasValue to false to simulate recovery fetch starting
valueSignal.set(undefined);
hasValueSignal.set(false);
await timeout();
// Wrapped snapshot should be frozen at 'initial' during recovery loading
expect(wrapped.value()).toBe('initial');
// Resource receives value while isLoading() remains true and status is 'loading'
valueSignal.set('recovered-stream-1');
hasValueSignal.set(true);
await harness.fixture.whenStable();
// Rollback recovery unfreezes because hasValue is true despite isLoading being true
expect(wrapped.value()).toBe('recovered-stream-1');
expect(wrapped.isLoading()).toBe(true);
});
});
});
File diff suppressed because it is too large Load Diff