From 80a04eba5293b8d9dfeee40366b0ff73a29248c6 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Fri, 14 Aug 2026 10:08:44 -0700 Subject: [PATCH] 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 --- .../platform-server-hydration/size.json | 4 +- .../router/bundle.golden_symbols.json | 5 +- packages/router/src/create_router_state.ts | 5 +- packages/router/src/models.ts | 57 + packages/router/src/navigation_transition.ts | 35 +- .../src/operators/setup_and_run_resources.ts | 165 ++- packages/router/src/private_export.ts | 4 +- packages/router/src/provide_router.ts | 39 +- packages/router/src/router_resource.ts | 50 +- .../router/src/router_resource_feature.ts | 12 +- packages/router/src/router_state.ts | 97 +- .../test/activated_route_injector.spec.ts | 39 +- packages/router/test/router_resource_spec.ts | 972 ++++++++++++++++++ 13 files changed, 1392 insertions(+), 92 deletions(-) create mode 100644 packages/router/test/router_resource_spec.ts diff --git a/integration/platform-server-hydration/size.json b/integration/platform-server-hydration/size.json index 87835fe9d82..34c6400d7bb 100644 --- a/integration/platform-server-hydration/size.json +++ b/integration/platform-server-hydration/size.json @@ -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 } diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 403c184cff3..e37b940eaf7 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -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", diff --git a/packages/router/src/create_router_state.ts b/packages/router/src/create_router_state.ts index c7f1210c682..2e8ba428c34 100644 --- a/packages/router/src/create_router_state.ts +++ b/packages/router/src/create_router_state.ts @@ -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(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), diff --git a/packages/router/src/models.ts b/packages/router/src/models.ts index 15243ff480d..ae67d473673 100644 --- a/packages/router/src/models.ts +++ b/packages/router/src/models.ts @@ -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>; + +// 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; + /** + * The query parameters of the route. + * + * @experimental + */ + queryParams: Signal; + /** + * The URL fragment. + * @experimental + */ + fragment: Signal; + /** + * Data provided in the route configuration. + * + * @experimental + */ + data: Signal>; + /** + * 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; + /** * An array of child `Route` objects that specifies a nested route * configuration. diff --git a/packages/router/src/navigation_transition.ts b/packages/router/src/navigation_transition.ts index d680f173b6a..bf09b14bb44 100644 --- a/packages/router/src/navigation_transition.ts +++ b/packages/router/src/navigation_transition.ts @@ -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).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) => { + node.value.pending?.set(false); + node.children.forEach(traverse); + }; + traverse(targetRouterState._root); } diff --git a/packages/router/src/operators/setup_and_run_resources.ts b/packages/router/src/operators/setup_and_run_resources.ts index ec7c47ad50f..b1d90ce17ff 100644 --- a/packages/router/src/operators/setup_and_run_resources.ts +++ b/packages/router/src/operators/setup_and_run_resources.ts @@ -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) => { - const route = stateNode.value; - if (route) { - processRoute(route, newlyCreatedRoutes); +export function setupAndRunResources( + abortSignal: AbortSignal, +): OperatorFunction { + return pipe( + switchTap(({newlyCreatedRoutes, targetRouterState}) => { + if (!newlyCreatedRoutes || !targetRouterState) { + return; } - for (const childState of stateNode.children) { - traverse(childState); - } - }; + const resourceSetupPromises: Array> = []; - traverse(targetRouterState._root); - }); + const traverse = (stateNode: TreeNode) => { + 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) { - // Only create injectors for routes with the feature enabled - const useActivatedRouteInjector = (route?.routeConfig as any)?.ɵUseActivatedRouteInjector; - if (!useActivatedRouteInjector) { +function processRoute( + route: ActivatedRoute, + newlyCreatedRoutes: Set, + resourceSetupPromises: Array>, + 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>).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); } diff --git a/packages/router/src/private_export.ts b/packages/router/src/private_export.ts index 8cafb32b651..b506699c3fd 100644 --- a/packages/router/src/private_export.ts +++ b/packages/router/src/private_export.ts @@ -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'; diff --git a/packages/router/src/provide_router.ts b/packages/router/src/provide_router.ts index cd2d42af41c..9f018feb215 100644 --- a/packages/router/src/provide_router.ts +++ b/packages/router/src/provide_router.ts @@ -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; +/** + * A type alias for providers returned by `withRouterResources` for use with `provideRouter`. + * + * @see {@link withRouterResources} + * @see {@link provideRouter} + * + * @experimental + */ +export type RouterResourcesFeature = RouterFeature; + +/** + * 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, }, }, ]; diff --git a/packages/router/src/router_resource.ts b/packages/router/src/router_resource.ts index 7b845fe69c0..061f4297ff5 100644 --- a/packages/router/src/router_resource.ts +++ b/packages/router/src/router_resource.ts @@ -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 extends Resource { + [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>(res: R): R { + (res as unknown as InternalRouterResource)[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(source: Resource): Resource & {reload(): injector, ); - const res = resourceFromSnapshots(snapshotSignal) as Resource & {reload(): boolean}; + const res = resourceFromSnapshots(snapshotSignal) as unknown as InternalRouterResource; + + if ((source as unknown as InternalRouterResource)[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( // 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( 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); } diff --git a/packages/router/src/router_resource_feature.ts b/packages/router/src/router_resource_feature.ts index 78fd72f768a..e466573dcd0 100644 --- a/packages/router/src/router_resource_feature.ts +++ b/packages/router/src/router_resource_feature.ts @@ -10,10 +10,14 @@ import {InjectionToken} from '@angular/core'; import {OperatorFunction} from 'rxjs'; import type {NavigationTransition} from './navigation_transition'; -export interface RouterResourcesFeature { - operator(): OperatorFunction; +import type {ActivatedRoute} from './router_state'; + +export interface RouterResourcesFeatureImplementation { + setupAndRunResources( + abortSignal: AbortSignal, + ): OperatorFunction; } -export const ROUTER_RESOURCES_FEATURE = new InjectionToken( - typeof ngDevMode === 'undefined' || ngDevMode ? 'Router resources feature' : '', +export const ROUTER_RESOURCES_FEATURE = new InjectionToken( + typeof ngDevMode === 'undefined' || ngDevMode ? 'Router Resources Feature' : '', ); diff --git a/packages/router/src/router_state.ts b/packages/router/src/router_state.ts index 2923541ef63..2605e03b166 100644 --- a/packages/router/src/router_state.ts +++ b/packages/router/src/router_state.ts @@ -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; + // ================================= + // ===== 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; + // 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; + /** @internal */ + readonly queryParamsSignal!: Signal; + /** @internal */ + readonly paramMapSignal!: Signal; + /** @internal */ + readonly queryParamMapSignal!: Signal; + /** @internal */ + readonly fragmentSignal!: Signal; + /** @internal */ + readonly dataSignal!: Signal; /** @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; + 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, diff --git a/packages/router/test/activated_route_injector.spec.ts b/packages/router/test/activated_route_injector.spec.ts index b02e15c5ec5..7e786d78840 100644 --- a/packages/router/test/activated_route_injector.spec.ts +++ b/packages/router/test/activated_route_injector.spec.ts @@ -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; +}; + 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 { + async function setUpRouter(routes: InternalRoute[]): Promise { 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'); diff --git a/packages/router/test/router_resource_spec.ts b/packages/router/test/router_resource_spec.ts new file mode 100644 index 00000000000..7d89901acc9 --- /dev/null +++ b/packages/router/test/router_resource_spec.ts @@ -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; + children?: InternalRoute[]; +}; + +export function provideRouter( + routes: InternalRoute[], + ...features: RouterFeatures[] +): EnvironmentProviders { + return internalProvideRouter(routes, ...features); +} + +type ActivatedRouteInternal = ActivatedRoute & { + resources?: {[key: string]: Resource}; +}; + +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(); + + 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(); + + 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(); + + 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(); + + @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(); + + 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(); + + 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(); + + // 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'); + }); + }); +});