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/activated_route_injector_feature.ts b/packages/router/src/activated_route_injector_feature.ts deleted file mode 100644 index 016d54cfa231..000000000000 --- a/packages/router/src/activated_route_injector_feature.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @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 {InjectionToken} from '@angular/core'; -import {OperatorFunction} from 'rxjs'; -import type {NavigationTransition} from './navigation_transition'; - -export interface ActivatedRouteInjectorFeature { - operator(): OperatorFunction; -} - -export const ACTIVATED_ROUTE_INJECTOR_FEATURE = new InjectionToken( - typeof ngDevMode === 'undefined' || ngDevMode ? 'ActivatedRoute injector feature' : '', -); 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 1a83bb91124b..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 {ACTIVATED_ROUTE_INJECTOR_FEATURE} from './activated_route_injector_feature'; +import {TreeNode} from './utils/tree'; /** * @description @@ -369,7 +370,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, }); @@ -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.activatedRouteInjectorFeature?.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_activated_route_injectors.ts b/packages/router/src/operators/setup_activated_route_injectors.ts deleted file mode 100644 index ec7c47ad50f0..000000000000 --- a/packages/router/src/operators/setup_activated_route_injectors.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * @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 {OperatorFunction} from 'rxjs'; -import {ActivatedRoute, ActivatedRouteSnapshot} from '../router_state'; -import {TreeNode} from '../utils/tree'; -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; - } - - // 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); - } - - for (const childState of stateNode.children) { - traverse(childState); - } - }; - - traverse(targetRouterState._root); - }); -} - -function processRoute(route: ActivatedRoute, newlyCreatedRoutes: Set) { - // Only create injectors for routes with the feature enabled - const useActivatedRouteInjector = (route?.routeConfig as any)?.ɵUseActivatedRouteInjector; - if (!useActivatedRouteInjector) { - return; - } - - if (newlyCreatedRoutes.has(route)) { - setupNewActivatedRouteInjector(route._futureSnapshot, route); - } else { - // TODO: Do something with injectors that already exist - } -} - -function setupNewActivatedRouteInjector(snapshot: ActivatedRouteSnapshot, route: ActivatedRoute) { - if (ngDevMode && !!route._localInjector) { - throw new Error( - 'invalid state: _localInjector should not exist on newly created ActivatedRoute yet', - ); - } - route._localInjector = createEnvironmentInjector([], snapshot._environmentInjector); -} diff --git a/packages/router/src/operators/setup_and_run_resources.ts b/packages/router/src/operators/setup_and_run_resources.ts new file mode 100644 index 000000000000..8d2bfaea93d8 --- /dev/null +++ b/packages/router/src/operators/setup_and_run_resources.ts @@ -0,0 +1,153 @@ +/** + * @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 {createEnvironmentInjector, runInInjectionContext, Resource} from '@angular/core'; +import {OperatorFunction, pipe} from 'rxjs'; +import {ResourceContext, ResourceResult} from '../models'; +import {NavigationTransition} from '../navigation_transition'; +import {ActivatedRoute, ActivatedRouteSnapshot, initializeActivatedRoute} from '../router_state'; +import {TreeNode} from '../utils/tree'; +import {InternalRouterResource, NON_BLOCKING_SYMBOL, routerResource} from '../router_resource'; +import {switchTap} from './switch_tap'; + +export function setupAndRunResources( + abortSignal: AbortSignal, +): OperatorFunction { + return pipe( + switchTap(({newlyCreatedRoutes, targetRouterState}) => { + if (!newlyCreatedRoutes || !targetRouterState) { + return; + } + + 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); + } + } + + return Promise.all(resourceSetupPromises).then(throwIfAborted); + // TODO: wait for blocking resources + }), + ); +} + +function processRoute( + route: ActivatedRoute, + newlyCreatedRoutes: Set, + resourceSetupPromises: Array>, +) { + const resources = route.routeConfig?.resources; + if (!resources) { + return; + } + + if (newlyCreatedRoutes.has(route)) { + // This route is new. We need to run its resources function once. + resourceSetupPromises.push(setupNewRouterResources(route._futureSnapshot, route)); + } else { + updateExistingResources(route); + } +} + +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'); + } +} diff --git a/packages/router/src/private_export.ts b/packages/router/src/private_export.ts index 1094335fafee..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 {withActivatedRouteInjectors 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 ab73bfe0f612..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'; @@ -63,8 +64,7 @@ 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'; /** * 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 ActivatedRouteInjectorFeature = - RouterFeature; -export function withActivatedRouteInjectors(): ActivatedRouteInjectorFeature { +/** + * 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: ACTIVATED_ROUTE_INJECTOR_FEATURE, + 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 new file mode 100644 index 000000000000..ecb7938f877b --- /dev/null +++ b/packages/router/src/router_resource_feature.ts @@ -0,0 +1,21 @@ +/** + * @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 {InjectionToken} from '@angular/core'; +import {OperatorFunction} from 'rxjs'; +import type {NavigationTransition} from './navigation_transition'; + +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' : '', +); 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'); + }); + }); +});