mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
6da6d7af65
Replaces the remaining hand-rolled deferred promise implementations
with the native `Promise.withResolvers()` API and removes the now
unused helper and import.
Follow-up to #69739.
(cherry picked from commit 1bc7e3c2c3)
513 lines
17 KiB
TypeScript
513 lines
17 KiB
TypeScript
/**
|
|
* @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,
|
|
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';
|
|
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({template: ''})
|
|
class DummyComponent {}
|
|
|
|
describe('routerResource behavior tests', () => {
|
|
useAutoTick();
|
|
let router: Router;
|
|
let harness: RouterTestingHarness;
|
|
|
|
let guardPromise2: Promise<boolean | UrlTree>;
|
|
let resolveGuard2: (val: boolean | UrlTree) => void;
|
|
|
|
let guardPromise3: Promise<boolean | UrlTree>;
|
|
let resolveGuard3: (val: boolean | UrlTree) => void;
|
|
|
|
let paramSignal: WritableSignal<string>;
|
|
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();
|
|
});
|
|
|
|
afterEach(() => {
|
|
router = undefined!;
|
|
harness = undefined!;
|
|
guardPromise2 = undefined!;
|
|
resolveGuard2 = undefined!;
|
|
guardPromise3 = undefined!;
|
|
resolveGuard3 = undefined!;
|
|
paramSignal = undefined!;
|
|
resolveLoader = undefined!;
|
|
});
|
|
|
|
// 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');
|
|
}
|
|
const {promise, resolve} = Promise.withResolvers<string>();
|
|
resolveLoader = resolve;
|
|
return promise;
|
|
},
|
|
});
|
|
}
|
|
|
|
// Helper to create a wrapped resource controlled in tests inside injection context
|
|
function createWrappedResource() {
|
|
return TestBed.runInInjectionContext(() => routerResource(createRealResource()));
|
|
}
|
|
|
|
describe('Basic Snapshot Propagation', () => {
|
|
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');
|
|
});
|
|
});
|
|
|
|
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<string>;
|
|
|
|
const wrapped = TestBed.runInInjectionContext(() => {
|
|
return routerResource(
|
|
rxResource({
|
|
params: () => triggerSignal(),
|
|
stream: () => {
|
|
resolveSubject = new Subject<string>();
|
|
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');
|
|
});
|
|
|
|
it('should complete rollback recovery when a resource has a value even while remaining in loading state', async () => {
|
|
const valueSignal = signal<string | undefined>('initial');
|
|
const hasValueSignal = signal<boolean>(true);
|
|
|
|
const customResource: Resource<string> = {
|
|
value: valueSignal as Signal<string>,
|
|
status: signal<ResourceStatus>('loading').asReadonly(),
|
|
isLoading: signal(true).asReadonly(),
|
|
hasValue: (() => hasValueSignal()) as any,
|
|
error: signal<Error | undefined>(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);
|
|
});
|
|
});
|
|
});
|