From 35102e50f2d9d6d43e7bb9ed56183107e4eda699 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 13 Aug 2026 10:22:30 -0700 Subject: [PATCH 1/2] refactor(router): rename activated route injector feature to resource feature Activated Route Injector feature was developed for resources. This moves the internal naming (files and vars) to reflect this now that we have landed the core bits of plumbing and are ready to implement router resources. --- packages/router/src/navigation_transition.ts | 6 +++--- ...d_route_injectors.ts => setup_and_run_resources.ts} | 0 packages/router/src/private_export.ts | 2 +- packages/router/src/provide_router.ts | 10 +++++----- ..._injector_feature.ts => router_resource_feature.ts} | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) rename packages/router/src/operators/{setup_activated_route_injectors.ts => setup_and_run_resources.ts} (100%) rename packages/router/src/{activated_route_injector_feature.ts => router_resource_feature.ts} (64%) diff --git a/packages/router/src/navigation_transition.ts b/packages/router/src/navigation_transition.ts index 1a83bb91124b..d680f173b6aa 100644 --- a/packages/router/src/navigation_transition.ts +++ b/packages/router/src/navigation_transition.ts @@ -83,7 +83,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 {ACTIVATED_ROUTE_INJECTOR_FEATURE} from './activated_route_injector_feature'; +import {ROUTER_RESOURCES_FEATURE} from './router_resource_feature'; /** * @description @@ -369,7 +369,7 @@ export class NavigationTransitions { private readonly urlHandlingStrategy = inject(UrlHandlingStrategy); private readonly createViewTransition = inject(CREATE_VIEW_TRANSITION, {optional: true}); private readonly navigationErrorHandler = inject(NAVIGATION_ERROR_HANDLER, {optional: true}); - private readonly activatedRouteInjectorFeature = inject(ACTIVATED_ROUTE_INJECTOR_FEATURE, { + private readonly routerResourcesFeature = inject(ROUTER_RESOURCES_FEATURE, { optional: true, }); @@ -765,7 +765,7 @@ export class NavigationTransitions { return of(t); }), - this.activatedRouteInjectorFeature?.operator() ?? ((t) => t), + this.routerResourcesFeature?.operator() ?? ((t) => t), switchTap(() => this.afterPreactivation()), diff --git a/packages/router/src/operators/setup_activated_route_injectors.ts b/packages/router/src/operators/setup_and_run_resources.ts similarity index 100% rename from packages/router/src/operators/setup_activated_route_injectors.ts rename to packages/router/src/operators/setup_and_run_resources.ts diff --git a/packages/router/src/private_export.ts b/packages/router/src/private_export.ts index 1094335fafee..8cafb32b651b 100644 --- a/packages/router/src/private_export.ts +++ b/packages/router/src/private_export.ts @@ -11,4 +11,4 @@ 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 {withActivatedRouteInjectors as ɵwithActivatedRouteInjectors} from './provide_router'; +export {withRouterResources as ɵwithActivatedRouteInjectors} from './provide_router'; diff --git a/packages/router/src/provide_router.ts b/packages/router/src/provide_router.ts index ab73bfe0f612..cd2d42af41cc 100644 --- a/packages/router/src/provide_router.ts +++ b/packages/router/src/provide_router.ts @@ -63,8 +63,8 @@ import { VIEW_TRANSITION_OPTIONS, ViewTransitionsFeatureOptions, } from './utils/view_transition'; -import {ACTIVATED_ROUTE_INJECTOR_FEATURE} from './activated_route_injector_feature'; -import {setupActivatedRouteInjectors} from './operators/setup_activated_route_injectors'; +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. @@ -888,12 +888,12 @@ export function withViewTransitions( return routerFeature(RouterFeatureKind.ViewTransitionsFeature, providers); } -export type ActivatedRouteInjectorFeature = +export type RouterResourcesFeature = RouterFeature; -export function withActivatedRouteInjectors(): ActivatedRouteInjectorFeature { +export function withRouterResources(): RouterResourcesFeature { const providers = [ { - provide: ACTIVATED_ROUTE_INJECTOR_FEATURE, + provide: ROUTER_RESOURCES_FEATURE, useValue: { operator: setupActivatedRouteInjectors, }, diff --git a/packages/router/src/activated_route_injector_feature.ts b/packages/router/src/router_resource_feature.ts similarity index 64% rename from packages/router/src/activated_route_injector_feature.ts rename to packages/router/src/router_resource_feature.ts index 016d54cfa231..78fd72f768aa 100644 --- a/packages/router/src/activated_route_injector_feature.ts +++ b/packages/router/src/router_resource_feature.ts @@ -10,10 +10,10 @@ import {InjectionToken} from '@angular/core'; import {OperatorFunction} from 'rxjs'; import type {NavigationTransition} from './navigation_transition'; -export interface ActivatedRouteInjectorFeature { +export interface RouterResourcesFeature { operator(): OperatorFunction; } -export const ACTIVATED_ROUTE_INJECTOR_FEATURE = new InjectionToken( - typeof ngDevMode === 'undefined' || ngDevMode ? 'ActivatedRoute injector feature' : '', +export const ROUTER_RESOURCES_FEATURE = new InjectionToken( + typeof ngDevMode === 'undefined' || ngDevMode ? 'Router resources feature' : '', ); From 7308452b8212ea8583c48d8177a6988d52e97c87 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Fri, 14 Aug 2026 10:08:44 -0700 Subject: [PATCH 2/2] refactor(router): add support for non-blocking router resources 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. --- goldens/public-api/router/index.api.md | 2 + .../public-api/router/testing/index.api.md | 1 + .../router/bundle.golden_symbols.json | 4 +- 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 | 167 +++- packages/router/src/private_export.ts | 4 +- packages/router/src/provide_router.ts | 39 +- packages/router/src/router_resource.ts | 54 +- .../router/src/router_resource_feature.ts | 10 +- packages/router/src/router_state.ts | 96 +- .../test/activated_route_injector.spec.ts | 39 +- packages/router/test/router_resource_spec.ts | 882 ++++++++++++++++++ 14 files changed, 1306 insertions(+), 89 deletions(-) create mode 100644 packages/router/test/router_resource_spec.ts diff --git a/goldens/public-api/router/index.api.md b/goldens/public-api/router/index.api.md index 1a8ea5faaa21..993b5d9b367b 100644 --- a/goldens/public-api/router/index.api.md +++ b/goldens/public-api/router/index.api.md @@ -26,6 +26,7 @@ import { Provider } from '@angular/core'; import { ProviderToken } from '@angular/core'; import { QueryList } from '@angular/core'; import { Renderer2 } from '@angular/core'; +import { Resource } from '@angular/core'; import { Signal } from '@angular/core'; import { SimpleChanges } from '@angular/core'; import { Title } from '@angular/platform-browser'; @@ -71,6 +72,7 @@ export class ActivatedRouteSnapshot { // (undocumented) get queryParamMap(): ParamMap; queryParams: Params; + resources?: ResourceResult; get root(): ActivatedRouteSnapshot; readonly routeConfig: Route | null; get title(): string | undefined; diff --git a/goldens/public-api/router/testing/index.api.md b/goldens/public-api/router/testing/index.api.md index cd677b53c55c..c3d0d3767dc9 100644 --- a/goldens/public-api/router/testing/index.api.md +++ b/goldens/public-api/router/testing/index.api.md @@ -26,6 +26,7 @@ import { Provider } from '@angular/core'; import { ProviderToken } from '@angular/core'; import { QueryList } from '@angular/core'; import { Renderer2 } from '@angular/core'; +import { Resource } from '@angular/core'; import { Signal } from '@angular/core'; import { SimpleChanges } from '@angular/core'; import { Type } from '@angular/core'; diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 403c184cff3d..35703ef11159 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", @@ -1045,6 +1044,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 c7f1210c6821..2e8ba428c34b 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 15243ff480d0..26dc88d26123 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 map 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 d680f173b6aa..3d20b2be5c12 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 @@ -723,6 +724,7 @@ export class NavigationTransitions { switchTap((t: NavigationTransition) => { const loadComponents = (route: ActivatedRouteSnapshot): Array> => { const loaders: Array> = []; + if (route.routeConfig?._loadedComponent) { route.component = route.routeConfig?._loadedComponent; } else if (route.routeConfig?.loadComponent) { @@ -765,11 +767,9 @@ export class NavigationTransitions { return of(t); }), - this.routerResourcesFeature?.operator() ?? ((t) => t), - + this.routerResourcesFeature?.operator(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 +813,12 @@ export class NavigationTransitions { return; } + const traverse = (node: TreeNode) => { + node.value.pending?.set(false); + node.children.forEach(traverse); + }; + traverse(t.targetRouterState!._root); + completedOrAborted = true; this.currentNavigation.update((nav) => { (nav as Writable).abort = noop; @@ -832,7 +838,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 +899,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 +1002,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 +1056,16 @@ export function isBrowserTriggeredNavigation(source: NavigationTrigger) { return source !== IMPERATIVE_NAVIGATION; } -function discardNewActivatedRoutes(t: NavigationTransition): void { - if (!t.newlyCreatedRoutes) { - return; - } - for (const r of t.newlyCreatedRoutes) { +function rollbackState(t: NavigationTransition): void { + for (const r of t.newlyCreatedRoutes ?? []) { r._localInjector?.destroy(); + r._localInjector = undefined; + } + if (t.targetRouterState) { + const traverse = (node: TreeNode) => { + node.value.pending?.set(false); + node.children.forEach(traverse); + }; + traverse(t.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 ec7c47ad50f0..8d2bfaea93d8 100644 --- a/packages/router/src/operators/setup_and_run_resources.ts +++ b/packages/router/src/operators/setup_and_run_resources.ts @@ -5,60 +5,149 @@ * 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'; - -export function setupActivatedRouteInjectors(): OperatorFunction< - NavigationTransition, - NavigationTransition -> { - return tap(({newlyCreatedRoutes, targetRouterState}) => { - if (!newlyCreatedRoutes || !targetRouterState) { - return; - } +import {ActivatedRoute, ActivatedRouteSnapshot, initializeActivatedRoute} from '../router_state'; +import {TreeNode} from '../utils/tree'; +import {InternalRouterResource, NON_BLOCKING_SYMBOL, routerResource} from '../router_resource'; +import {switchTap} from './switch_tap'; - // 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> = []; + + const traverse = (stateNode: TreeNode) => { + const route = stateNode.value; + if (route) { + initializeActivatedRoute(route); + processRoute(route, newlyCreatedRoutes, resourceSetupPromises); + } + + for (const childState of stateNode.children) { + traverse(childState); + } + }; + + traverse(targetRouterState._root); + + function throwIfAborted() { + if (abortSignal.aborted) { + throw new Error(abortSignal.reason); + } } - }; - traverse(targetRouterState._root); - }); + return Promise.all(resourceSetupPromises).then(throwIfAborted); + // 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>, +) { + 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)); } 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) { + 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 (!route.pending?.()) return; + } else { + resourceResult = resourceResultRaw as ResourceResult; + } + + if (!resourceResult) return; + + const wrappedResult: ResourceResult = {}; + for (const [key, r] of Object.entries(resourceResult)) { + let res = r; + + 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}.`, + ); + } + } + + res = runInInjectionContext(childInjector, () => routerResource(res)); + wrappedResult[key] = 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[NON_BLOCKING_SYMBOL]) { + 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 8cafb32b651b..b506699c3fd1 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 cd2d42af41cc..68006272810b 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, + operator: setupAndRunResources, }, }, ]; diff --git a/packages/router/src/router_resource.ts b/packages/router/src/router_resource.ts index 7b845fe69c09..11441e3547a8 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,32 @@ import { NavigationCancellationCode, } from './events'; +export const NON_BLOCKING_SYMBOL: unique symbol = Symbol( + typeof ngDevMode === 'undefined' || ngDevMode ? '__isNonBlocking' : '', +); +export const BLOCKING_SYMBOL: unique symbol = Symbol( + typeof ngDevMode === 'undefined' || ngDevMode ? '__isBlocking' : '', +); + +/** + * @internal + */ +export interface InternalRouterResource extends Resource { + [NON_BLOCKING_SYMBOL]?: boolean; + [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)[NON_BLOCKING_SYMBOL] = true; + 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 +70,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)[NON_BLOCKING_SYMBOL]) { + res[NON_BLOCKING_SYMBOL] = true; + } else { + res[BLOCKING_SYMBOL] = true; + } if (typeof (source as any).reload === 'function') { res.reload = function (): boolean { @@ -99,10 +130,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 +159,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 78fd72f768aa..ecb7938f877b 100644 --- a/packages/router/src/router_resource_feature.ts +++ b/packages/router/src/router_resource_feature.ts @@ -10,10 +10,12 @@ 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 { + operator(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 2923541ef63d..70981ca97375 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,11 @@ export class ActivatedRouteSnapshot { _queryParamMap?: ParamMap; /** @internal */ readonly _environmentInjector: EnvironmentInjector; + /** + * The result of running the route's resources function. + * @experimental + */ + resources?: ResourceResult; /** The resolved route title */ get title(): string | undefined { @@ -531,6 +582,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 b02e15c5ec5b..7e786d78840d 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 000000000000..a926abb1660c --- /dev/null +++ b/packages/router/test/router_resource_spec.ts @@ -0,0 +1,882 @@ +/** + * @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} 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'); + }); + + xit('should support async resource functions returning a Promise', 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'); + }); + + xit('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 {} + + let resolveFirstResources!: (value: any) => void; + const firstResourcesPromise = new Promise((resolve) => { + resolveFirstResources = resolve; + }); + + TestBed.configureTestingModule({ + providers: [ + provideRouter( + [ + { + path: 'first', + component: FirstCmp, + resources: () => { + const data = resource({loader: async () => 'first data'}); + return firstResourcesPromise.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 + resolveFirstResources({}); + 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 () => { + let callCount = 0; + @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 () => { + callCount++; + return '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); + expect(callCount).toBe(1); + + 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(); + let error: any; + try { + await harness.navigateByUrl('/test'); + } catch (e) { + error = e; + } + expect(error.message).toContain('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'); + let resolve!: (val: any) => void; + const promise = new Promise((r) => (resolve = r)); + + @Component({standalone: true, template: ''}) + class TestCmp {} + + TestBed.configureTestingModule({ + providers: [ + provideRouter( + [ + { + path: 'test', + component: TestCmp, + resources: () => ({ + data: resource({ + loader: async () => { + resolverSpy(); + return await 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(); + + 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 {} + + let resolve!: (val: any) => void; + const promise = new Promise((r) => (resolve = r)); + 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 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 + 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 {} + + let resolveThird!: (v: any) => void; + let p2 = new Promise(() => {}); // never resolves + let p3 = new Promise((r) => (resolveThird = r)); + + 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; + 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']); + + resolveThird('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 resolveLoader!: (val: any) => void; + let promise = new Promise((resolve) => (resolveLoader = resolve)); + + TestBed.configureTestingModule({ + providers: [ + provideRouter( + [ + { + path: 'target/:id', + component: TargetCmp, + resources: (ctx: any) => ({ + data: resource({ + params: () => ctx.params(), + loader: async () => 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 + resolveLoader('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); + + promise = new Promise((resolve) => (resolveLoader = resolve)); + + // 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'); + + resolveLoader('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'); + }); + }); +});