From e39a22b1d706aade76307212ffbccb7fed87b1f9 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Tue, 23 Jun 2026 13:19:47 -0700 Subject: [PATCH] refactor(router): Create transactional router resource This commit adds an implementation of a router resource (not currently exposed for public use) which defines the behavior of a resource dependent on the Router navigation lifecycle. The cooperative router resource has the following behaviors: * **Universal Resource Support**: Because the wrapper operates on the standard `Resource` base interface, it natively and transparently supports wrapping any compliant resource implementation (such as `resource()` or `rxResource()`) out-of-the-box. * **Transactional Freezing**: During navigation, the state of the resource (value, status, error, and loading signals) is frozen so the application state and UI do not eagerly change/flash during an in-progress navigation. * **Rollback Recovery**: On a true rollback cancellation, it retains the frozen snapshot (to prevent a loading flash of the previous state) until the resource successfully finishes reloading the rolled-back state. * **Immediate Recovery Cancellation**: If the frozen state was not a valid settled state (e.g. was in 'error'), it bypasses the recovery phase and unfreezes immediately on rollback. * **New Navigation Preemption**: Starting a new navigation immediately preempts and clears any pending rollback recovery. * **Cooperative API Surface**: It omits writable APIs (set/update) to prevent out-of-band mutations that bypass the transactional freeze, and only exposes a minification-safe `reload()` method that is disabled during active navigations. --- packages/router/src/router_resource.ts | 137 +++++ .../test/router_resource_behavior_spec.ts | 502 ++++++++++++++++++ 2 files changed, 639 insertions(+) create mode 100644 packages/router/src/router_resource.ts create mode 100644 packages/router/test/router_resource_behavior_spec.ts diff --git a/packages/router/src/router_resource.ts b/packages/router/src/router_resource.ts new file mode 100644 index 000000000000..d436713514bc --- /dev/null +++ b/packages/router/src/router_resource.ts @@ -0,0 +1,137 @@ +/** + * @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 { + inject, + Injector, + Resource, + resourceFromSnapshots, + Signal, + signal, + DestroyRef, + ResourceSnapshot, + effect, + computed, + WritableResource, + assertInInjectionContext, +} from '@angular/core'; +import {Router} from './router'; +import { + NavigationStart, + NavigationEnd, + NavigationCancel, + NavigationError, + NavigationSkipped, + NavigationCancellationCode, +} from './events'; + +/** + * Wraps a Resource to make it cooperative with the Angular Router, freezing its state + * during navigation transitions and handling rollback recovery. + */ +export function routerResource(source: Resource): Resource & {reload(): boolean} { + ngDevMode && assertInInjectionContext(routerResource); + const injector = inject(Injector); + const router = injector.get(Router); + + const {snapshot: snapshotSignal, frozenSnapshot} = createTransactionalSnapshot( + source, + router, + injector, + ); + + const res = resourceFromSnapshots(snapshotSignal) as Resource & {reload(): boolean}; + + res.reload = function (): boolean { + if (frozenSnapshot() !== null) { + return false; + } + return (source as WritableResource).reload?.() ?? false; + }; + + return res; +} + +/** + * Creates a signal that tracks the resource snapshot and handles transactional behavior + * (freezing during navigation and rollback recovery). + */ +function createTransactionalSnapshot( + source: Resource, + router: Router, + injector: Injector, +): { + snapshot: Signal>; + frozenSnapshot: Signal | null>; +} { + // Holds a snapshot of the resource to keep the UI masked (frozen) during pending navigations + // or while recovering from a cancelled navigation. + const frozenSnapshot = signal | null>(null); + + // Tracks whether we are in a recovery phase after a cancelled navigation. + // The intended behavior is that on cancellation, the router reverts to the previous state. + // This reversion might trigger a new load of the previous state because the signal dependencies + // changed. If we were to release the frozen resource state immediately, the user would see a loading state + // for data they were just looking at. To avoid this "loading flash", we retain the frozen + // value (via frozenSnapshot) during this recovery load/reload until the resource settles. + const isRollbackRecoveryPending = signal(false); + + const sub = router.events.subscribe((e) => { + if (e instanceof NavigationStart) { + isRollbackRecoveryPending.set(false); + + if (frozenSnapshot() === null) { + // 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. + frozenSnapshot.set(null); + isRollbackRecoveryPending.set(false); + } else if (e instanceof NavigationCancel || e instanceof NavigationError) { + const isRollback = + e instanceof NavigationError || + (e instanceof NavigationCancel && + e.code !== NavigationCancellationCode.SupersededByNewNavigation && + e.code !== NavigationCancellationCode.Redirect); + + if (!isRollback) return; + + const frozen = frozenSnapshot(); + + // Because `rollbackState` runs synchronously immediately prior to `NavigationCancel` (for true rollbacks), + // the underlying resource parameters have already reverted. + // If those parameters triggered a reload, `isLoading` will synchronously remain true here. + if (frozen?.status === 'resolved' || frozen?.status === 'reloading') { + // We were in a valid state, so keep the UI frozen while we wait for the recovery load to complete. + isRollbackRecoveryPending.set(true); + } else { + // We were not in a valid state, so we can't recover. Unfreeze immediately. + isRollbackRecoveryPending.set(false); + frozenSnapshot.set(null); + } + } + }); + + injector.get(DestroyRef).onDestroy(() => sub.unsubscribe()); + + effect( + () => { + if (isRollbackRecoveryPending() && !source.isLoading()) { + isRollbackRecoveryPending.set(false); + frozenSnapshot.set(null); + } + }, + {injector}, + ); + + return { + snapshot: computed(() => frozenSnapshot() ?? source.snapshot()), + frozenSnapshot, + }; +} diff --git a/packages/router/test/router_resource_behavior_spec.ts b/packages/router/test/router_resource_behavior_spec.ts new file mode 100644 index 000000000000..d981213e5147 --- /dev/null +++ b/packages/router/test/router_resource_behavior_spec.ts @@ -0,0 +1,502 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license $can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Component, 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'; +import {routerResource} from '../src/router_resource'; +import {timeout, useAutoTick} from '../../private/testing/src/utils'; +import {rxResource} from '@angular/core/rxjs-interop'; +import {Subject} from 'rxjs'; + +@Component({ + standalone: true, + template: '', +}) +class DummyComponent {} + +describe('routerResource behavior tests', () => { + useAutoTick(); + let router: Router; + let harness: RouterTestingHarness; + + let guardPromise2: Promise; + let resolveGuard2: (val: boolean | UrlTree) => void; + + let guardPromise3: Promise; + let resolveGuard3: (val: boolean | UrlTree) => void; + + let paramSignal: WritableSignal; + let resolveLoader: (value: string) => void; + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + {path: 'route1', component: DummyComponent}, + { + path: 'route2', + component: DummyComponent, + canActivate: [() => guardPromise2], + }, + { + path: 'route3', + component: DummyComponent, + canActivate: [() => guardPromise3], + }, + {path: 'route4', component: DummyComponent}, + ]), + ], + }); + + router = TestBed.inject(Router); + harness = await RouterTestingHarness.create(); + + // Navigate to /route1 to start at a settled state + await harness.navigateByUrl('/route1'); + await harness.fixture.whenStable(); + }); + + // Helper to create a real resource controlled in tests + // Resolves the very first load immediately to 'initial', and returns a pending promise for subsequent loads. + function createRealResource() { + paramSignal = signal('initial'); + let resolveImmediately = true; + + return resource({ + params: () => paramSignal(), + loader: () => { + if (resolveImmediately) { + resolveImmediately = false; + return Promise.resolve('initial'); + } + return new Promise((resolve) => { + resolveLoader = resolve; + }); + }, + }); + } + + // Helper to create a wrapped resource controlled in tests inside injection context + function createWrappedResource() { + return TestBed.runInInjectionContext(() => routerResource(createRealResource())); + } + + describe('Basic Snapshot Propagation and Symbols', () => { + it("should propagate the source resource's snapshot", async () => { + const wrapped = createWrappedResource(); + await timeout(); + + expect(wrapped.status()).toBe('resolved'); + expect(wrapped.value()).toBe('initial'); + + // Update source by changing param and resolving the loader + paramSignal.set('updated'); + await timeout(); // Let the loader trigger + resolveLoader('updated'); + await harness.fixture.whenStable(); + + expect(wrapped.status()).toBe('resolved'); + expect(wrapped.value()).toBe('updated'); + }); + }); + + describe('Transactional Freezing during Navigation', () => { + it('should freeze the snapshot at the start of navigation', async () => { + const wrapped = createWrappedResource(); + await timeout(); + + // Setup pending navigation to /route2 + guardPromise2 = new Promise((resolve) => (resolveGuard2 = resolve)); + const navPromise = harness.navigateByUrl('/route2'); + await timeout(); + + // Update source snapshot while navigating (should be frozen) + paramSignal.set('updated'); + await timeout(); // Loader triggers + + // Should still be frozen at 'initial' + expect(wrapped.value()).toBe('initial'); + const snapshot = wrapped.snapshot(); + expect(snapshot.status).toBe('resolved'); + if (snapshot.status === 'resolved') { + expect(snapshot.value).toBe('initial'); + } + + // Complete navigation to avoid leaving router in pending state + resolveLoader('updated'); // Settle the resource loader + resolveGuard2(true); + await navPromise; + await harness.fixture.whenStable(); + }); + + it('should unfreeze the snapshot when navigation succeeds', async () => { + const wrapped = createWrappedResource(); + await timeout(); + + guardPromise2 = new Promise((resolve) => (resolveGuard2 = resolve)); + const navPromise = harness.navigateByUrl('/route2'); + await timeout(); + + paramSignal.set('updated'); + await timeout(); + + expect(wrapped.value()).toBe('initial'); + + // Let navigation succeed and resource resolve + resolveLoader('updated'); + resolveGuard2(true); + await navPromise; + await harness.fixture.whenStable(); + + // Should now be unfrozen and updated + expect(wrapped.value()).toBe('updated'); + }); + + it('should unfreeze the snapshot when navigation is skipped', async () => { + const wrapped = createWrappedResource(); + await timeout(); + + // Navigate to same URL (/route1) to trigger skipped navigation + const navPromise = harness.navigateByUrl('/route1'); + await timeout(); + + paramSignal.set('updated'); + await timeout(); + resolveLoader('updated'); + + await navPromise; + await harness.fixture.whenStable(); + + // Should be unfrozen and updated + expect(wrapped.value()).toBe('updated'); + }); + }); + + describe('Rollback Recovery', () => { + it('should maintain the freeze when navigation is cancelled due to redirect or being superseded', async () => { + const wrapped = createWrappedResource(); + await timeout(); + + // Block route3 with a pending guard so we can pause and inspect the redirect transition + guardPromise3 = new Promise((resolve) => (resolveGuard3 = resolve)); + + guardPromise2 = new Promise((resolve) => (resolveGuard2 = resolve)); + const navPromise = harness.navigateByUrl('/route2'); + await timeout(); + + paramSignal.set('updated'); + await timeout(); + + // Trigger redirect to /route3 + resolveLoader('updated'); + resolveGuard2(router.createUrlTree(['/route3'])); + await timeout(); // Let nav 1 cancel and redirect nav start + + // The resource should REMAIN frozen at 'initial' during the active redirect navigation + expect(wrapped.value()).toBe('initial'); + + // Now complete the redirect navigation + resolveGuard3(true); + await navPromise; + await harness.fixture.whenStable(); + + // Once the redirect completes, it should unfreeze + expect(wrapped.value()).toBe('updated'); + }); + + it('should initiate rollback recovery and remain frozen on true rollback until source finishes loading', async () => { + const wrapped = createWrappedResource(); + await timeout(); + + guardPromise2 = new Promise((resolve) => (resolveGuard2 = resolve)); + const navPromise = harness.navigateByUrl('/route2'); + await timeout(); + + // Change source value (simulating parameter rollback triggering a new reload of the old value) + paramSignal.set('rolled-back'); + await timeout(); + + // Reject guard to trigger true rollback + resolveGuard2(false); + await navPromise; + await timeout(); + + // Should still be frozen during recovery loading (status must remain 'resolved', not 'reloading'!) + expect(wrapped.status()).toBe('resolved'); + expect(wrapped.value()).toBe('initial'); + + // Complete loading the rolled-back state + resolveLoader('rolled-back-settled'); + await harness.fixture.whenStable(); + + // Now it should be unfrozen and show the settled rolled-back state + expect(wrapped.status()).toBe('resolved'); + expect(wrapped.value()).toBe('rolled-back-settled'); + }); + + it('should unfreeze immediately on rollback if the frozen state was not a settled, non error state', async () => { + const errorParamSignal = signal('initial'); + const {promise: initialErrorPromise, reject: rejectInitial} = ɵpromiseWithResolvers(); + initialErrorPromise.catch(() => {}); // Prevent unhandled promise rejection error. When integrated into Router, we catch these there. + + const wrapped = TestBed.runInInjectionContext(() => { + return routerResource( + resource({ + params: () => errorParamSignal(), + loader: ({params}) => { + if (params === 'initial') { + return initialErrorPromise; + } + return new Promise((resolve) => { + resolveLoader = resolve; + }); + }, + }), + ); + }); + + rejectInitial(new Error('Initial error')); + await harness.fixture.whenStable(); + + expect(wrapped.status()).toBe('error'); + + guardPromise2 = new Promise((resolve) => (resolveGuard2 = resolve)); + const navPromise = harness.navigateByUrl('/route2'); + await timeout(); + + // Reject guard to trigger true rollback + resolveGuard2(false); + await navPromise; + await timeout(); + + // If it unfroze immediately, changing the parameter should immediately transition the wrapper to 'loading'. + // (If it was still frozen, it would remain insulated in the 'error' state). + errorParamSignal.set('new-param'); + await timeout(); + harness.fixture.detectChanges(); + + expect(wrapped.status()).toBe('loading'); + + // Resolve the loader to complete the verification + resolveLoader('recovered-data'); + await harness.fixture.whenStable(); + + expect(wrapped.status()).toBe('resolved'); + expect(wrapped.value()).toBe('recovered-data'); + }); + }); + + describe('Multi-Navigation Interactions', () => { + it('should ignore events from older navigations', async () => { + const wrapped = createWrappedResource(); + await timeout(); + + // Start Nav 1 (to /route2) + guardPromise2 = new Promise((resolve) => (resolveGuard2 = resolve)); + const nav1Promise = harness.navigateByUrl('/route2'); + await timeout(); + + // Start Nav 2 (to /route3), which cancels Nav 1 + guardPromise3 = new Promise((resolve) => (resolveGuard3 = resolve)); + const nav2Promise = harness.navigateByUrl('/route3'); + await timeout(); + + paramSignal.set('updated'); + await timeout(); + + // Resolve Nav 1's guard (should have no effect because Nav 1 was cancelled/superseded) + resolveLoader('updated'); + resolveGuard2(true); + await timeout(); // Wait a moment for events to process (Nav 1's promise is rejected in the background) + + expect(wrapped.value()).toBe('initial'); // Still frozen by Nav 2 + + // Let Nav 2 complete + resolveGuard3(true); + await nav2Promise; + await harness.fixture.whenStable(); + + expect(wrapped.value()).toBe('updated'); // Unfrozen! + + // Clean up Nav 1 promise rejection + await nav1Promise.catch(() => {}); + }); + + it('should clear rollback recovery if a new navigation starts', async () => { + const wrapped = createWrappedResource(); + await timeout(); + + // Start Nav 1 + guardPromise2 = new Promise((resolve) => (resolveGuard2 = resolve)); + const nav1Promise = harness.navigateByUrl('/route2'); + await timeout(); + + // Cancel Nav 1 with rollback + resolveGuard2(false); + // Synchronously trigger the reload to simulate rollback reload! + paramSignal.set('rolled-back'); + await nav1Promise; + await timeout(); + + // While recovery is pending, a new navigation (Nav 2) starts (to /route3) + guardPromise3 = new Promise((resolve) => (resolveGuard3 = resolve)); + const nav2Promise = harness.navigateByUrl('/route3'); + await timeout(); + + // Recovery should be cleared, but the resource is still frozen because of Nav 2. + // Now recovery loading completes (isLoading becomes false) + resolveLoader('rolled-back'); + await timeout(); + + // It should still be frozen at 'initial' because Nav 2 is active! + expect(wrapped.value()).toBe('initial'); + + // Let Nav 2 succeed + resolveGuard3(true); + await nav2Promise; + await harness.fixture.whenStable(); + + // Unfrozen! + expect(wrapped.value()).toBe('rolled-back'); + }); + }); + + describe('Reload Behavior', () => { + it('should allow reload and delegate to source when not frozen', async () => { + const wrapped = createWrappedResource(); + await timeout(); + + const result = wrapped.reload(); + expect(result).toBe(true); + + // Verify that the resource actually started reloading (proves delegation!) + await timeout(); + expect(wrapped.isLoading()).toBe(true); + }); + + it('should ignore reload and return false when frozen', async () => { + const wrapped = createWrappedResource(); + await timeout(); + + guardPromise2 = new Promise((resolve) => (resolveGuard2 = resolve)); + const navPromise = harness.navigateByUrl('/route2'); + await timeout(); + + const result = wrapped.reload(); + expect(result).toBe(false); + + // Clean up + resolveGuard2(true); + await navPromise; + await harness.fixture.whenStable(); + }); + }); + + describe('Reactive Integration with Router Navigation State', () => { + it('should reactively trigger loading but keep the wrapped resource frozen until navigation completes', async () => { + const wrapped = createWrappedResource(); + await harness.fixture.whenStable(); // Let the initial Promise.resolve('data-route1') resolve and the zone stabilize! + + // Initially, we are at route1 and resolved + expect(wrapped.status()).toBe('resolved'); + expect(wrapped.value()).toBe('initial'); + + // 1. Start navigation to route2 (blocked by guard) + guardPromise2 = new Promise((resolve) => (resolveGuard2 = resolve)); + const navPromise = harness.navigateByUrl('/route2'); + await timeout(); // Let router start and freeze the resource + + // 2. Manually trigger the parameter change while navigation is active + paramSignal.set('route2'); + await timeout(); // Let the resource loader trigger in the background + harness.fixture.detectChanges(); + + // The wrapped resource MUST remain frozen at route1's data! + expect(wrapped.status()).toBe('resolved'); + expect(wrapped.value()).toBe('initial'); + + // 3. Settle the resource loader for route2 + resolveLoader('data-route2'); + await timeout(); // Let the resource resolve + harness.fixture.detectChanges(); + + // But the wrapped resource MUST STILL remain frozen at route1's data because the navigation is still pending! + expect(wrapped.status()).toBe('resolved'); + expect(wrapped.value()).toBe('initial'); + + // 4. Resolve the guard to let the navigation complete + resolveGuard2(true); + await navPromise; + await harness.fixture.whenStable(); + + // Now that the navigation completed, the wrapped resource must unfreeze and show route2's data! + expect(wrapped.status()).toBe('resolved'); + expect(wrapped.value()).toBe('data-route2'); + }); + }); + + describe('rxResource Integration', () => { + it('should support wrapping rxResource natively and stream values without completion', async () => { + const triggerSignal = signal('initial'); + let resolveSubject!: Subject; + + const wrapped = TestBed.runInInjectionContext(() => { + return routerResource( + rxResource({ + params: () => triggerSignal(), + stream: () => { + resolveSubject = new Subject(); + return resolveSubject; + }, + }), + ); + }); + + // 1. Verify initial streaming (multiple emissions, no completion) + await timeout(); + resolveSubject.next('initial-1'); + await harness.fixture.whenStable(); + expect(wrapped.value()).toBe('initial-1'); + + resolveSubject.next('initial-2'); + await harness.fixture.whenStable(); + expect(wrapped.value()).toBe('initial-2'); + + // 2. Start navigation (should freeze at the last streamed value) + guardPromise2 = new Promise((resolve) => (resolveGuard2 = resolve)); + const navPromise = harness.navigateByUrl('/route2'); + await timeout(); + + // Trigger a parameter change which creates a new stream + triggerSignal.set('updated'); + await timeout(); + + // Emit a value on the new active stream + resolveSubject.next('updated-1'); + await timeout(); + + // Wrapper must remain frozen at 'initial-2' + expect(wrapped.value()).toBe('initial-2'); + + // 3. Complete navigation to unfreeze + resolveGuard2(true); + await navPromise; + await harness.fixture.whenStable(); + + // Wrapper should unfreeze and show the latest value from the new stream + expect(wrapped.value()).toBe('updated-1'); + + // 4. Verify streaming continues to work after unfreezing + resolveSubject.next('updated-2'); + await harness.fixture.whenStable(); + expect(wrapped.value()).toBe('updated-2'); + }); + }); +});