refactor(router): add support for non-blocking router resources (#70211)

Introduces support for route-level resources via the `resources` property on route definitions, enabled with `withRouterResources()`.

Router resources provide a reactive, signal-based alternative to resolvers, allowing routes to declare resources tied to route parameters and route lifecycle:
- The `resources` function executes during navigation transitions within an injection context scoped to the route (`_localInjector`).
- It receives a `ResourceContext` containing signals for `params`, `queryParams`, `fragment`, and `data`, alongside the static `snapshot`.
- For newly created routes, `resources` runs once and attaches to `ActivatedRoute.resources`. For reused routes, parameter signals update reactively to trigger new data fetches while keeping resource references stable.
- Wrapped resources (`routerResource`) provide transactional stability: snapshots are frozen during active navigations to prevent UI jitter, unfreezing on `NavigationEnd`.
- On cancelled navigations or errors, rollback recovery retains the frozen snapshot until reverted signals settle, avoiding flashes of loading state. Manual reloads are rejected while frozen.
- Local injectors are automatically cleaned up if navigations are cancelled or rolled back.

Note that this commit only implements non-blocking resources (marked with `nonBlocking()`), deferring blocking resource resolution to future work to keep the initial changeset smaller and less complex.

PR Close #70211
This commit is contained in:
Andrew Scott
2026-08-14 10:08:44 -07:00
committed by Jessica Janiuk
parent 99c0f593c4
commit 80a04eba52
13 changed files with 1392 additions and 92 deletions
@@ -1,5 +1,5 @@
{
"dist/browser/main-[hash].js": 232419,
"dist/browser/polyfills-[hash].js": 35726,
"dist/browser/main-[hash].js": 237605,
"dist/browser/polyfills-[hash].js": 35784,
"dist/browser/event-dispatch-contract.min.js": 476
}
@@ -1,7 +1,6 @@
{
"chunks": {
"main": [
"ACTIVATED_ROUTE_INJECTOR_FEATURE",
"AFTER_RENDER_SEQUENCES_TO_ADD",
"ANIMATIONS",
"ANIMATION_QUEUE",
@@ -243,6 +242,7 @@
"ROUTER_CONFIGURATION",
"ROUTER_OUTLET_DATA",
"ROUTER_PRELOADER",
"ROUTER_RESOURCES_FEATURE",
"ROUTER_SCROLLER",
"ROUTES",
"ROUTES2",
@@ -584,7 +584,6 @@
"diPublicInInjector",
"directiveHostEndFirstCreatePass",
"directiveHostFirstCreatePass",
"discardNewActivatedRoutes",
"documentSupported",
"domOnlyFirstCreatePass",
"elementAttributeInternal",
@@ -1037,6 +1036,7 @@
"reportUnhandledError",
"requiresRefreshOrTraversal",
"resetConsumerBeforeComputation",
"resetPendingRoutes",
"resetPreOrderHookFlags",
"resolveData",
"resolveDirectives",
@@ -1045,6 +1045,7 @@
"resolveNode",
"retrieveHydrationInfo",
"reusedNodes",
"rollbackState",
"rootRoute",
"runAfterLeaveAnimations",
"runCanActivate",
+3 -2
View File
@@ -41,7 +41,7 @@ function createNode(
// 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;
value._setPending(curr.value);
const children = createOrReuseChildren(routeReuseStrategy, curr, prevState, newlyCreatedRoutes);
return new TreeNode<ActivatedRoute>(value, children);
} else {
@@ -50,7 +50,7 @@ function createNode(
const detachedRouteHandle = routeReuseStrategy.retrieve(curr.value);
if (detachedRouteHandle !== null) {
const tree = (detachedRouteHandle as DetachedRouteHandleInternal).route;
tree.value._futureSnapshot = curr.value;
tree.value._setPending(curr.value);
tree.children = curr.children.map((c) =>
createNode(routeReuseStrategy, c, undefined, newlyCreatedRoutes),
);
@@ -59,6 +59,7 @@ function createNode(
}
const value = createActivatedRoute(curr.value);
value._setPending(curr.value);
newlyCreatedRoutes.add(value);
const children = curr.children.map((c) =>
createNode(routeReuseStrategy, c, undefined, newlyCreatedRoutes),
+57
View File
@@ -13,14 +13,63 @@ import {
NgModuleFactory,
Provider,
ProviderToken,
Signal,
Type,
Resource,
} from '@angular/core';
import {Observable} from 'rxjs';
export {DefaultExport} from '@angular/core';
import type {ActivatedRouteSnapshot, RouterStateSnapshot} from './router_state';
import {ParamMap, Params} from './shared';
import type {UrlSegment, UrlSegmentGroup, UrlTree} from './url_tree';
/**
* The expected return type of a `resources` function.
* @experimental
*/
export type ResourceResult = Record<string, Resource<unknown>>;
// Developer notes: properties are exposed as a plain Record (`Params`) rather than a `ParamMap`
// to allow future type-check layers to infer exact keys (e.g., `{ id: string }`).
// Same applies to data and queryparams.
/**
* The contextual information provided to a `resources` function.
* @experimental
*/
export interface ResourceContext {
/**
* The matrix parameters of the route.
*
* @experimental
*/
params: Signal<Params>;
/**
* The query parameters of the route.
*
* @experimental
*/
queryParams: Signal<Params>;
/**
* The URL fragment.
* @experimental
*/
fragment: Signal<string | null>;
/**
* Data provided in the route configuration.
*
* @experimental
*/
data: Signal<Record<string, any>>;
/**
* The static activated route snapshot for this navigation.
* Useful for reading initial static configuration statically without
* reacting to future parameter changes on reused routes.
* @experimental
*/
snapshot: ActivatedRouteSnapshot;
}
/**
* How to handle a navigation request to the current URL. One of:
*
@@ -713,6 +762,14 @@ export interface Route {
* @see [Resolve](guide/routing/data-resolvers#what-are-data-resolvers)
*/
resolve?: ResolveData;
/**
* A function that returns a record of resources.
* This function is executed during the Main Loading Phase of a navigation.
* @experimental
* @internal
*/
resources?: (ctx: ResourceContext) => ResourceResult | Promise<ResourceResult>;
/**
* An array of child `Route` objects that specifies a nested route
* configuration.
+23 -12
View File
@@ -63,6 +63,7 @@ import {ActivateRoutes} from './operators/activate_routes';
import {checkGuards} from './operators/check_guards';
import {recognize} from './operators/recognize';
import {resolveData} from './operators/resolve_data';
import {ROUTER_RESOURCES_FEATURE} from './router_resource_feature';
import {switchTap} from './operators/switch_tap';
import {TitleStrategy} from './page_title_strategy';
import type {Router} from './router';
@@ -83,7 +84,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 {ROUTER_RESOURCES_FEATURE} from './router_resource_feature';
import {TreeNode} from './utils/tree';
/**
* @description
@@ -765,11 +766,9 @@ export class NavigationTransitions {
return of(t);
}),
this.routerResourcesFeature?.operator() ?? ((t) => t),
this.routerResourcesFeature?.setupAndRunResources(abortController.signal) ?? ((t) => t),
switchTap(() => this.afterPreactivation()),
// TODO(atscott): Move this into the last block below.
switchMap(() => {
const {currentSnapshot, targetSnapshot} = overallTransitionState;
const viewTransitionStarted = this.createViewTransition?.(
@@ -813,6 +812,8 @@ export class NavigationTransitions {
return;
}
resetPendingRoutes(t.targetRouterState);
completedOrAborted = true;
this.currentNavigation.update((nav) => {
(nav as Writable<Navigation>).abort = noop;
@@ -832,7 +833,7 @@ export class NavigationTransitions {
takeUntil(
abortSignalToObservable(abortController.signal).pipe(
// Ignore aborts if we are already completed, canceled, or are in the activation stage (we have targetRouterState)
// Ignore aborts if we are already completed, canceled, or the transition has entered the non-abortable activation stage
filter(() => !completedOrAborted && abortable),
tap(() => {
this.cancelNavigationTransition(
@@ -893,7 +894,7 @@ export class NavigationTransitions {
}),
catchError((e) => {
completedOrAborted = true;
discardNewActivatedRoutes(overallTransitionState);
rollbackState(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.
@@ -996,7 +997,7 @@ export class NavigationTransitions {
reason: string,
code: NavigationCancellationCode,
) {
discardNewActivatedRoutes(t);
rollbackState(t);
const navCancel = new NavigationCancel(
t.id,
this.urlSerializer.serialize(t.extractedUrl),
@@ -1050,11 +1051,21 @@ export function isBrowserTriggeredNavigation(source: NavigationTrigger) {
return source !== IMPERATIVE_NAVIGATION;
}
function discardNewActivatedRoutes(t: NavigationTransition): void {
if (!t.newlyCreatedRoutes) {
function rollbackState(t: NavigationTransition): void {
for (const r of t.newlyCreatedRoutes ?? []) {
r._localInjector?.destroy();
r._localInjector = undefined;
}
resetPendingRoutes(t.targetRouterState);
}
function resetPendingRoutes(targetRouterState?: RouterState | null): void {
if (!targetRouterState) {
return;
}
for (const r of t.newlyCreatedRoutes) {
r._localInjector?.destroy();
}
const traverse = (node: TreeNode<ActivatedRoute>) => {
node.value.pending?.set(false);
node.children.forEach(traverse);
};
traverse(targetRouterState._root);
}
@@ -5,60 +5,145 @@
* 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} from 'rxjs';
import {ActivatedRoute, ActivatedRouteSnapshot} from '../router_state';
import {TreeNode} from '../utils/tree';
import {createEnvironmentInjector, runInInjectionContext, Resource} from '@angular/core';
import {OperatorFunction, pipe} from 'rxjs';
import {ResourceContext, ResourceResult} from '../models';
import {NavigationTransition} from '../navigation_transition';
import {createEnvironmentInjector} from '@angular/core';
import {tap} from 'rxjs/operators';
import {ActivatedRoute, ActivatedRouteSnapshot, initializeActivatedRoute} from '../router_state';
import {TreeNode} from '../utils/tree';
import {BLOCKING_SYMBOL, InternalRouterResource, routerResource} from '../router_resource';
import {switchTap} from './switch_tap';
export function setupActivatedRouteInjectors(): OperatorFunction<
NavigationTransition,
NavigationTransition
> {
return 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);
export function setupAndRunResources(
abortSignal: AbortSignal,
): OperatorFunction<NavigationTransition, NavigationTransition> {
return pipe(
switchTap(({newlyCreatedRoutes, targetRouterState}) => {
if (!newlyCreatedRoutes || !targetRouterState) {
return;
}
for (const childState of stateNode.children) {
traverse(childState);
}
};
const resourceSetupPromises: Array<Promise<void>> = [];
traverse(targetRouterState._root);
});
const traverse = (stateNode: TreeNode<ActivatedRoute>) => {
const route = stateNode.value;
if (route) {
initializeActivatedRoute(route);
processRoute(route, newlyCreatedRoutes, resourceSetupPromises, abortSignal);
}
for (const childState of stateNode.children) {
traverse(childState);
}
};
traverse(targetRouterState._root);
return Promise.all(resourceSetupPromises);
// TODO: wait for blocking resources
}),
);
}
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) {
function processRoute(
route: ActivatedRoute,
newlyCreatedRoutes: Set<ActivatedRoute>,
resourceSetupPromises: Array<Promise<void>>,
abortSignal: AbortSignal,
) {
const resources = route.routeConfig?.resources;
if (!resources) {
return;
}
if (newlyCreatedRoutes.has(route)) {
setupNewActivatedRouteInjector(route._futureSnapshot, route);
// This route is new. We need to run its resources function once.
resourceSetupPromises.push(setupNewRouterResources(route._futureSnapshot, route, abortSignal));
} else {
// TODO: Do something with injectors that already exist
updateExistingResources(route);
}
}
function setupNewActivatedRouteInjector(snapshot: ActivatedRouteSnapshot, route: ActivatedRoute) {
if (ngDevMode && !!route._localInjector) {
throw new Error(
'invalid state: _localInjector should not exist on newly created ActivatedRoute yet',
);
async function setupNewRouterResources(
snapshot: ActivatedRouteSnapshot,
route: ActivatedRoute,
abortSignal: AbortSignal,
) {
const resourcesFn = snapshot?.routeConfig?.resources;
const parentInjector = snapshot?._environmentInjector;
if (!resourcesFn || !parentInjector) {
return;
}
let childInjector = route._localInjector;
if (!childInjector) {
childInjector = createEnvironmentInjector([], parentInjector);
route._localInjector = childInjector; // Attach to route for cleanup
}
const context: ResourceContext = {
params: route.paramsSignal,
queryParams: route.queryParamsSignal,
fragment: route.fragmentSignal,
data: route.dataSignal,
snapshot: route._futureSnapshot,
};
const resourceResultRaw = runInInjectionContext(childInjector, () => resourcesFn(context));
let resourceResult: ResourceResult;
if (resourceResultRaw instanceof Promise) {
resourceResult = await resourceResultRaw;
// Bail out if the router cancelled the navigation (and destroyed our injector!)
// while we were waiting.
if (abortSignal.aborted) return;
} else {
resourceResult = resourceResultRaw as ResourceResult;
}
if (!resourceResult) return;
const wrappedResult: ResourceResult = {};
for (const [key, res] of Object.entries(resourceResult)) {
if (typeof ngDevMode === 'undefined' || ngDevMode) {
if (
!res ||
typeof res !== 'object' ||
typeof (res as Partial<Resource<unknown>>).snapshot !== 'function'
) {
throw new Error(
`Invalid resource returned for key "${key}". Expected a Resource, but got ${res === null ? 'null' : typeof res}.`,
);
}
}
wrappedResult[key] = runInInjectionContext(childInjector, () => routerResource(res));
}
route.resources = route._futureSnapshot.resources = snapshot.resources = wrappedResult;
prohibitBlockingResources(route, wrappedResult);
}
function updateExistingResources(route: ActivatedRoute) {
// 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;
if (!currentResources) {
return;
}
route._futureSnapshot.resources = currentResources;
prohibitBlockingResources(route, currentResources);
}
function prohibitBlockingResources(route: ActivatedRoute, resourceResult: ResourceResult) {
const childInjector = route._localInjector;
if (!childInjector || !resourceResult) return;
for (const r of Object.values(resourceResult)) {
const res = r as InternalRouterResource;
if (res[BLOCKING_SYMBOL] === false) {
continue;
}
throw new Error('blocking resources not implemented yet');
}
route._localInjector = createEnvironmentInjector([], snapshot._environmentInjector);
}
+3 -1
View File
@@ -11,4 +11,6 @@ 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 {withRouterResources as ɵwithActivatedRouteInjectors} from './provide_router';
export {withRouterResources as ɵwithRouterResources} from './provide_router';
export {nonBlocking as ɵnonBlocking} from './router_resource';
export {ResourceContext as ɵResourceContext, ResourceResult as ɵResourceResult} from './models';
+33 -6
View File
@@ -48,6 +48,7 @@ import {
RouterConfigOptions,
} from './router_config';
import {ROUTES} from './router_config_loader';
import {setupAndRunResources} from './operators/setup_and_run_resources';
import {PreloadingStrategy, RouterPreloader} from './router_preloader';
import {ROUTER_SCROLLER, RouterScroller} from './router_scroller';
@@ -64,7 +65,6 @@ import {
ViewTransitionsFeatureOptions,
} from './utils/view_transition';
import {ROUTER_RESOURCES_FEATURE} from './router_resource_feature';
import {setupActivatedRouteInjectors} from './operators/setup_and_run_resources';
/**
* Sets up providers necessary to enable `Router` functionality for the application.
@@ -363,8 +363,7 @@ export type EnabledBlockingInitialNavigationFeature =
* @publicApi
*/
export type InitialNavigationFeature =
| EnabledBlockingInitialNavigationFeature
| DisabledInitialNavigationFeature;
EnabledBlockingInitialNavigationFeature | DisabledInitialNavigationFeature;
/**
* Configures initial navigation to start before the root component is created.
@@ -888,14 +887,42 @@ export function withViewTransitions(
return routerFeature(RouterFeatureKind.ViewTransitionsFeature, providers);
}
export type RouterResourcesFeature =
RouterFeature<RouterFeatureKind.ViewTransitionsFeature /* temporary - not public API. Must reuse existing */>;
/**
* A type alias for providers returned by `withRouterResources` for use with `provideRouter`.
*
* @see {@link withRouterResources}
* @see {@link provideRouter}
*
* @experimental
*/
export type RouterResourcesFeature = RouterFeature<RouterFeatureKind.ViewTransitionsFeature>;
/**
* Enables `resources` capabilities for Route definitions.
*
* @usageNotes
*
* Basic example of how you can enable the feature:
* ```ts
* const appRoutes: Routes = [];
* bootstrapApplication(AppComponent,
* {
* providers: [
* provideRouter(appRoutes, withRouterResources())
* ]
* }
* );
* ```
*
* @experimental
* @returns A set of providers for use with `provideRouter`.
*/
export function withRouterResources(): RouterResourcesFeature {
const providers = [
{
provide: ROUTER_RESOURCES_FEATURE,
useValue: {
operator: setupActivatedRouteInjectors,
setupAndRunResources,
},
},
];
+45 -5
View File
@@ -17,7 +17,6 @@ import {
ResourceSnapshot,
effect,
computed,
WritableResource,
assertInInjectionContext,
} from '@angular/core';
import {Router} from './router';
@@ -30,6 +29,28 @@ import {
NavigationCancellationCode,
} from './events';
export const BLOCKING_SYMBOL: unique symbol = Symbol(
typeof ngDevMode === 'undefined' || ngDevMode ? '__isBlocking' : '',
);
/**
* @internal
*/
export interface InternalRouterResource<T = unknown> extends Resource<T> {
[BLOCKING_SYMBOL]?: boolean;
reload(): boolean;
}
/**
* Marks a resource as non-blocking. The Router will NOT wait for this resource to resolve
* before completing the navigation.
* @experimental
*/
export function nonBlocking<T, R extends Resource<T>>(res: R): R {
(res as unknown as InternalRouterResource<T>)[BLOCKING_SYMBOL] = false;
return res;
}
/**
* Wraps a Resource to make it cooperative with the Angular Router, freezing its state
* during navigation transitions and handling rollback recovery.
@@ -45,7 +66,13 @@ export function routerResource<T>(source: Resource<T>): Resource<T> & {reload():
injector,
);
const res = resourceFromSnapshots(snapshotSignal) as Resource<T> & {reload(): boolean};
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;
}
if (typeof (source as any).reload === 'function') {
res.reload = function (): boolean {
@@ -99,10 +126,18 @@ function createTransactionalSnapshot<T>(
// Freeze the snapshot at the start of navigation to keep the UI stable.
frozenSnapshot.set(source.snapshot());
}
} else if (e instanceof NavigationEnd || e instanceof NavigationSkipped) {
// Navigation succeeded or was skipped, so we can unfreeze and use the live state.
} else if (e instanceof NavigationEnd) {
// Navigation succeeded, so we can unfreeze and use the live state.
frozenSnapshot.set(null);
isRollbackRecoveryPending.set(false);
} else if (e instanceof NavigationSkipped) {
// If a navigation is skipped while we have a frozen snapshot (e.g. navigating to the
// current URL to cancel an in-flight navigation), the in-flight navigation is aborted
// and parameter rollback begins. We must maintain the frozen snapshot until the rollback
// recovery load completes to prevent flashing a loading state.
if (frozenSnapshot() !== null) {
isRollbackRecoveryPending.set(true);
}
} else if (e instanceof NavigationCancel || e instanceof NavigationError) {
const isRollback =
e instanceof NavigationError ||
@@ -120,7 +155,12 @@ function createTransactionalSnapshot<T>(
effect(
() => {
if (isRollbackRecoveryPending() && !source.isLoading()) {
if (
isRollbackRecoveryPending() &&
// TODO(consider): should this be hasValue || status !== loading
// Some stream implementations may retain loading status after first item resolves
!source.isLoading()
) {
isRollbackRecoveryPending.set(false);
frozenSnapshot.set(null);
}
@@ -10,10 +10,14 @@ import {InjectionToken} from '@angular/core';
import {OperatorFunction} from 'rxjs';
import type {NavigationTransition} from './navigation_transition';
export interface RouterResourcesFeature {
operator(): OperatorFunction<NavigationTransition, NavigationTransition>;
import type {ActivatedRoute} from './router_state';
export interface RouterResourcesFeatureImplementation {
setupAndRunResources(
abortSignal: AbortSignal,
): OperatorFunction<NavigationTransition, NavigationTransition>;
}
export const ROUTER_RESOURCES_FEATURE = new InjectionToken<RouterResourcesFeature>(
typeof ngDevMode === 'undefined' || ngDevMode ? 'Router resources feature' : '',
export const ROUTER_RESOURCES_FEATURE = new InjectionToken<RouterResourcesFeatureImplementation>(
typeof ngDevMode === 'undefined' || ngDevMode ? 'Router Resources Feature' : '',
);
+92 -5
View File
@@ -6,11 +6,19 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {EnvironmentInjector, Type} from '@angular/core';
import {
computed,
EnvironmentInjector,
signal,
Signal,
Type,
WritableSignal,
ɵWritable as Writable,
} from '@angular/core';
import {BehaviorSubject, Observable, of} from 'rxjs';
import {map} from 'rxjs/operators';
import {Data, ResolveData, Route} from './models';
import {Data, ResourceResult, ResolveData, Route} from './models';
import {convertToParamMap, ParamMap, Params, PRIMARY_OUTLET, RouteTitleKey} from './shared';
import {equalSegments, UrlSegment} from './url_tree';
import {shallowEqual, shallowEqualArrays} from './utils/collection';
@@ -154,13 +162,45 @@ export class ActivatedRoute {
/** An observable of the static and resolved data of this route. */
public data: Observable<Data>;
// =================================
// ===== Resource integration ======
// =================================
// Note for framework developers: Unlike `data` and `params`, the `resources` property
// is assigned once when the route is first initialized and its reference remains stable
// for the entire lifetime of the `ActivatedRoute` instance. Do NOT replace or swap this
// reference during pending navigations or route reuse, as doing so breaks reactivity
// for components subscribed to the underlying resource signals.
/**
* Injector scoped to the lifetime of this ActivatedRoute object.
* Created only when features tied to ActivatedRoute lifetime are used.
* A map of resources for this route.
*
* @internal
* @experimental
* @internal don't expose while experimental
*/
resources?: ResourceResult;
/** @internal */
_localInjector?: EnvironmentInjector;
/** @internal */
readonly pending?: WritableSignal<boolean>;
// Note for framework developers: `paramsSignal`, `queryParamsSignal`, and `dataSignal`
// do NOT use `{equal: shallowEqual}`. Even though navigations can re-emit structurally
// identical parameters, we rely on the default strict equality (`===`) so that any consumer
// depending on the entire object is correctly invalidated. This encourages developers to properly
// project nested scalar properties (e.g. `paramMap().get('id')`) instead of subscribing to the
// naked object. Furthermore, `shallowEqual` unsafely relies on mutations (`.sort()`) for generic
// arrays, making it unsuitable for signals that might carry complex `data` objects.
/** @internal */
readonly paramsSignal!: Signal<Params>;
/** @internal */
readonly queryParamsSignal!: Signal<Params>;
/** @internal */
readonly paramMapSignal!: Signal<ParamMap>;
/** @internal */
readonly queryParamMapSignal!: Signal<ParamMap>;
/** @internal */
readonly fragmentSignal!: Signal<string | null>;
/** @internal */
readonly dataSignal!: Signal<Data>;
/** @internal */
constructor(
@@ -244,6 +284,12 @@ export class ActivatedRoute {
toString(): string {
return this.snapshot ? this.snapshot.toString() : `Future(${this._futureSnapshot})`;
}
/** @internal */
_setPending(snapshot: ActivatedRouteSnapshot): void {
this._futureSnapshot = snapshot;
this.pending?.set(true);
}
}
export type ParamsInheritanceStrategy = 'emptyOnly' | 'always';
@@ -352,6 +398,12 @@ export class ActivatedRouteSnapshot {
_queryParamMap?: ParamMap;
/** @internal */
readonly _environmentInjector: EnvironmentInjector;
/**
* The result of running the route's resources function.
* @experimental
* @internal
*/
resources?: ResourceResult;
/** The resolved route title */
get title(): string | undefined {
@@ -531,6 +583,41 @@ export function advanceActivatedRoute(route: ActivatedRoute): void {
}
}
export function initializeActivatedRoute(route: ActivatedRoute): void {
if (route.paramsSignal !== undefined) {
return;
}
const writableRoute = route as Writable<ActivatedRoute>;
const pendingSignal = signal(false);
writableRoute.pending = pendingSignal;
writableRoute.paramsSignal = computed(() =>
pendingSignal() || !route.snapshot ? route._futureSnapshot.params : route.snapshot.params,
);
writableRoute.queryParamsSignal = computed(() =>
pendingSignal() || !route.snapshot
? route._futureSnapshot.queryParams
: route.snapshot.queryParams,
);
writableRoute.paramMapSignal = computed(() => convertToParamMap(route.paramsSignal()));
writableRoute.queryParamMapSignal = computed(() => convertToParamMap(route.queryParamsSignal()));
writableRoute.fragmentSignal = computed(() =>
pendingSignal() || !route.snapshot ? route._futureSnapshot.fragment : route.snapshot.fragment,
);
writableRoute.dataSignal = computed(() =>
pendingSignal() || !route.snapshot ? route._futureSnapshot.data : route.snapshot.data,
);
writableRoute._setPending = (snapshot: ActivatedRouteSnapshot) => {
route._futureSnapshot = snapshot;
pendingSignal.set(true);
};
}
export function equalParamsAndUrlSegments(
a: ActivatedRouteSnapshot,
b: ActivatedRouteSnapshot,
@@ -18,10 +18,23 @@ import {
Router,
destroyDetachedRouteHandle,
provideRouter,
ɵwithActivatedRouteInjectors,
ɵwithRouterResources,
ɵResourceContext as ResourceContext,
ɵResourceResult as ResourceResult,
} from '@angular/router';
import {RouterTestingHarness} from '@angular/router/testing';
// TODO: Use the public @angular/router API once exposed
type InternalRoute = Route & {
/**
* A function that returns a map of resources.
* This function is executed during the Main Loading Phase of a navigation.
* @experimental
* @internal
*/
resources?: (ctx: ResourceContext) => ResourceResult | Promise<ResourceResult>;
};
describe('ActivatedRoute local injector', () => {
@Component({
template: 'home',
@@ -63,10 +76,10 @@ describe('ActivatedRoute local injector', () => {
let router: Router;
let strategy: CustomReuseStrategy;
async function setUpRouter(routes: Route[]): Promise<RouterTestingHarness> {
async function setUpRouter(routes: InternalRoute[]): Promise<RouterTestingHarness> {
TestBed.configureTestingModule({
providers: [
provideRouter(routes, ɵwithActivatedRouteInjectors()),
provideRouter(routes, ɵwithRouterResources()),
{provide: RouteReuseStrategy, useClass: CustomReuseStrategy},
],
});
@@ -76,13 +89,13 @@ describe('ActivatedRoute local injector', () => {
return await RouterTestingHarness.create();
}
it('should create and destroy local injector for routes with ɵUseActivatedRouteInjector', async () => {
it('should create and destroy local injector for routes with resources', async () => {
const harness = await setUpRouter([
{
path: 'home',
component: HomeComponent,
'ɵUseActivatedRouteInjector': true,
} as any,
resources: () => ({}),
},
{
path: 'away',
component: AwayComponent,
@@ -107,7 +120,7 @@ describe('ActivatedRoute local injector', () => {
expect(destroyed).toBe(true);
});
it('should NOT create local injector for routes without ɵUseActivatedRouteInjector', async () => {
it('should NOT create local injector for routes without resources', async () => {
const harness = await setUpRouter([
{
path: 'home',
@@ -127,8 +140,8 @@ describe('ActivatedRoute local injector', () => {
{
path: 'home',
component: HomeComponent,
'ɵUseActivatedRouteInjector': true,
} as any,
resources: () => ({}),
},
{
path: 'away',
component: AwayComponent,
@@ -186,13 +199,13 @@ describe('ActivatedRoute local injector', () => {
{
path: 'home',
component: HomeComponent,
'ɵUseActivatedRouteInjector': true,
} as any,
resources: () => ({}),
},
{
path: 'throwing',
component: ThrowingComponent,
'ɵUseActivatedRouteInjector': true,
} as any,
resources: () => ({}),
},
]);
await harness.navigateByUrl('/home');
@@ -0,0 +1,972 @@
/**
* @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 {
Component,
EnvironmentProviders,
resource,
Resource,
signal,
ɵpromiseWithResolvers as promiseWithResolvers,
} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {
provideRouter as internalProvideRouter,
Router,
NavigationError,
withNavigationErrorHandler,
RedirectCommand,
ɵwithRouterResources as withRouterResources,
ɵnonBlocking as nonBlocking,
ActivatedRoute,
Route,
ɵResourceContext as ResourceContext,
ɵResourceResult as ResourceResult,
RouterFeatures,
} from '@angular/router';
import {RouterTestingHarness} from '../testing';
import {timeout, useAutoTick} from '../../private/testing/src/utils';
import {rxResource} from '@angular/core/rxjs-interop';
import {of} from 'rxjs';
import {delay} from 'rxjs/operators';
// TODO: Use the public @angular/router API once exposed
type InternalRoute = Route & {
/**
* A function that returns a map of resources.
* This function is executed during the Main Loading Phase of a navigation.
* @experimental
* @internal
*/
resources?: (ctx: ResourceContext) => ResourceResult | Promise<ResourceResult>;
children?: InternalRoute[];
};
export function provideRouter(
routes: InternalRoute[],
...features: RouterFeatures[]
): EnvironmentProviders {
return internalProvideRouter(routes, ...features);
}
type ActivatedRouteInternal = ActivatedRoute & {
resources?: {[key: string]: Resource<unknown>};
};
describe('Router resources integration', () => {
useAutoTick();
describe('Route Configuration and Execution', () => {
it('should execute resources on initial navigation and expose the result', async () => {
const loaderSpy = jasmine.createSpy('loader').and.resolveTo('loaded');
@Component({standalone: true, template: ''})
class TargetCmp {}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'test',
component: TargetCmp,
resources: () => ({
data: nonBlocking(resource({loader: loaderSpy})),
}),
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
await harness.navigateByUrl('/test');
await harness.fixture.whenStable();
expect(loaderSpy).toHaveBeenCalled();
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
?.resources?.['data'] as any;
expect(resourceRef.value()).toBe('loaded');
});
it('should support async resource functions returning a Promise', async () => {
@Component({standalone: true, template: ''})
class TargetCmp {}
const loaderDeferred = promiseWithResolvers<string>();
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'test',
component: TargetCmp,
resources: async () => {
const data = nonBlocking(
resource({
loader: async () => loaderDeferred.promise,
}),
);
await timeout(10);
return {data};
},
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
await router.navigateByUrl('/test');
expect(router.url).toBe('/test');
const route = router.routerState.root.firstChild as ActivatedRouteInternal;
const resourceRef = route?.resources?.['data'] as any;
expect(resourceRef).toBeDefined();
expect(resourceRef.isLoading()).toBe(true);
expect(resourceRef.value()).toBeUndefined();
loaderDeferred.resolve('async loaded');
await harness.fixture.whenStable();
expect(resourceRef.isLoading()).toBe(false);
expect(resourceRef.value()).toBe('async loaded');
});
xit('should support async resource functions returning a Promise (blocking)', async () => {
@Component({standalone: true, template: ''})
class TargetCmp {}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'test',
component: TargetCmp,
resources: async () => {
const data = resource({loader: async () => 'async loaded'});
await timeout(10);
return {data};
},
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
await harness.navigateByUrl('/test');
await harness.fixture.whenStable();
const route = router.routerState.root.firstChild as ActivatedRouteInternal;
const resourceRef = route?.resources?.['data'] as any;
expect(resourceRef).toBeDefined();
expect(resourceRef.value()).toBe('async loaded');
});
it('should cleanly ignore resolution of async resource function if navigation was cancelled', async () => {
@Component({standalone: true, template: ''})
class FirstCmp {}
@Component({standalone: true, template: ''})
class SecondCmp {}
const firstResources = promiseWithResolvers<void>();
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'first',
component: FirstCmp,
resources: () => {
const data = nonBlocking(resource({loader: async () => 'first data'}));
return firstResources.promise.then(() => ({data}));
},
},
{
path: 'second',
component: SecondCmp,
resources: () => ({
data: nonBlocking(resource({loader: async () => 'second data'})),
}),
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
// Start navigation to /first (which will block waiting on firstResourcesPromise)
const nav1 = harness.navigateByUrl('/first');
await timeout(10);
// Supersede with navigation to /second
await harness.navigateByUrl('/second');
await harness.fixture.whenStable();
expect(router.url).toBe('/second');
// Now resolve the cancelled /first resources promise
firstResources.resolve();
await harness.fixture.whenStable();
// Navigation should remain on /second
expect(router.url).toBe('/second');
});
xit('should cleanly ignore resolution of async resource function if navigation was cancelled (blocking)', async () => {
@Component({standalone: true, template: ''})
class FirstCmp {}
@Component({standalone: true, template: ''})
class SecondCmp {}
const firstResources = promiseWithResolvers<void>();
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'first',
component: FirstCmp,
resources: () => {
const data = resource({loader: async () => 'first data'});
return firstResources.promise.then(() => ({data}));
},
},
{
path: 'second',
component: SecondCmp,
resources: () => ({
data: resource({loader: async () => 'second data'}),
}),
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
// Start navigation to /first (which will block waiting on firstResourcesPromise)
const nav1 = harness.navigateByUrl('/first');
await timeout(10);
// Supersede with navigation to /second
await harness.navigateByUrl('/second');
await harness.fixture.whenStable();
expect(router.url).toBe('/second');
// Now resolve the cancelled /first resources promise
firstResources.resolve();
await harness.fixture.whenStable();
// Navigation should remain on /second
expect(router.url).toBe('/second');
});
it('should not recreate and re-execute resources on subsequent navigations to the same route', async () => {
let callCount = 0;
@Component({standalone: true, template: ''})
class TargetCmp {}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'test/:id',
component: TargetCmp,
resources: (ctx) => ({
data: nonBlocking(
resource({
params: () => ctx.params(),
loader: async () => {
callCount++;
return 'loaded';
},
}),
),
}),
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
await harness.navigateByUrl('/test/1');
expect(callCount).toBe(1);
// Navigating to the identical URL should not trigger a refetch
await harness.navigateByUrl('/test/1');
expect(callCount).toBe(1);
});
it('should support resources on componentless routes', async () => {
@Component({standalone: true, template: '', selector: 'child-cmp-componentless'})
class ChildCmp {}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'parent',
resources: () => ({
parentData: nonBlocking(resource({loader: async () => 'parent'})),
}),
children: [
{
path: 'componentless',
resources: () => ({
compData: nonBlocking(resource({loader: async () => 'comp'})),
}),
children: [{path: 'child', component: ChildCmp}],
},
],
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
await harness.navigateByUrl('/parent/componentless/child');
await harness.fixture.whenStable();
await timeout(20);
const parentRoute = router.routerState.root.firstChild!;
const componentlessRoute = parentRoute.firstChild!;
expect(
((parentRoute as ActivatedRouteInternal).resources?.['parentData'] as any).value(),
).toBe('parent');
expect(
((componentlessRoute as ActivatedRouteInternal).resources?.['compData'] as any).value(),
).toBe('comp');
});
it('should throw an error in dev mode if resource function does not return a Resource', async () => {
@Component({standalone: true, template: '', selector: 'target-cmp-throw'})
class TargetCmp {}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'test',
component: TargetCmp,
resources: () => ({
data: {foo: 'bar'} as any,
}),
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
await expectAsync(harness.navigateByUrl('/test')).toBeRejectedWithError(
/Invalid resource returned for key "data"/,
);
});
});
describe('Blocking vs Non-blocking Resources', () => {
xit('should resolve resources before component initialization if blocking', async () => {
let resolverSpy = jasmine.createSpy('resolver');
const deferred = promiseWithResolvers<string>();
@Component({standalone: true, template: ''})
class TestCmp {}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'test',
component: TestCmp,
resources: () => ({
data: resource({
loader: async () => {
resolverSpy();
return await deferred.promise;
},
}),
}),
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
let completed = false;
const navPromise = harness.navigateByUrl('/test').then(() => {
completed = true;
});
await timeout(10);
expect(completed).toBe(false);
expect(router.url).toBe('/');
expect(resolverSpy).toHaveBeenCalled();
deferred.resolve('resolved');
await navPromise;
expect(completed).toBe(true);
expect(router.url).toBe('/test');
});
xit('should cancel navigation when blocking resource yields error', async () => {
@Component({standalone: true, template: ''})
class TargetCmp {}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'test',
component: TargetCmp,
resources: () => ({
data: resource({
loader: () => Promise.reject('test error'),
}),
}),
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
await harness.navigateByUrl('/test').catch(() => {});
expect(router.url).not.toContain('/test');
});
xit('should emit NavigationError when blocking resource rejects', async () => {
@Component({standalone: true, template: ''})
class TargetCmp {}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'test',
component: TargetCmp,
resources: () => ({
data: resource({
loader: () => Promise.reject('test error'),
}),
}),
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
const error = await new Promise((resolve) => {
router.events.subscribe((e) => {
if (e instanceof NavigationError) resolve(e.error);
});
harness.navigateByUrl('/test').catch(() => {});
});
expect(typeof error).toBe('object');
expect(error).toBeDefined();
});
xit('should allow retrying a blocking route that previously threw an error', async () => {
let shouldError = true;
@Component({standalone: true, template: ''})
class TargetCmp {}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'test/:id',
component: TargetCmp,
resources: (ctx) => ({
data: resource({
params: () => ctx.params(),
loader: async ({params}: any) => {
if (shouldError) throw new Error('Failed');
return params['id'];
},
}),
}),
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
// First navigation fails
await harness.navigateByUrl('/test/1').catch(() => {});
expect(router.url).toBe('/'); // Cancelled
await harness.fixture.whenStable();
// Retry the identical route with same parameters
shouldError = false;
await harness.navigateByUrl('/test/1');
await harness.fixture.whenStable();
expect(router.url).toBe('/test/1'); // Succeeded!
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
?.resources?.['data'] as any;
expect(resourceRef.value()).toBe('1');
});
it('should complete navigation and expose error for non-blocking resources', async () => {
@Component({standalone: true, template: ''})
class TargetCmp {}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'test',
component: TargetCmp,
resources: () => ({
data: nonBlocking(
resource({
loader: async () => {
throw new Error('Non-blocking error');
},
}),
),
}),
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
// Non-blocking resource error doesn't cancel navigation
await harness.navigateByUrl('/test');
await harness.fixture.whenStable();
expect(router.url).toBe('/test');
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
?.resources?.['data'] as any;
expect(resourceRef.error()?.message).toBe('Non-blocking error');
expect(resourceRef.isLoading()).toBe(false);
});
});
describe('Integration with Router Features', () => {
it('should work with resolvers', async () => {
@Component({standalone: true, template: ''})
class TargetCmp {}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'test',
component: TargetCmp,
resolve: {id: () => '123'},
resources: (ctx) => ({
data: nonBlocking(
resource({
params: () => ctx.data(),
loader: async ({params}: any) => ({name: `user ${params['id']}`}),
}),
),
}),
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
await harness.navigateByUrl('/test');
await harness.fixture.whenStable();
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
?.resources?.['data'] as any;
await timeout(20);
expect(resourceRef.value()).toEqual({name: 'user 123'});
});
it('should rollback parameter state on failed navigation', async () => {
let canActivate = true;
@Component({standalone: true, template: ''})
class TargetCmp {}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'test/:id',
component: TargetCmp,
canActivate: [
async () => {
await timeout(10);
return canActivate;
},
],
resources: (ctx) => ({
data: nonBlocking(
resource({
params: () => ctx.params(),
loader: async ({params}: any) => params['id'],
}),
),
}),
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
await harness.navigateByUrl('/test/1');
await harness.fixture.whenStable();
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
?.resources?.['data'] as any;
expect(resourceRef.value()).toBe('1');
// Fail next navigation
canActivate = false;
await harness.navigateByUrl('/test/2');
await harness.fixture.whenStable();
// The navigation is cancelled so the resource should retain the old value without loading flicker.
expect(resourceRef.value()).toBe('1');
expect(resourceRef.isLoading()).toBe(false);
});
it('should abort previous request via AbortSignal when a new navigation comes in', async () => {
@Component({standalone: true, template: ''})
class TargetCmp {}
const deferred = promiseWithResolvers<{name: string}>();
let aborted = false;
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'user/:id',
component: TargetCmp,
resources: (ctx) => ({
user: nonBlocking(
resource({
params: () => ctx.params(),
loader: async ({params, abortSignal}: any) => {
abortSignal.addEventListener('abort', () => (aborted = true));
if (params['id'] === '1') return deferred.promise;
return {name: 'user 2'};
},
}),
),
}),
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
harness.navigateByUrl('/user/1');
await timeout(10);
await harness.navigateByUrl('/user/2');
await harness.fixture.whenStable();
expect(aborted).toBe(true);
const userResource = (router.routerState.root.firstChild as ActivatedRouteInternal)
?.resources?.['user'] as any;
expect(userResource?.value()).toEqual({name: 'user 2'});
// Resolving the old promise should have no effect
deferred.resolve({name: 'user 1'});
await timeout(10);
expect(userResource.value()).toEqual({name: 'user 2'});
});
xit('should correctly propagate parameter state when a pending navigation supersedes identically reused routes', async () => {
@Component({standalone: true, template: ''})
class TargetCmp {}
const p2 = new Promise(() => {}); // never resolves
const p3 = promiseWithResolvers<string>();
let loadedParams: any[] = [];
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'test/:id',
component: TargetCmp,
resources: (ctx: any) => ({
data: resource({
params: () => ctx.params(),
loader: async ({params}: any) => {
loadedParams.push(params['id']);
if (params['id'] === '2') return p2;
if (params['id'] === '3') return p3.promise;
return params['id'];
},
}),
}),
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
await harness.navigateByUrl('/test/1');
// Trigger nav2 and let it pend.
harness.navigateByUrl('/test/2');
await timeout(10);
// Supersede with identical route (/test/2 -> /test/3)
const nav3 = harness.navigateByUrl('/test/3');
await timeout(10);
expect(loadedParams).toEqual(['1', '2', '3']);
p3.resolve('loaded-3');
await nav3;
await harness.fixture.whenStable();
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
?.resources?.['data'] as any;
expect(resourceRef.value()).toBe('loaded-3');
});
xit('should mask loading states during multi-step Guard UrlTree redirects', async () => {
@Component({standalone: true, template: ''})
class TargetCmp {}
let loader = promiseWithResolvers<string>();
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'target/:id',
component: TargetCmp,
resources: (ctx: any) => ({
data: resource({
params: () => ctx.params(),
loader: async () => loader.promise,
}),
}),
},
{
path: 'bad-link',
canActivate: [() => TestBed.inject(Router).createUrlTree(['/target/3'])],
component: TargetCmp,
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
// Settle initial state
loader.resolve('1');
await harness.navigateByUrl('/target/1');
await harness.fixture.whenStable();
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
?.resources?.['data'] as any;
expect(resourceRef.value()).toBe('1');
expect(resourceRef.isLoading()).toBe(false);
loader = promiseWithResolvers<string>();
// Initiate a navigation to a link that Redirects using a UrlTree Guard.
const nav2 = harness.navigateByUrl('/bad-link');
await timeout(50);
// UI is still masked looking like '1'
expect(resourceRef.isLoading()).toBe(false);
expect(resourceRef.value()).toBe('1');
loader.resolve('3');
await nav2;
await harness.fixture.whenStable();
expect(resourceRef.isLoading()).toBe(false);
expect(resourceRef.value()).toBe('3');
});
xit('should be able to redirect from a blocking resource using a NavigationErrorHandler', async () => {
let handleCount = 0;
let errorRef: unknown = null;
@Component({standalone: true, template: ''})
class TargetCmp {}
@Component({standalone: true, template: ''})
class ErrorCmp {}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'test',
component: TargetCmp,
resources: () => ({
data: resource({
loader: async () => {
throw new Error('Resource failed!');
},
}),
}),
},
{
path: 'error',
component: ErrorCmp,
},
],
withNavigationErrorHandler((e: NavigationError) => {
handleCount++;
errorRef = e.error;
return new RedirectCommand(TestBed.inject(Router).parseUrl('/error'));
}),
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
await harness.navigateByUrl('/test');
expect(router.url).toBe('/error');
expect(handleCount).toBe(1);
expect((errorRef as Error).message).toBe('Resource failed!');
});
});
describe('rxResource Integration', () => {
xit('should successfully wrap and await an rxResource', async () => {
@Component({standalone: true, template: ''})
class TargetCmp {}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'rx/:id',
component: TargetCmp,
resources: (ctx) => ({
data: rxResource({
params: () => ctx.params(),
stream: ({params}: any) => of(`rx loaded ${params['id']}`).pipe(delay(10)),
}),
}),
},
],
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
const nav = harness.navigateByUrl('/rx/123');
await timeout(5);
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
?.resources?.['data'] as any;
expect(resourceRef.isLoading()).toBe(true);
expect(resourceRef.value()).toBeUndefined();
await nav;
await harness.fixture.whenStable();
expect(resourceRef.isLoading()).toBe(false);
expect(resourceRef.value()).toBe('rx loaded 123');
});
});
});