From d2e3bac33b022679e596ed388eaefa316a0837e9 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Fri, 21 Aug 2026 11:38:19 -0700 Subject: [PATCH] refactor(router): add support for blocking router resources Extends router resource integration to support blocking resources during navigation transitions. (cherry picked from commit fa2aca969f18f41c1d6cfd275d84d0ecac5b9d79) --- .../src/operators/setup_and_run_resources.ts | 109 +- packages/router/src/router_resource.ts | 27 +- .../test/router_resource_behavior_spec.ts | 57 +- packages/router/test/router_resource_spec.ts | 1042 ++++++++--------- 4 files changed, 631 insertions(+), 604 deletions(-) diff --git a/packages/router/src/operators/setup_and_run_resources.ts b/packages/router/src/operators/setup_and_run_resources.ts index b1d90ce17ff..1d5f5d78673 100644 --- a/packages/router/src/operators/setup_and_run_resources.ts +++ b/packages/router/src/operators/setup_and_run_resources.ts @@ -5,13 +5,25 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ -import {createEnvironmentInjector, runInInjectionContext, Resource} from '@angular/core'; +import { + createEnvironmentInjector, + runInInjectionContext, + Resource, + effect, + DestroyRef, +} from '@angular/core'; import {OperatorFunction, pipe} from 'rxjs'; import {ResourceContext, ResourceResult} from '../models'; import {NavigationTransition} from '../navigation_transition'; import {ActivatedRoute, ActivatedRouteSnapshot, initializeActivatedRoute} from '../router_state'; import {TreeNode} from '../utils/tree'; -import {BLOCKING_SYMBOL, InternalRouterResource, routerResource} from '../router_resource'; +import { + BLOCKING_SYMBOL, + hasValueOrResolved, + InternalRouterResource, + routerResource, + SOURCE_RESOURCE_SYMBOL, +} from '../router_resource'; import {switchTap} from './switch_tap'; export function setupAndRunResources( @@ -19,17 +31,24 @@ export function setupAndRunResources( ): OperatorFunction { return pipe( switchTap(({newlyCreatedRoutes, targetRouterState}) => { - if (!newlyCreatedRoutes || !targetRouterState) { + if (!newlyCreatedRoutes || !targetRouterState || abortSignal.aborted) { return; } const resourceSetupPromises: Array> = []; + const blockingResourcePromises: Array> = []; const traverse = (stateNode: TreeNode) => { const route = stateNode.value; if (route) { initializeActivatedRoute(route); - processRoute(route, newlyCreatedRoutes, resourceSetupPromises, abortSignal); + processRoute( + route, + newlyCreatedRoutes, + resourceSetupPromises, + abortSignal, + blockingResourcePromises, + ); } for (const childState of stateNode.children) { @@ -39,8 +58,7 @@ export function setupAndRunResources( traverse(targetRouterState._root); - return Promise.all(resourceSetupPromises); - // TODO: wait for blocking resources + return Promise.all(resourceSetupPromises).then(() => Promise.all(blockingResourcePromises)); }), ); } @@ -50,6 +68,7 @@ function processRoute( newlyCreatedRoutes: Set, resourceSetupPromises: Array>, abortSignal: AbortSignal, + blockingResourcePromises: Array>, ) { const resources = route.routeConfig?.resources; if (!resources) { @@ -58,9 +77,11 @@ function processRoute( if (newlyCreatedRoutes.has(route)) { // This route is new. We need to run its resources function once. - resourceSetupPromises.push(setupNewRouterResources(route._futureSnapshot, route, abortSignal)); + resourceSetupPromises.push( + setupNewRouterResources(route._futureSnapshot, route, abortSignal, blockingResourcePromises), + ); } else { - updateExistingResources(route); + updateExistingResources(route, blockingResourcePromises, abortSignal); } } @@ -68,6 +89,7 @@ async function setupNewRouterResources( snapshot: ActivatedRouteSnapshot, route: ActivatedRoute, abortSignal: AbortSignal, + blockingResourcePromises: Promise[], ) { const resourcesFn = snapshot?.routeConfig?.resources; const parentInjector = snapshot?._environmentInjector; @@ -120,10 +142,14 @@ async function setupNewRouterResources( } route.resources = route._futureSnapshot.resources = snapshot.resources = wrappedResult; - prohibitBlockingResources(route, wrappedResult); + setupBlocking(route, wrappedResult, blockingResourcePromises, abortSignal); } -function updateExistingResources(route: ActivatedRoute) { +function updateExistingResources( + route: ActivatedRoute, + blockingResourcePromises: Promise[], + abortSignal: AbortSignal, +) { // This route is reused. We must eagerly update the resource context signals // so that resources can react and fetch new data during the pending navigation. const currentResources = route.snapshot?.resources; @@ -131,11 +157,27 @@ function updateExistingResources(route: ActivatedRoute) { return; } + Object.values(currentResources).forEach((r) => { + const underlyingRes = (r as InternalRouterResource)[SOURCE_RESOURCE_SYMBOL]; + if (underlyingRes.status() === 'error') { + // If a resource previously failed and the route is reused identically, + // the parameter signals won't change, meaning the internal effect won't automatically refetch. + // We must manually trigger a reload to ensure the new navigation attempts a retry. + (underlyingRes as unknown as {reload?: () => boolean}).reload?.(); + } + }); + route._futureSnapshot.resources = currentResources; - prohibitBlockingResources(route, currentResources); + setupBlocking(route, currentResources, blockingResourcePromises, abortSignal); } -function prohibitBlockingResources(route: ActivatedRoute, resourceResult: ResourceResult) { +function setupBlocking( + route: ActivatedRoute, + resourceResult: ResourceResult, + blockingResourcePromises: Array>, + abortSignal: AbortSignal, +) { + if (abortSignal.aborted) return; const childInjector = route._localInjector; if (!childInjector || !resourceResult) return; @@ -144,6 +186,47 @@ function prohibitBlockingResources(route: ActivatedRoute, resourceResult: Resour if (res[BLOCKING_SYMBOL] === false) { continue; } - throw new Error('blocking resources not implemented yet'); + const promise = new Promise((resolve, reject) => { + const underlyingRes = res[SOURCE_RESOURCE_SYMBOL]; + let isDestroyed = false; + let unregisterOnDestroy: (() => void) | undefined; + + const cleanup = () => { + isDestroyed = true; + blockingEffect.destroy(); + unregisterOnDestroy?.(); + abortSignal.removeEventListener('abort', onAbort); + }; + + const onAbort = () => { + cleanup(); + resolve(); + }; + + abortSignal.addEventListener('abort', onAbort, {once: true}); + + const blockingEffect = effect( + () => { + if (isDestroyed) { + return; + } + const status = underlyingRes.status(); + if (status === 'error') { + cleanup(); + reject(underlyingRes.error()); + } else if (hasValueOrResolved(underlyingRes)) { + cleanup(); + resolve(); + } + }, + {injector: childInjector, manualCleanup: true}, + ); + + unregisterOnDestroy = childInjector.get(DestroyRef).onDestroy(() => { + cleanup(); + resolve(); + }); + }); + blockingResourcePromises.push(promise); } } diff --git a/packages/router/src/router_resource.ts b/packages/router/src/router_resource.ts index 061f4297ff5..c9cbc3bc156 100644 --- a/packages/router/src/router_resource.ts +++ b/packages/router/src/router_resource.ts @@ -32,11 +32,23 @@ import { export const BLOCKING_SYMBOL: unique symbol = Symbol( typeof ngDevMode === 'undefined' || ngDevMode ? '__isBlocking' : '', ); +export const SOURCE_RESOURCE_SYMBOL: unique symbol = Symbol( + typeof ngDevMode === 'undefined' || ngDevMode ? '__sourceResource' : '', +); + +/** + * Checks if a resource has a value or has transitioned to a resolved/non-loading status. + */ +export function hasValueOrResolved(res: Resource): boolean { + const status = res.status(); + return res.hasValue() || (status !== 'loading' && status !== 'reloading'); +} /** * @internal */ export interface InternalRouterResource extends Resource { + [SOURCE_RESOURCE_SYMBOL]: Resource; [BLOCKING_SYMBOL]?: boolean; reload(): boolean; } @@ -68,11 +80,9 @@ export function routerResource(source: Resource): Resource & {reload(): const res = resourceFromSnapshots(snapshotSignal) as unknown as InternalRouterResource; - if ((source as unknown as InternalRouterResource)[BLOCKING_SYMBOL] === false) { - res[BLOCKING_SYMBOL] = false; - } else { - res[BLOCKING_SYMBOL] = true; - } + res[SOURCE_RESOURCE_SYMBOL] = source; + res[BLOCKING_SYMBOL] = + (source as unknown as InternalRouterResource)[BLOCKING_SYMBOL] !== false; if (typeof (source as any).reload === 'function') { res.reload = function (): boolean { @@ -155,12 +165,7 @@ function createTransactionalSnapshot( effect( () => { - if ( - isRollbackRecoveryPending() && - // TODO(consider): should this be hasValue || status !== loading - // Some stream implementations may retain loading status after first item resolves - !source.isLoading() - ) { + if (isRollbackRecoveryPending() && hasValueOrResolved(source)) { isRollbackRecoveryPending.set(false); frozenSnapshot.set(null); } diff --git a/packages/router/test/router_resource_behavior_spec.ts b/packages/router/test/router_resource_behavior_spec.ts index c332c6dc89a..71fc938fe6b 100644 --- a/packages/router/test/router_resource_behavior_spec.ts +++ b/packages/router/test/router_resource_behavior_spec.ts @@ -2,11 +2,21 @@ * @license * Copyright Google LLC All Rights Reserved. * - * Use of this source code is governed by an MIT-style license $can be + * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ -import {Component, signal, WritableSignal, resource, ɵpromiseWithResolvers} from '@angular/core'; +import { + Component, + computed, + Resource, + ResourceStatus, + Signal, + signal, + WritableSignal, + resource, + ɵpromiseWithResolvers, +} from '@angular/core'; import {TestBed} from '@angular/core/testing'; import {provideRouter, Router, UrlTree} from '@angular/router'; import {RouterTestingHarness} from '@angular/router/testing'; @@ -455,5 +465,48 @@ describe('routerResource behavior tests', () => { await harness.fixture.whenStable(); expect(wrapped.value()).toBe('updated-2'); }); + + it('should complete rollback recovery when a resource has a value even while remaining in loading state', async () => { + const valueSignal = signal('initial'); + const hasValueSignal = signal(true); + + const customResource: Resource = { + value: valueSignal as Signal, + status: signal('loading').asReadonly(), + isLoading: signal(true).asReadonly(), + hasValue: (() => hasValueSignal()) as any, + error: signal(undefined).asReadonly(), + snapshot: computed(() => ({ + status: 'loading' as const, + value: valueSignal()!, + })), + }; + + const wrapped = TestBed.runInInjectionContext(() => routerResource(customResource)); + expect(wrapped.value()).toBe('initial'); + + // Start navigation to route2 with a failing guard to trigger rollback + guardPromise2 = Promise.reject(new Error('Navigation failed')); + try { + await harness.navigateByUrl('/route2'); + } catch {} + + // Reset value and set hasValue to false to simulate recovery fetch starting + valueSignal.set(undefined); + hasValueSignal.set(false); + await timeout(); + + // Wrapped snapshot should be frozen at 'initial' during recovery loading + expect(wrapped.value()).toBe('initial'); + + // Resource receives value while isLoading() remains true and status is 'loading' + valueSignal.set('recovered-stream-1'); + hasValueSignal.set(true); + await harness.fixture.whenStable(); + + // Rollback recovery unfreezes because hasValue is true despite isLoading being true + expect(wrapped.value()).toBe('recovered-stream-1'); + expect(wrapped.isLoading()).toBe(true); + }); }); }); diff --git a/packages/router/test/router_resource_spec.ts b/packages/router/test/router_resource_spec.ts index 7d89901acc9..b2d30781ead 100644 --- a/packages/router/test/router_resource_spec.ts +++ b/packages/router/test/router_resource_spec.ts @@ -8,9 +8,12 @@ import { Component, + computed, EnvironmentProviders, resource, Resource, + ResourceStatus, + Signal, signal, ɵpromiseWithResolvers as promiseWithResolvers, } from '@angular/core'; @@ -51,13 +54,25 @@ export function provideRouter( routes: InternalRoute[], ...features: RouterFeatures[] ): EnvironmentProviders { - return internalProvideRouter(routes, ...features); + return internalProvideRouter(routes, withRouterResources(), ...features); +} + +async function setupRouter(routes: InternalRoute[], ...features: RouterFeatures[]) { + TestBed.configureTestingModule({ + providers: [provideRouter(routes, ...features)], + }); + const harness = await RouterTestingHarness.create(); + const router = TestBed.inject(Router); + return {harness, router}; } type ActivatedRouteInternal = ActivatedRoute & { resources?: {[key: string]: Resource}; }; +@Component({template: ''}) +class TargetCmp {} + describe('Router resources integration', () => { useAutoTick(); @@ -65,28 +80,15 @@ describe('Router resources integration', () => { 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); + const {harness, router} = await setupRouter([ + { + path: 'test', + component: TargetCmp, + resources: () => ({ + data: nonBlocking(resource({loader: loaderSpy})), + }), + }, + ]); await harness.navigateByUrl('/test'); await harness.fixture.whenStable(); @@ -98,36 +100,23 @@ describe('Router resources integration', () => { }); it('should support async resource functions returning a Promise', async () => { - @Component({standalone: true, template: ''}) - class TargetCmp {} - const loaderDeferred = promiseWithResolvers(); - TestBed.configureTestingModule({ - providers: [ - provideRouter( - [ - { - path: 'test', - component: TargetCmp, - resources: async () => { - const data = nonBlocking( - resource({ - loader: async () => loaderDeferred.promise, - }), - ); - await timeout(10); - return {data}; - }, - }, - ], - withRouterResources(), - ), - ], - }); - - const harness = await RouterTestingHarness.create(); - const router = TestBed.inject(Router); + const {harness, router} = await setupRouter([ + { + path: 'test', + component: TargetCmp, + resources: async () => { + const data = nonBlocking( + resource({ + loader: async () => loaderDeferred.promise, + }), + ); + await timeout(10); + return {data}; + }, + }, + ]); await router.navigateByUrl('/test'); @@ -146,31 +135,18 @@ describe('Router resources integration', () => { expect(resourceRef.value()).toBe('async loaded'); }); - xit('should support async resource functions returning a Promise (blocking)', async () => { - @Component({standalone: true, template: ''}) - class TargetCmp {} - - TestBed.configureTestingModule({ - providers: [ - provideRouter( - [ - { - path: 'test', - component: TargetCmp, - resources: async () => { - const data = resource({loader: async () => 'async loaded'}); - await timeout(10); - return {data}; - }, - }, - ], - withRouterResources(), - ), - ], - }); - - const harness = await RouterTestingHarness.create(); - const router = TestBed.inject(Router); + it('should support async resource functions returning a Promise (blocking)', async () => { + const {harness, router} = await setupRouter([ + { + path: 'test', + component: TargetCmp, + resources: async () => { + const data = resource({loader: async () => 'async loaded'}); + await timeout(10); + return {data}; + }, + }, + ]); await harness.navigateByUrl('/test'); await harness.fixture.whenStable(); @@ -181,41 +157,61 @@ describe('Router resources integration', () => { expect(resourceRef.value()).toBe('async loaded'); }); - it('should cleanly ignore resolution of async resource function if navigation was cancelled', async () => { - @Component({standalone: true, template: ''}) - class FirstCmp {} - @Component({standalone: true, template: ''}) - class SecondCmp {} + it('should await blocking resource resolution even when resources function is async', async () => { + const loaderDeferred = promiseWithResolvers(); + const {harness, router} = await setupRouter([ + { + path: 'test', + component: TargetCmp, + resources: async () => { + const data = resource({loader: () => loaderDeferred.promise}); + await timeout(10); + return {data}; + }, + }, + ]); + + const navPromise = harness.navigateByUrl('/test'); + + // 1. Wait past the async resources function delay (10ms) + await timeout(15); + harness.fixture.detectChanges(); + + // Navigation must STILL be pending because the blocking resource loader hasn't resolved yet + expect(router.url).not.toBe('/test'); + + // 2. Now resolve the blocking resource loader + loaderDeferred.resolve('resolved data'); + await navPromise; + await harness.fixture.whenStable(); + + expect(router.url).toBe('/test'); + const route = router.routerState.root.firstChild as ActivatedRouteInternal; + const resourceRef = route?.resources?.['data'] as any; + expect(resourceRef.value()).toBe('resolved data'); + }); + + it('should cleanly ignore resolution of async resource function if navigation was cancelled', async () => { const firstResources = promiseWithResolvers(); - TestBed.configureTestingModule({ - providers: [ - provideRouter( - [ - { - path: 'first', - component: FirstCmp, - resources: () => { - const data = nonBlocking(resource({loader: async () => 'first data'})); - return firstResources.promise.then(() => ({data})); - }, - }, - { - path: 'second', - component: SecondCmp, - resources: () => ({ - data: nonBlocking(resource({loader: async () => 'second data'})), - }), - }, - ], - withRouterResources(), - ), - ], - }); - - const harness = await RouterTestingHarness.create(); - const router = TestBed.inject(Router); + const {harness, router} = await setupRouter([ + { + path: 'first', + component: TargetCmp, + resources: () => { + const data = nonBlocking(resource({loader: async () => 'first data'})); + return firstResources.promise.then(() => ({data})); + }, + }, + { + path: 'second', + component: TargetCmp, + resources: () => ({ + data: nonBlocking(resource({loader: async () => 'second data'})), + }), + }, + ]); // Start navigation to /first (which will block waiting on firstResourcesPromise) const nav1 = harness.navigateByUrl('/first'); @@ -235,41 +231,26 @@ describe('Router resources integration', () => { expect(router.url).toBe('/second'); }); - xit('should cleanly ignore resolution of async resource function if navigation was cancelled (blocking)', async () => { - @Component({standalone: true, template: ''}) - class FirstCmp {} - @Component({standalone: true, template: ''}) - class SecondCmp {} - + it('should cleanly ignore resolution of async resource function if navigation was cancelled (blocking)', async () => { const firstResources = promiseWithResolvers(); - TestBed.configureTestingModule({ - providers: [ - provideRouter( - [ - { - path: 'first', - component: FirstCmp, - resources: () => { - const data = resource({loader: async () => 'first data'}); - return firstResources.promise.then(() => ({data})); - }, - }, - { - path: 'second', - component: SecondCmp, - resources: () => ({ - data: resource({loader: async () => 'second data'}), - }), - }, - ], - withRouterResources(), - ), - ], - }); - - const harness = await RouterTestingHarness.create(); - const router = TestBed.inject(Router); + const {harness, router} = await setupRouter([ + { + path: 'first', + component: TargetCmp, + resources: () => { + const data = resource({loader: async () => 'first data'}); + return firstResources.promise.then(() => ({data})); + }, + }, + { + path: 'second', + component: TargetCmp, + resources: () => ({ + data: resource({loader: async () => 'second data'}), + }), + }, + ]); // Start navigation to /first (which will block waiting on firstResourcesPromise) const nav1 = harness.navigateByUrl('/first'); @@ -289,38 +270,42 @@ describe('Router resources integration', () => { expect(router.url).toBe('/second'); }); + it('should fail navigation when async resources function rejects', async () => { + const {harness, router} = await setupRouter([ + { + path: 'test', + component: TargetCmp, + resources: async () => { + throw new Error('Async setup failed'); + }, + }, + ]); + + await expectAsync(harness.navigateByUrl('/test')).toBeRejectedWithError(/Async setup failed/); + expect(router.url).not.toContain('/test'); + }); + 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 {} + const {harness} = await setupRouter([ + { + path: 'test/:id', + component: TargetCmp, + resources: (ctx) => ({ + data: nonBlocking( + resource({ + params: () => ctx.params(), + loader: async () => { + callCount++; + return 'loaded'; + }, + }), + ), + }), + }, + ]); - 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); @@ -330,35 +315,23 @@ describe('Router resources integration', () => { }); it('should support resources on componentless routes', async () => { - @Component({standalone: true, template: '', selector: 'child-cmp-componentless'}) - class ChildCmp {} - - TestBed.configureTestingModule({ - providers: [ - provideRouter( - [ - { - path: 'parent', - resources: () => ({ - parentData: nonBlocking(resource({loader: async () => 'parent'})), - }), - children: [ - { - path: 'componentless', - resources: () => ({ - compData: nonBlocking(resource({loader: async () => 'comp'})), - }), - children: [{path: 'child', component: ChildCmp}], - }, - ], - }, - ], - withRouterResources(), - ), - ], - }); - const harness = await RouterTestingHarness.create(); - const router = TestBed.inject(Router); + const {harness, router} = await setupRouter([ + { + path: 'parent', + resources: () => ({ + parentData: nonBlocking(resource({loader: async () => 'parent'})), + }), + children: [ + { + path: 'componentless', + resources: () => ({ + compData: nonBlocking(resource({loader: async () => 'comp'})), + }), + children: [{path: 'child', component: TargetCmp}], + }, + ], + }, + ]); await harness.navigateByUrl('/parent/componentless/child'); await harness.fixture.whenStable(); @@ -376,27 +349,16 @@ describe('Router resources integration', () => { }); 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 {} + const {harness} = await setupRouter([ + { + path: 'test', + component: TargetCmp, + resources: () => ({ + data: {foo: 'bar'} as any, + }), + }, + ]); - TestBed.configureTestingModule({ - providers: [ - provideRouter( - [ - { - path: 'test', - component: TargetCmp, - resources: () => ({ - data: {foo: 'bar'} as any, - }), - }, - ], - withRouterResources(), - ), - ], - }); - - const harness = await RouterTestingHarness.create(); await expectAsync(harness.navigateByUrl('/test')).toBeRejectedWithError( /Invalid resource returned for key "data"/, ); @@ -404,37 +366,24 @@ describe('Router resources integration', () => { }); describe('Blocking vs Non-blocking Resources', () => { - xit('should resolve resources before component initialization if blocking', async () => { + it('should resolve resources before component initialization if blocking', async () => { let resolverSpy = jasmine.createSpy('resolver'); const deferred = promiseWithResolvers(); - @Component({standalone: true, template: ''}) - class TestCmp {} - - TestBed.configureTestingModule({ - providers: [ - provideRouter( - [ - { - path: 'test', - component: TestCmp, - resources: () => ({ - data: resource({ - loader: async () => { - resolverSpy(); - return await deferred.promise; - }, - }), - }), + const {harness, router} = await setupRouter([ + { + path: 'test', + component: TargetCmp, + resources: () => ({ + data: resource({ + loader: async () => { + resolverSpy(); + return await deferred.promise; }, - ], - withRouterResources(), - ), - ], - }); - - const harness = await RouterTestingHarness.create(); - const router = TestBed.inject(Router); + }), + }), + }, + ]); let completed = false; const navPromise = harness.navigateByUrl('/test').then(() => { @@ -452,61 +401,35 @@ describe('Router resources integration', () => { 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); + it('should cancel navigation when blocking resource yields error', async () => { + const {harness, router} = await setupRouter([ + { + path: 'test', + component: TargetCmp, + resources: () => ({ + data: resource({ + loader: () => Promise.reject('test error'), + }), + }), + }, + ]); 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); + it('should emit NavigationError when blocking resource rejects', async () => { + const {harness, router} = await setupRouter([ + { + path: 'test', + component: TargetCmp, + resources: () => ({ + data: resource({ + loader: () => Promise.reject('test error'), + }), + }), + }, + ]); const error = await new Promise((resolve) => { router.events.subscribe((e) => { @@ -518,84 +441,65 @@ describe('Router resources integration', () => { expect(error).toBeDefined(); }); - xit('should allow retrying a blocking route that previously threw an error', async () => { - let shouldError = true; + it('should allow retrying a blocking route that previously threw an error', async () => { + let shouldError = false; - @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']; - }, - }), - }), + const {harness, router} = await setupRouter([ + { + path: 'test/:id', + component: TargetCmp, + resources: () => ({ + data: resource({ + loader: async () => { + if (shouldError) throw new Error('Failed'); + return '1'; }, - ], - 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; + // 1. Initial navigation succeeds await harness.navigateByUrl('/test/1'); await harness.fixture.whenStable(); + expect(router.url).toBe('/test/1'); + + const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal) + ?.resources?.['data'] as any; + expect(resourceRef.value()).toBe('1'); + + // 2. Resource encounters an error while on the route + shouldError = true; + resourceRef.reload(); + await harness.fixture.whenStable(); + expect(resourceRef.status()).toBe('error'); + + // 3. Retry the identical route with same parameters using onSameUrlNavigation: 'reload' + shouldError = false; + await router.navigateByUrl('/test/1', {onSameUrlNavigation: 'reload'}); + 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.status()).toBe('resolved'); 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); + const {harness, router} = await setupRouter([ + { + path: 'test', + component: TargetCmp, + resources: () => ({ + data: nonBlocking( + resource({ + loader: async () => { + throw new Error('Non-blocking error'); + }, + }), + ), + }), + }, + ]); // Non-blocking resource error doesn't cancel navigation await harness.navigateByUrl('/test'); @@ -607,38 +511,50 @@ describe('Router resources integration', () => { expect(resourceRef.error()?.message).toBe('Non-blocking error'); expect(resourceRef.isLoading()).toBe(false); }); + + it('should complete navigation when a resource is idle and not loading', async () => { + const {harness, router} = await setupRouter([ + { + path: 'search', + component: TargetCmp, + resources: (ctx) => ({ + data: resource({ + params: () => ctx.queryParams()['q'], + loader: async ({params}) => `Query: ${params}`, + }), + }), + }, + ]); + + // Navigate without query params -> params() is undefined -> resource is idle + await harness.navigateByUrl('/search'); + await harness.fixture.whenStable(); + + expect(router.url).toBe('/search'); + const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal) + ?.resources?.['data'] as any; + expect(resourceRef.status()).toBe('idle'); + expect(resourceRef.value()).toBeUndefined(); + }); }); 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); + const {harness, router} = await setupRouter([ + { + path: 'test', + component: TargetCmp, + resolve: {id: () => '123'}, + resources: (ctx) => ({ + data: nonBlocking( + resource({ + params: () => ctx.data(), + loader: async ({params}: any) => ({name: `user ${params['id']}`}), + }), + ), + }), + }, + ]); await harness.navigateByUrl('/test'); await harness.fixture.whenStable(); @@ -652,39 +568,26 @@ describe('Router resources integration', () => { 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); + const {harness, router} = await setupRouter([ + { + 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'], + }), + ), + }), + }, + ]); await harness.navigateByUrl('/test/1'); await harness.fixture.whenStable(); @@ -703,40 +606,27 @@ describe('Router resources integration', () => { }); it('should abort previous request via AbortSignal when a new navigation comes in', async () => { - @Component({standalone: true, template: ''}) - class TargetCmp {} - const deferred = promiseWithResolvers<{name: string}>(); let aborted = false; - TestBed.configureTestingModule({ - providers: [ - provideRouter( - [ - { - path: 'user/:id', - component: TargetCmp, - resources: (ctx) => ({ - user: nonBlocking( - resource({ - params: () => ctx.params(), - loader: async ({params, abortSignal}: any) => { - abortSignal.addEventListener('abort', () => (aborted = true)); - if (params['id'] === '1') return deferred.promise; - return {name: 'user 2'}; - }, - }), - ), - }), - }, - ], - withRouterResources(), - ), - ], - }); - - const harness = await RouterTestingHarness.create(); - const router = TestBed.inject(Router); + const {harness, router} = await setupRouter([ + { + path: 'user/:id', + component: TargetCmp, + resources: (ctx) => ({ + user: nonBlocking( + resource({ + params: () => ctx.params(), + loader: async ({params, abortSignal}: any) => { + abortSignal.addEventListener('abort', () => (aborted = true)); + if (params['id'] === '1') return deferred.promise; + return {name: 'user 2'}; + }, + }), + ), + }), + }, + ]); harness.navigateByUrl('/user/1'); await timeout(10); @@ -755,42 +645,29 @@ describe('Router resources integration', () => { 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 {} - + it('should correctly propagate parameter state when a pending navigation supersedes identically reused routes', async () => { const p2 = new Promise(() => {}); // never resolves const p3 = promiseWithResolvers(); let loadedParams: any[] = []; - TestBed.configureTestingModule({ - providers: [ - provideRouter( - [ - { - path: 'test/:id', - component: TargetCmp, - resources: (ctx: any) => ({ - data: resource({ - params: () => ctx.params(), - loader: async ({params}: any) => { - loadedParams.push(params['id']); - if (params['id'] === '2') return p2; - if (params['id'] === '3') return p3.promise; - return params['id']; - }, - }), - }), + const {harness, router} = await setupRouter([ + { + path: 'test/:id', + component: TargetCmp, + resources: (ctx: any) => ({ + data: resource({ + params: () => ctx.params(), + loader: async ({params}: any) => { + loadedParams.push(params['id']); + if (params['id'] === '2') return p2; + if (params['id'] === '3') return p3.promise; + return params['id']; }, - ], - withRouterResources(), - ), - ], - }); - - const harness = await RouterTestingHarness.create(); - const router = TestBed.inject(Router); + }), + }), + }, + ]); await harness.navigateByUrl('/test/1'); @@ -813,39 +690,26 @@ describe('Router resources integration', () => { expect(resourceRef.value()).toBe('loaded-3'); }); - xit('should mask loading states during multi-step Guard UrlTree redirects', async () => { - @Component({standalone: true, template: ''}) - class TargetCmp {} - + it('should mask loading states during multi-step Guard UrlTree redirects', async () => { let loader = promiseWithResolvers(); - TestBed.configureTestingModule({ - providers: [ - provideRouter( - [ - { - path: 'target/:id', - component: TargetCmp, - resources: (ctx: any) => ({ - data: resource({ - params: () => ctx.params(), - loader: async () => loader.promise, - }), - }), - }, - { - path: 'bad-link', - canActivate: [() => TestBed.inject(Router).createUrlTree(['/target/3'])], - component: TargetCmp, - }, - ], - withRouterResources(), - ), - ], - }); - - const harness = await RouterTestingHarness.create(); - const router = TestBed.inject(Router); + const {harness, router} = await setupRouter([ + { + path: 'target/:id', + component: TargetCmp, + resources: (ctx: any) => ({ + data: resource({ + params: () => ctx.params(), + loader: async () => loader.promise, + }), + }), + }, + { + path: 'bad-link', + canActivate: [() => TestBed.inject(Router).createUrlTree(['/target/3'])], + component: TargetCmp, + }, + ]); // Settle initial state loader.resolve('1'); @@ -875,48 +739,34 @@ describe('Router resources integration', () => { expect(resourceRef.value()).toBe('3'); }); - xit('should be able to redirect from a blocking resource using a NavigationErrorHandler', async () => { + it('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')); + const {harness, router} = await setupRouter( + [ + { + path: 'test', + component: TargetCmp, + resources: () => ({ + data: resource({ + loader: async () => { + throw new Error('Resource failed!'); + }, + }), }), - withRouterResources(), - ), + }, + { + path: 'error', + component: TargetCmp, + }, ], - }); - - const harness = await RouterTestingHarness.create(); - const router = TestBed.inject(Router); + withNavigationErrorHandler((e: NavigationError) => { + handleCount++; + errorRef = e.error; + return new RedirectCommand(TestBed.inject(Router).parseUrl('/error')); + }), + ); await harness.navigateByUrl('/test'); @@ -927,32 +777,19 @@ describe('Router resources integration', () => { }); 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); + it('should successfully wrap and await an rxResource', async () => { + const {harness, router} = await setupRouter([ + { + path: 'rx/:id', + component: TargetCmp, + resources: (ctx) => ({ + data: rxResource({ + params: () => ctx.params(), + stream: ({params}: any) => of(`rx loaded ${params['id']}`).pipe(delay(10)), + }), + }), + }, + ]); const nav = harness.navigateByUrl('/rx/123'); await timeout(5); @@ -968,5 +805,54 @@ describe('Router resources integration', () => { expect(resourceRef.isLoading()).toBe(false); expect(resourceRef.value()).toBe('rx loaded 123'); }); + + it('should unblock navigation when a resource emits a value even while remaining in loading state', async () => { + const valueSignal = signal(undefined); + const hasValueSignal = signal(false); + + const customResource: Resource = { + value: valueSignal as Signal, + status: signal('loading').asReadonly(), + isLoading: signal(true).asReadonly(), + hasValue: (() => hasValueSignal()) as any, + error: signal(undefined).asReadonly(), + snapshot: computed(() => ({ + status: 'loading' as const, + value: valueSignal()!, + })), + }; + + const {harness, router} = await setupRouter([ + { + path: 'stream', + component: TargetCmp, + resources: () => ({ + data: customResource, + }), + }, + ]); + + harness.navigateByUrl('/stream'); + await timeout(); + + // Navigation should be pending because customResource has no value yet + expect(router.url).not.toBe('/stream'); + + // Custom resource emits a value while retaining isLoading() === true and status 'loading' + valueSignal.set('streamed-1'); + hasValueSignal.set(true); + await harness.fixture.whenStable(); + + expect(router.url).toBe('/stream'); + const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal) + ?.resources?.['data'] as any; + expect(resourceRef.value()).toBe('streamed-1'); + expect(resourceRef.isLoading()).toBe(true); + + // Verify subsequent value update while still in loading state + valueSignal.set('streamed-2'); + await harness.fixture.whenStable(); + expect(resourceRef.value()).toBe('streamed-2'); + }); }); });