mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
094bce9e3d
Previously, blocking router resources waited on hasValueOrResolved(), which checked whether the resource already had a value. This caused blocking resources with initial values (e.g. defaultValue), streamed early emissions, or existing values during route reloads to resolve prematurely while still in a loading state. This change updates the blocking resolution check to be solely determined by !underlyingRes.isLoading().
1015 lines
32 KiB
TypeScript
1015 lines
32 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,
|
|
EnvironmentProviders,
|
|
EnvironmentInjector,
|
|
inject,
|
|
resource,
|
|
Resource,
|
|
ResourceStatus,
|
|
Signal,
|
|
signal,
|
|
ɵpromiseWithResolvers as promiseWithResolvers,
|
|
} from '@angular/core';
|
|
import {TestBed} from '@angular/core/testing';
|
|
import {
|
|
provideRouter,
|
|
Router,
|
|
NavigationError,
|
|
withNavigationErrorHandler,
|
|
RedirectCommand,
|
|
withRouterResources,
|
|
nonBlocking,
|
|
ActivatedRoute,
|
|
Route,
|
|
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';
|
|
|
|
async function setupRouter(routes: Route[], ...features: RouterFeatures[]) {
|
|
TestBed.configureTestingModule({
|
|
providers: [provideRouter(routes, ...features, withRouterResources())],
|
|
});
|
|
const harness = await RouterTestingHarness.create();
|
|
const router = TestBed.inject(Router);
|
|
return {harness, router};
|
|
}
|
|
|
|
type ActivatedRouteInternal = ActivatedRoute & {
|
|
resources?: {[key: string]: Resource<unknown>};
|
|
};
|
|
|
|
@Component({template: ''})
|
|
class TargetCmp {}
|
|
|
|
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');
|
|
|
|
const {harness, router} = await setupRouter([
|
|
{
|
|
path: 'test',
|
|
component: TargetCmp,
|
|
resources: () => ({
|
|
data: nonBlocking(resource({loader: loaderSpy})),
|
|
}),
|
|
},
|
|
]);
|
|
|
|
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');
|
|
});
|
|
|
|
it('should support async resource functions returning a Promise', async () => {
|
|
const loaderDeferred = promiseWithResolvers<string>();
|
|
|
|
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');
|
|
|
|
expect(router.url).toBe('/test');
|
|
|
|
const route = router.routerState.root.firstChild as ActivatedRouteInternal;
|
|
const resourceRef = route?.resources?.['data'] as any;
|
|
expect(resourceRef).toBeDefined();
|
|
expect(resourceRef.isLoading()).toBe(true);
|
|
expect(resourceRef.value()).toBeUndefined();
|
|
|
|
loaderDeferred.resolve('async loaded');
|
|
await harness.fixture.whenStable();
|
|
|
|
expect(resourceRef.isLoading()).toBe(false);
|
|
expect(resourceRef.value()).toBe('async loaded');
|
|
});
|
|
|
|
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();
|
|
|
|
const route = router.routerState.root.firstChild as ActivatedRouteInternal;
|
|
const resourceRef = route?.resources?.['data'] as any;
|
|
expect(resourceRef).toBeDefined();
|
|
expect(resourceRef.value()).toBe('async loaded');
|
|
});
|
|
|
|
it('should await blocking resource resolution even when resources function is async', async () => {
|
|
const loaderDeferred = promiseWithResolvers<string>();
|
|
|
|
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<void>();
|
|
|
|
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');
|
|
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
|
|
firstResources.resolve();
|
|
await harness.fixture.whenStable();
|
|
|
|
// Navigation should remain on /second
|
|
expect(router.url).toBe('/second');
|
|
});
|
|
|
|
it('should cleanly ignore resolution of async resource function if navigation was cancelled (blocking)', async () => {
|
|
const firstResources = promiseWithResolvers<void>();
|
|
|
|
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');
|
|
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
|
|
firstResources.resolve();
|
|
await harness.fixture.whenStable();
|
|
|
|
// Navigation should remain on /second
|
|
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;
|
|
|
|
const {harness} = await setupRouter([
|
|
{
|
|
path: 'test/:id',
|
|
component: TargetCmp,
|
|
resources: (ctx) => ({
|
|
data: nonBlocking(
|
|
resource({
|
|
params: () => ctx.params(),
|
|
loader: async () => {
|
|
callCount++;
|
|
return 'loaded';
|
|
},
|
|
}),
|
|
),
|
|
}),
|
|
},
|
|
]);
|
|
|
|
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 () => {
|
|
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();
|
|
await timeout(20);
|
|
|
|
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 () => {
|
|
const {harness} = await setupRouter([
|
|
{
|
|
path: 'test',
|
|
component: TargetCmp,
|
|
resources: () => ({
|
|
data: {foo: 'bar'} as any,
|
|
}),
|
|
},
|
|
]);
|
|
|
|
await expectAsync(harness.navigateByUrl('/test')).toBeRejectedWithError(
|
|
/Invalid resource returned for key "data"/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Blocking vs Non-blocking Resources', () => {
|
|
it('should resolve resources before component initialization if blocking', async () => {
|
|
let resolverSpy = jasmine.createSpy('resolver');
|
|
const deferred = promiseWithResolvers<string>();
|
|
|
|
const {harness, router} = await setupRouter([
|
|
{
|
|
path: 'test',
|
|
component: TargetCmp,
|
|
resources: () => ({
|
|
data: resource({
|
|
loader: async () => {
|
|
resolverSpy();
|
|
return await deferred.promise;
|
|
},
|
|
}),
|
|
}),
|
|
},
|
|
]);
|
|
|
|
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();
|
|
|
|
deferred.resolve('resolved');
|
|
await navPromise;
|
|
expect(completed).toBe(true);
|
|
expect(router.url).toBe('/test');
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
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) => {
|
|
if (e instanceof NavigationError) resolve(e.error);
|
|
});
|
|
harness.navigateByUrl('/test').catch(() => {});
|
|
});
|
|
expect(typeof error).toBe('object');
|
|
expect(error).toBeDefined();
|
|
});
|
|
|
|
it('should allow retrying a blocking route that previously threw an error', async () => {
|
|
let shouldError = false;
|
|
|
|
const {harness, router} = await setupRouter([
|
|
{
|
|
path: 'test/:id',
|
|
component: TargetCmp,
|
|
resources: () => ({
|
|
data: resource({
|
|
loader: async () => {
|
|
if (shouldError) throw new Error('Failed');
|
|
return '1';
|
|
},
|
|
}),
|
|
}),
|
|
},
|
|
]);
|
|
|
|
// 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!
|
|
expect(resourceRef.status()).toBe('resolved');
|
|
expect(resourceRef.value()).toBe('1');
|
|
});
|
|
|
|
it('should block navigation when a resource has a defaultValue until loading is complete', async () => {
|
|
const loaderDeferred = promiseWithResolvers<string>();
|
|
|
|
const {harness, router} = await setupRouter([
|
|
{
|
|
path: 'test',
|
|
component: TargetCmp,
|
|
resources: () => ({
|
|
user: resource({
|
|
defaultValue: 'default-user',
|
|
loader: async () => loaderDeferred.promise,
|
|
}),
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const nav = harness.navigateByUrl('/test');
|
|
await timeout();
|
|
|
|
// Navigation is blocked on loading even though the resource has a defaultValue
|
|
expect(router.url).not.toBe('/test');
|
|
|
|
loaderDeferred.resolve('loaded-user');
|
|
await nav;
|
|
|
|
expect(router.url).toBe('/test');
|
|
|
|
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
|
|
?.resources?.['user'] as any;
|
|
expect(resourceRef.value()).toBe('loaded-user');
|
|
expect(resourceRef.isLoading()).toBe(false);
|
|
});
|
|
|
|
it('should complete navigation and expose error for non-blocking resources', async () => {
|
|
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');
|
|
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);
|
|
});
|
|
|
|
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 () => {
|
|
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();
|
|
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;
|
|
|
|
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();
|
|
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 () => {
|
|
const deferred = promiseWithResolvers<{name: string}>();
|
|
let aborted = false;
|
|
|
|
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);
|
|
|
|
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
|
|
deferred.resolve({name: 'user 1'});
|
|
await timeout(10);
|
|
expect(userResource.value()).toEqual({name: 'user 2'});
|
|
});
|
|
|
|
it('should correctly propagate parameter state when a pending navigation supersedes identically reused routes', async () => {
|
|
const p2 = new Promise(() => {}); // never resolves
|
|
const p3 = promiseWithResolvers<string>();
|
|
|
|
let loadedParams: any[] = [];
|
|
|
|
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'];
|
|
},
|
|
}),
|
|
}),
|
|
},
|
|
]);
|
|
|
|
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']);
|
|
|
|
p3.resolve('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');
|
|
});
|
|
|
|
it('should mask loading states during multi-step Guard UrlTree redirects', async () => {
|
|
let loader = promiseWithResolvers<string>();
|
|
|
|
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');
|
|
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);
|
|
|
|
loader = promiseWithResolvers<string>();
|
|
|
|
// 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');
|
|
|
|
loader.resolve('3');
|
|
await nav2;
|
|
await harness.fixture.whenStable();
|
|
|
|
expect(resourceRef.isLoading()).toBe(false);
|
|
expect(resourceRef.value()).toBe('3');
|
|
});
|
|
|
|
it('should be able to redirect from a blocking resource using a NavigationErrorHandler', async () => {
|
|
let handleCount = 0;
|
|
let errorRef: unknown = null;
|
|
|
|
const {harness, router} = await setupRouter(
|
|
[
|
|
{
|
|
path: 'test',
|
|
component: TargetCmp,
|
|
resources: () => ({
|
|
data: resource({
|
|
loader: async () => {
|
|
throw new Error('Resource failed!');
|
|
},
|
|
}),
|
|
}),
|
|
},
|
|
{
|
|
path: 'error',
|
|
component: TargetCmp,
|
|
},
|
|
],
|
|
withNavigationErrorHandler((e: NavigationError) => {
|
|
handleCount++;
|
|
errorRef = e.error;
|
|
return new RedirectCommand(TestBed.inject(Router).parseUrl('/error'));
|
|
}),
|
|
);
|
|
|
|
await harness.navigateByUrl('/test');
|
|
|
|
expect(router.url).toBe('/error');
|
|
expect(handleCount).toBe(1);
|
|
expect((errorRef as Error).message).toBe('Resource failed!');
|
|
});
|
|
|
|
it('should redirect when a blocking resource throws a RedirectCommand', async () => {
|
|
const {harness, router} = await setupRouter([
|
|
{
|
|
path: 'test',
|
|
component: TargetCmp,
|
|
resources: () => ({
|
|
data: resource({
|
|
loader: async () => {
|
|
throw new RedirectCommand(TestBed.inject(Router).parseUrl('/redirected'));
|
|
},
|
|
}),
|
|
}),
|
|
},
|
|
{
|
|
path: 'redirected',
|
|
component: TargetCmp,
|
|
},
|
|
]);
|
|
|
|
await harness.navigateByUrl('/test');
|
|
|
|
expect(router.url).toBe('/redirected');
|
|
});
|
|
});
|
|
|
|
describe('rxResource Integration', () => {
|
|
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);
|
|
|
|
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');
|
|
});
|
|
|
|
it('should remain blocked when a resource emits a value while remaining in loading state until loading completes', async () => {
|
|
const valueSignal = signal<string | undefined>(undefined);
|
|
const statusSignal = signal<ResourceStatus>('loading');
|
|
const isLoadingSignal = signal<boolean>(true);
|
|
const hasValueSignal = signal<boolean>(false);
|
|
|
|
const customResource: Resource<string> = {
|
|
value: valueSignal as Signal<string>,
|
|
status: statusSignal.asReadonly(),
|
|
isLoading: isLoadingSignal.asReadonly(),
|
|
hasValue: (() => hasValueSignal()) as any,
|
|
error: signal<Error | undefined>(undefined).asReadonly(),
|
|
snapshot: computed(
|
|
() =>
|
|
({
|
|
status: statusSignal(),
|
|
value: valueSignal()!,
|
|
}) as any,
|
|
),
|
|
};
|
|
|
|
const {harness, router} = await setupRouter([
|
|
{
|
|
path: 'stream',
|
|
component: TargetCmp,
|
|
resources: () => ({
|
|
data: customResource,
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const nav = harness.navigateByUrl('/stream');
|
|
await timeout();
|
|
|
|
// Navigation should be pending because customResource is in loading state
|
|
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 timeout();
|
|
|
|
// Navigation should still be blocked because loading state is true
|
|
expect(router.url).not.toBe('/stream');
|
|
|
|
// Resource finishes loading
|
|
statusSignal.set('resolved');
|
|
isLoadingSignal.set(false);
|
|
await nav;
|
|
|
|
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(false);
|
|
});
|
|
|
|
describe('dependent resources', () => {
|
|
it('should support resources depending on each other on the same route', async () => {
|
|
const {promise: userPromise, resolve: resolveUser} = promiseWithResolvers<{
|
|
id: string;
|
|
teamId: string;
|
|
}>();
|
|
|
|
const {harness, router} = await setupRouter([
|
|
{
|
|
path: 'user-team',
|
|
component: TargetCmp,
|
|
resources: () => {
|
|
const user = resource({
|
|
loader: () => userPromise,
|
|
});
|
|
const team = resource({
|
|
params: ({chain}) => chain(user)?.teamId,
|
|
loader: async ({params: teamId}) => ({id: teamId, name: 'Angular Team'}),
|
|
});
|
|
return {user, team};
|
|
},
|
|
},
|
|
]);
|
|
|
|
const nav = harness.navigateByUrl('/user-team');
|
|
await timeout();
|
|
|
|
// Navigation is blocked because user is loading and team depends on user
|
|
expect(router.url).not.toBe('/user-team');
|
|
|
|
// Resolve user
|
|
resolveUser({id: 'u1', teamId: 't42'});
|
|
await nav;
|
|
await harness.fixture.whenStable();
|
|
|
|
expect(router.url).toBe('/user-team');
|
|
const resources = (router.routerState.root.firstChild as ActivatedRouteInternal)
|
|
?.resources as any;
|
|
expect(resources['user'].value()).toEqual({id: 'u1', teamId: 't42'});
|
|
expect(resources['team'].value()).toEqual({id: 't42', name: 'Angular Team'});
|
|
});
|
|
|
|
it('should support child route resource depending on shared signal updated by parent resource', async () => {
|
|
const {promise: parentPromise, resolve: resolveParent} = promiseWithResolvers<any>();
|
|
const {promise: childPromise, resolve: resolveChild} = promiseWithResolvers<any>();
|
|
|
|
const sharedUserSignal = signal<any>(undefined);
|
|
|
|
const {harness, router} = await setupRouter([
|
|
{
|
|
path: 'user/:id',
|
|
component: TargetCmp,
|
|
resources: () => ({
|
|
user: resource({
|
|
loader: async () => {
|
|
const user = await parentPromise;
|
|
sharedUserSignal.set(user);
|
|
return user;
|
|
},
|
|
}),
|
|
}),
|
|
children: [
|
|
{
|
|
path: 'details',
|
|
component: TargetCmp,
|
|
resources: () => ({
|
|
details: resource({
|
|
params: () => sharedUserSignal()?.role,
|
|
loader: async ({params: role}) => {
|
|
if (!role) {
|
|
return new Promise(() => {});
|
|
}
|
|
return childPromise;
|
|
},
|
|
}),
|
|
}),
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const nav = harness.navigateByUrl('/user/1/details');
|
|
await timeout();
|
|
|
|
// Navigation is blocked
|
|
expect(router.url).not.toBe('/user/1/details');
|
|
|
|
// Resolve parent
|
|
resolveParent({id: '1', role: 'admin'});
|
|
await timeout();
|
|
|
|
// Still blocked on child
|
|
expect(router.url).not.toBe('/user/1/details');
|
|
|
|
// Resolve child
|
|
resolveChild({role: 'admin', permissions: ['read', 'write']});
|
|
await nav;
|
|
await harness.fixture.whenStable();
|
|
|
|
expect(router.url).toBe('/user/1/details');
|
|
const parentRoute = router.routerState.root.firstChild as ActivatedRouteInternal;
|
|
const childRoute = parentRoute.firstChild as ActivatedRouteInternal;
|
|
expect(parentRoute.resources?.['user'].value()).toEqual({id: '1', role: 'admin'});
|
|
expect(childRoute.resources?.['details'].value()).toEqual({
|
|
role: 'admin',
|
|
permissions: ['read', 'write'],
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|