mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user