/
githubmirror
/
angular
Обзор
Документация
Войти
/
githubmirror
/
angular
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/router/test/integration/guards.spec.ts
2 548 строк
83 KB
Andrew Scott
feat(router): allow throwing RedirectCommand to trigger redirects
11 авг 2026, 20:03
11 авг 2026, 20:03
b65dea4
Код
Авторство
О чём код?
/** * @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 {LocationStrategy, HashLocationStrategy, Location} from '@angular/common'; import { Injectable, DestroyRef, inject, Component, NgModule, InjectionToken, EnvironmentInjector, } from '@angular/core'; import {TestBed, ComponentFixture} from '@angular/core/testing'; import {Subject, Observable, of, concat, EMPTY} from 'rxjs'; import {tap, mapTo, first, takeWhile, last, switchMap} from 'rxjs/operators'; import { ActivatedRouteSnapshot, RouterStateSnapshot, Event, Router, NavigationStart, RoutesRecognized, Route, UrlSegment, GuardsCheckStart, ChildActivationStart, ActivationStart, GuardsCheckEnd, NavigationCancel, ResolveStart, ResolveEnd, ActivationEnd, ChildActivationEnd, NavigationEnd, provideRouter, withRouterConfig, RedirectCommand, RunGuardsAndResolvers, Data, RouterModule, NavigationCancellationCode, RouteConfigLoadStart, RouteConfigLoadEnd, UrlTree, CanMatchFn, CanActivateFn, CanActivateChildFn, CanDeactivateFn, PartialMatchRouteSnapshot, } from '../../src'; import {wrapIntoObservable} from '../../src/utils/collection'; import {RouterTestingHarness} from '../../testing'; import {expect} from '@angular/private/testing/matchers'; import { TeamCmp, RootCmp, BlankCmp, expectEvents, RouteCmp, RootCmpWithTwoOutlets, WrapperCmp, ThrowingCmp, SimpleCmp, TwoOutletsCmp, UserCmp, ModuleWithBlankCmpAsRoute, createRoot, advance, } from './integration_helpers'; import {timeout} from '@angular/private/testing'; export function guardsIntegrationSuite() { describe('guards', () => { describe('CanActivate', () => { describe('guard completes before emitting a value', () => { @Injectable({providedIn: 'root'}) class CompletesBeforeEmitting { private subject$ = new Subject<boolean>(); constructor(destroyRef: DestroyRef) { destroyRef.onDestroy(() => this.subject$.complete()); } // Note that this is a simple illustrative case of when an observable // completes without emitting a value. In a real-world scenario, this // might represent an HTTP request that never emits before the app is // destroyed and then completes when the app is destroyed. canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { return this.subject$; } } it('should not thrown an unhandled promise rejection', async () => { const router = TestBed.inject(Router); const fixture = await createRoot(router, RootCmp); const onUnhandledrejection = jasmine.createSpy(); window.addEventListener('unhandledrejection', onUnhandledrejection); router.resetConfig([ {path: 'team/:id', component: TeamCmp, canActivate: [CompletesBeforeEmitting]}, ]); router.navigateByUrl('/team/22'); // This was previously throwing an error `NG0205: Injector has already been destroyed`. fixture.destroy(); // Wait until the event task is dispatched. await timeout(10); window.removeEventListener('unhandledrejection', onUnhandledrejection); expect(onUnhandledrejection).not.toHaveBeenCalled(); }); }); describe('should not activate a route when CanActivate returns false', () => { it('works', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); const recordedEvents: Event[] = []; router.events.forEach((e) => recordedEvents.push(e)); router.resetConfig([{path: 'team/:id', component: TeamCmp, canActivate: [() => false]}]); router.navigateByUrl('/team/22'); await advance(fixture); expect(location.path()).toEqual(''); expectEvents(recordedEvents, [ [NavigationStart, '/team/22'], [RoutesRecognized, '/team/22'], [GuardsCheckStart, '/team/22'], [ChildActivationStart], [ActivationStart], [GuardsCheckEnd, '/team/22'], [NavigationCancel, '/team/22'], ]); expect((recordedEvents[5] as GuardsCheckEnd).shouldActivate).toBe(false); }); }); describe('should not activate a route when CanActivate returns false (componentless route)', () => { it('works', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'parent', canActivate: [() => false], children: [{path: 'team/:id', component: TeamCmp}], }, ]); router.navigateByUrl('parent/team/22'); await advance(fixture); expect(location.path()).toEqual(''); }); }); describe('should activate a route when CanActivate returns true', () => { it('works', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([{path: 'team/:id', component: TeamCmp, canActivate: [() => true]}]); router.navigateByUrl('/team/22'); await advance(fixture); expect(location.path()).toEqual('/team/22'); }); }); describe('should work when given a class', () => { class AlwaysTrue { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean { return true; } } beforeEach(() => { TestBed.configureTestingModule({providers: [AlwaysTrue]}); }); it('works', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([{path: 'team/:id', component: TeamCmp, canActivate: [AlwaysTrue]}]); router.navigateByUrl('/team/22'); await advance(fixture); expect(location.path()).toEqual('/team/22'); }); }); describe('should work when returns an observable', () => { it('works', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'team/:id', component: TeamCmp, canActivate: [ () => new Observable<boolean>((observer) => { observer.next(false); }), ], }, ]); router.navigateByUrl('/team/22'); await advance(fixture); expect(location.path()).toEqual(''); }); }); describe('should work when returns a promise', () => { it('works', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'team/:id', component: TeamCmp, canActivate: [ (a) => { if (a.params['id'] === '22') { return Promise.resolve(true); } else { return Promise.resolve(false); } }, ], }, ]); router.navigateByUrl('/team/22'); await advance(fixture); expect(location.path()).toEqual('/team/22'); router.navigateByUrl('/team/33'); await advance(fixture); expect(location.path()).toEqual('/team/22'); }); }); describe('should reset the location when cancelling a navigation', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [{provide: LocationStrategy, useClass: HashLocationStrategy}], }); }); it('works', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ {path: 'one', component: SimpleCmp}, {path: 'two', component: SimpleCmp, canActivate: [() => false]}, ]); router.navigateByUrl('/one'); await advance(fixture); expect(location.path()).toEqual('/one'); location.go('/two'); location.historyGo(0); await advance(fixture); expect(location.path()).toEqual('/one'); }); }); describe('should redirect to / when guard returns false', () => { it('works', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); router.resetConfig([ { path: '', component: SimpleCmp, }, { path: 'one', component: RouteCmp, canActivate: [ () => { inject(Router).navigate(['/']); return false; }, ], }, ]); const fixture = TestBed.createComponent(RootCmp); router.navigateByUrl('/one'); await advance(fixture); expect(location.path()).toEqual(''); expect(fixture.nativeElement).toHaveText('simple'); }); }); describe('should redirect when guard returns UrlTree', () => { it('works', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); const recordedEvents: Event[] = []; let cancelEvent: NavigationCancel = null!; router.events.forEach((e) => { recordedEvents.push(e); if (e instanceof NavigationCancel) cancelEvent = e; }); router.resetConfig([ {path: '', component: SimpleCmp}, { path: 'one', component: RouteCmp, canActivate: [() => inject(Router).parseUrl('/redirected')], }, {path: 'redirected', component: SimpleCmp}, ]); const fixture = TestBed.createComponent(RootCmp); router.navigateByUrl('/one'); await advance(fixture); expect(location.path()).toEqual('/redirected'); expect(fixture.nativeElement).toHaveText('simple'); expect(cancelEvent && cancelEvent.reason).toBe( 'NavigationCancelingError: Redirecting to "/redirected"', ); expectEvents(recordedEvents, [ [NavigationStart, '/one'], [RoutesRecognized, '/one'], [GuardsCheckStart, '/one'], [ChildActivationStart, undefined], [ActivationStart, undefined], [NavigationCancel, '/one'], [NavigationStart, '/redirected'], [RoutesRecognized, '/redirected'], [GuardsCheckStart, '/redirected'], [ChildActivationStart, undefined], [ActivationStart, undefined], [GuardsCheckEnd, '/redirected'], [ResolveStart, '/redirected'], [ResolveEnd, '/redirected'], [ActivationEnd, undefined], [ChildActivationEnd, undefined], [NavigationEnd, '/redirected'], ]); }); it('works with root url', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); const recordedEvents: Event[] = []; let cancelEvent: NavigationCancel = null!; router.events.forEach((e: any) => { recordedEvents.push(e); if (e instanceof NavigationCancel) cancelEvent = e; }); router.resetConfig([ {path: '', component: SimpleCmp}, { path: 'one', component: RouteCmp, canActivate: [() => inject(Router).parseUrl('/')], }, ]); const fixture = TestBed.createComponent(RootCmp); router.navigateByUrl('/one'); await advance(fixture); expect(location.path()).toEqual(''); expect(fixture.nativeElement).toHaveText('simple'); expect(cancelEvent && cancelEvent.reason).toBe( 'NavigationCancelingError: Redirecting to "/"', ); expectEvents(recordedEvents, [ [NavigationStart, '/one'], [RoutesRecognized, '/one'], [GuardsCheckStart, '/one'], [ChildActivationStart, undefined], [ActivationStart, undefined], [NavigationCancel, '/one'], [NavigationStart, '/'], [RoutesRecognized, '/'], [GuardsCheckStart, '/'], [ChildActivationStart, undefined], [ActivationStart, undefined], [GuardsCheckEnd, '/'], [ResolveStart, '/'], [ResolveEnd, '/'], [ActivationEnd, undefined], [ChildActivationEnd, undefined], [NavigationEnd, '/'], ]); }); it('replaces URL when URL is updated eagerly so back button can still work', async () => { TestBed.configureTestingModule({ providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))], }); const router = TestBed.inject(Router); const location = TestBed.inject(Location); router.resetConfig([ {path: '', component: SimpleCmp}, { path: 'one', component: RouteCmp, canActivate: [() => inject(Router).parseUrl('/redirected')], }, {path: 'redirected', component: SimpleCmp}, ]); await createRoot(router, RootCmp); router.navigateByUrl('/one'); const urlChanges: string[] = []; location.onUrlChange((change) => { urlChanges.push(change); }); await timeout(); expect(location.path()).toEqual('/redirected'); expect(urlChanges).toEqual(['/one', '/redirected']); location.back(); await timeout(); expect(location.path()).toEqual(''); }); it('should resolve navigateByUrl promise after redirect finishes', async () => { TestBed.configureTestingModule({ providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))], }); const router = TestBed.inject(Router); const location = TestBed.inject(Location); let resolvedPath = ''; router.resetConfig([ {path: '', component: SimpleCmp}, { path: 'one', component: RouteCmp, canActivate: [() => inject(Router).parseUrl('/redirected')], }, {path: 'redirected', component: SimpleCmp}, ]); const fixture = await createRoot(router, RootCmp); router.navigateByUrl('/one').then((v) => { resolvedPath = location.path(); }); await timeout(); expect(resolvedPath).toBe('/redirected'); }); it('can redirect to 404 without changing the URL', async () => { TestBed.configureTestingModule({ providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))], }); const router = TestBed.inject(Router); const location = TestBed.inject(Location); router.resetConfig([ {path: '', component: SimpleCmp}, { path: 'one', component: RouteCmp, canActivate: [ () => new RedirectCommand(router.parseUrl('/404'), {skipLocationChange: true}), ], }, {path: '404', component: SimpleCmp}, ]); const fixture = await createRoot(router, RootCmp); router.navigateByUrl('/one'); await advance(fixture); expect(location.path()).toEqual('/one'); expect(router.url.toString()).toEqual('/404'); }); it('can redirect while changing state object', async () => { TestBed.configureTestingModule({ providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))], }); const router = TestBed.inject(Router); const location = TestBed.inject(Location); router.resetConfig([ {path: '', component: SimpleCmp}, { path: 'one', component: RouteCmp, canActivate: [ () => new RedirectCommand(router.parseUrl('/redirected'), {state: {test: 1}}), ], }, {path: 'redirected', component: SimpleCmp}, ]); const fixture = await createRoot(router, RootCmp); router.navigateByUrl('/one'); await advance(fixture); expect(location.path()).toEqual('/redirected'); expect(location.getState()).toEqual(jasmine.objectContaining({test: 1})); }); }); it('can redirect to 404 without changing the URL', async () => { TestBed.configureTestingModule({ providers: [ provideRouter([ { path: 'one', component: RouteCmp, canActivate: [ () => { const router = inject(Router); router.navigateByUrl('/404', { browserUrl: router.getCurrentNavigation()?.finalUrl, }); return false; }, ], }, {path: '404', component: SimpleCmp}, ]), ], }); const location = TestBed.inject(Location); await RouterTestingHarness.create('/one'); expect(location.path()).toEqual('/one'); expect(TestBed.inject(Router).url.toString()).toEqual('/404'); }); it('can navigate to same internal route with different browser url', async () => { TestBed.configureTestingModule({ providers: [provideRouter([{path: 'one', component: RouteCmp}])], }); const location = TestBed.inject(Location); const router = TestBed.inject(Router); await RouterTestingHarness.create('/one'); await router.navigateByUrl('/one', {browserUrl: '/two'}); expect(location.path()).toEqual('/two'); expect(router.url.toString()).toEqual('/one'); }); it('retains browserUrl through UrlTree redirects', async () => { TestBed.configureTestingModule({ providers: [ provideRouter([ { path: 'one', component: RouteCmp, canActivate: [() => inject(Router).parseUrl('/404')], }, {path: '404', component: SimpleCmp}, ]), ], }); const router = TestBed.inject(Router); const location = TestBed.inject(Location); await RouterTestingHarness.create(); await router.navigateByUrl('/one', {browserUrl: router.parseUrl('abc123')}); expect(location.path()).toEqual('/abc123'); expect(TestBed.inject(Router).url.toString()).toEqual('/404'); }); describe('runGuardsAndResolvers', () => { let guardRunCount = 0; let resolverRunCount = 0; beforeEach(() => { guardRunCount = 0; resolverRunCount = 0; TestBed.configureTestingModule({ providers: [{provide: 'resolver', useValue: () => resolverRunCount++}], }); }); async function configureRouter( router: Router, runGuardsAndResolvers: RunGuardsAndResolvers, ): Promise<ComponentFixture<RootCmpWithTwoOutlets>> { const fixture = await createRoot(router, RootCmpWithTwoOutlets); router.resetConfig([ { path: 'a', runGuardsAndResolvers, component: RouteCmp, canActivate: [ () => { guardRunCount++; return true; }, ], resolve: {data: 'resolver'}, }, {path: 'b', component: SimpleCmp, outlet: 'right'}, { path: 'c/:param', runGuardsAndResolvers, component: RouteCmp, canActivate: [ () => { guardRunCount++; return true; }, ], resolve: {data: 'resolver'}, }, { path: 'd/:param', component: WrapperCmp, runGuardsAndResolvers, children: [ { path: 'e/:param', component: SimpleCmp, canActivate: [ () => { guardRunCount++; return true; }, ], resolve: {data: 'resolver'}, }, ], }, { path: 'throwing', runGuardsAndResolvers, component: ThrowingCmp, canActivate: [ () => { guardRunCount++; return true; }, ], resolve: {data: 'resolver'}, }, ]); router.navigateByUrl('/a'); await advance(fixture); return fixture; } it('should rerun guards and resolvers when params change', async () => { const router = TestBed.inject(Router); const fixture = await configureRouter(router, 'paramsChange'); const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; const recordedData: Data[] = []; cmp.route.data.subscribe((data) => recordedData.push(data)); expect(guardRunCount).toEqual(1); expect(recordedData).toEqual([{data: 0}]); router.navigateByUrl('/a;p=1'); await advance(fixture); expect(guardRunCount).toEqual(2); expect(recordedData).toEqual([{data: 0}, {data: 1}]); router.navigateByUrl('/a;p=2'); await advance(fixture); expect(guardRunCount).toEqual(3); expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]); router.navigateByUrl('/a;p=2?q=1'); await advance(fixture); expect(guardRunCount).toEqual(3); expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]); }); it('should rerun guards and resolvers when query params change', async () => { const router = TestBed.inject(Router); const fixture = await configureRouter(router, 'paramsOrQueryParamsChange'); const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; const recordedData: Data[] = []; cmp.route.data.subscribe((data) => recordedData.push(data)); expect(guardRunCount).toEqual(1); expect(recordedData).toEqual([{data: 0}]); router.navigateByUrl('/a;p=1'); await advance(fixture); expect(guardRunCount).toEqual(2); expect(recordedData).toEqual([{data: 0}, {data: 1}]); router.navigateByUrl('/a;p=2'); await advance(fixture); expect(guardRunCount).toEqual(3); expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]); router.navigateByUrl('/a;p=2?q=1'); await advance(fixture); expect(guardRunCount).toEqual(4); expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}, {data: 3}]); router.navigateByUrl('/a;p=2(right:b)?q=1'); await advance(fixture); expect(guardRunCount).toEqual(4); expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}, {data: 3}]); }); it('should always rerun guards and resolvers', async () => { const router = TestBed.inject(Router); const fixture = await configureRouter(router, 'always'); const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; const recordedData: Data[] = []; cmp.route.data.subscribe((data) => recordedData.push(data)); expect(guardRunCount).toEqual(1); expect(recordedData).toEqual([{data: 0}]); router.navigateByUrl('/a;p=1'); await advance(fixture); expect(guardRunCount).toEqual(2); expect(recordedData).toEqual([{data: 0}, {data: 1}]); router.navigateByUrl('/a;p=2'); await advance(fixture); expect(guardRunCount).toEqual(3); expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]); router.navigateByUrl('/a;p=2?q=1'); await advance(fixture); expect(guardRunCount).toEqual(4); expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}, {data: 3}]); router.navigateByUrl('/a;p=2(right:b)?q=1'); await advance(fixture); expect(guardRunCount).toEqual(5); expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}, {data: 3}, {data: 4}]); // Issue #39030, always running guards and resolvers should not throw // when navigating away from a component with a throwing constructor. await expectAsync( (async () => { router.navigateByUrl('/throwing').catch(() => {}); await advance(fixture); router.navigateByUrl('/a;p=1'); await advance(fixture); })(), ).not.toBeRejected(); }); it('should rerun rerun guards and resolvers when path params change', async () => { const router = TestBed.inject(Router); const fixture = await configureRouter(router, 'pathParamsChange'); const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; const recordedData: Data[] = []; cmp.route.data.subscribe((data) => recordedData.push(data)); // First navigation has already run expect(guardRunCount).toEqual(1); expect(recordedData).toEqual([{data: 0}]); // Changing any optional params will not result in running guards or resolvers router.navigateByUrl('/a;p=1'); await advance(fixture); expect(guardRunCount).toEqual(1); expect(recordedData).toEqual([{data: 0}]); router.navigateByUrl('/a;p=2'); await advance(fixture); expect(guardRunCount).toEqual(1); expect(recordedData).toEqual([{data: 0}]); router.navigateByUrl('/a;p=2?q=1'); await advance(fixture); expect(guardRunCount).toEqual(1); expect(recordedData).toEqual([{data: 0}]); router.navigateByUrl('/a;p=2(right:b)?q=1'); await advance(fixture); expect(guardRunCount).toEqual(1); expect(recordedData).toEqual([{data: 0}]); // Change to new route with path param should run guards and resolvers router.navigateByUrl('/c/paramValue'); await advance(fixture); expect(guardRunCount).toEqual(2); // Modifying a path param should run guards and resolvers router.navigateByUrl('/c/paramValueChanged'); await advance(fixture); expect(guardRunCount).toEqual(3); // Adding optional params should not cause guards/resolvers to run router.navigateByUrl('/c/paramValueChanged;p=1?q=2'); await advance(fixture); expect(guardRunCount).toEqual(3); }); it('should rerun when a parent segment changes', async () => { const router = TestBed.inject(Router); const fixture = await configureRouter(router, 'pathParamsChange'); const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; // Land on an initial page router.navigateByUrl('/d/1;dd=11/e/2;dd=22'); await advance(fixture); expect(guardRunCount).toEqual(2); // Changes cause re-run on the config with the guard router.navigateByUrl('/d/1;dd=11/e/3;ee=22'); await advance(fixture); expect(guardRunCount).toEqual(3); // Changes to the parent also cause re-run router.navigateByUrl('/d/2;dd=11/e/3;ee=22'); await advance(fixture); expect(guardRunCount).toEqual(4); }); it('should rerun rerun guards and resolvers when path or query params change', async () => { const router = TestBed.inject(Router); const fixture = await configureRouter(router, 'pathParamsOrQueryParamsChange'); const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; const recordedData: Data[] = []; cmp.route.data.subscribe((data) => recordedData.push(data)); // First navigation has already run expect(guardRunCount).toEqual(1); expect(recordedData).toEqual([{data: 0}]); // Changing matrix params will not result in running guards or resolvers router.navigateByUrl('/a;p=1'); await advance(fixture); expect(guardRunCount).toEqual(1); expect(recordedData).toEqual([{data: 0}]); router.navigateByUrl('/a;p=2'); await advance(fixture); expect(guardRunCount).toEqual(1); expect(recordedData).toEqual([{data: 0}]); // Adding query params will re-run guards/resolvers router.navigateByUrl('/a;p=2?q=1'); await advance(fixture); expect(guardRunCount).toEqual(2); expect(recordedData).toEqual([{data: 0}, {data: 1}]); // Changing query params will re-run guards/resolvers router.navigateByUrl('/a;p=2?q=2'); await advance(fixture); expect(guardRunCount).toEqual(3); expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]); }); it('should allow a predicate function to determine when to run guards and resolvers', async () => { const router = TestBed.inject(Router); const fixture = await configureRouter(router, (from, to) => to.paramMap.get('p') === '2'); const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; const recordedData: Data[] = []; cmp.route.data.subscribe((data) => recordedData.push(data)); // First navigation has already run expect(guardRunCount).toEqual(1); expect(recordedData).toEqual([{data: 0}]); // Adding `p` param shouldn't cause re-run router.navigateByUrl('/a;p=1'); await advance(fixture); expect(guardRunCount).toEqual(1); expect(recordedData).toEqual([{data: 0}]); // Re-run should trigger on p=2 router.navigateByUrl('/a;p=2'); await advance(fixture); expect(guardRunCount).toEqual(2); expect(recordedData).toEqual([{data: 0}, {data: 1}]); // Any other changes don't pass the predicate router.navigateByUrl('/a;p=3?q=1'); await advance(fixture); expect(guardRunCount).toEqual(2); expect(recordedData).toEqual([{data: 0}, {data: 1}]); // Changing query params will re-run guards/resolvers router.navigateByUrl('/a;p=3?q=2'); await advance(fixture); expect(guardRunCount).toEqual(2); expect(recordedData).toEqual([{data: 0}, {data: 1}]); }); }); describe('should wait for parent to complete', () => { let log: string[]; beforeEach(() => { log = []; }); function delayPromise(delay: number): Promise<boolean> { let resolve: (val: boolean) => void; const promise = new Promise<boolean>((res) => (resolve = res)); setTimeout(() => resolve(true), delay); return promise; } it('works', async () => { const router = TestBed.inject(Router); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'parent', canActivate: [ () => delayPromise(10).then(() => { log.push('parent'); return true; }), ], children: [ { path: 'child', component: SimpleCmp, canActivate: [ () => { return delayPromise(5).then(() => { log.push('child'); return true; }); }, ], }, ], }, ]); await router.navigateByUrl('/parent/child'); expect(log).toEqual(['parent', 'child']); }); }); }); describe('CanDeactivate', () => { let log: any; const recordingDeactivate: CanDeactivateFn<any> = (c, a) => { log.push({path: a.routeConfig!.path, component: c}); return true; }; beforeEach(() => { log = []; }); describe('should not deactivate a route when CanDeactivate returns false', () => { it('works', async () => { const router: Router = TestBed.inject(Router); const location: Location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'team/:id', component: TeamCmp, canDeactivate: [ (c, a) => { return a.params['id'] === '22'; }, ], }, ]); router.navigateByUrl('/team/22'); await advance(fixture); expect(location.path()).toEqual('/team/22'); let successStatus: boolean = false; router.navigateByUrl('/team/33')!.then((res) => (successStatus = res)); await advance(fixture); expect(location.path()).toEqual('/team/33'); expect(successStatus).toEqual(true); let canceledStatus: boolean = false; router.navigateByUrl('/team/44')!.then((res) => (canceledStatus = res)); await advance(fixture); expect(location.path()).toEqual('/team/33'); expect(canceledStatus).toEqual(false); }); it('works with componentless routes', async () => { const router: Router = TestBed.inject(Router); const location: Location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'grandparent', canDeactivate: [recordingDeactivate], children: [ { path: 'parent', canDeactivate: [recordingDeactivate], children: [ { path: 'child', canDeactivate: [recordingDeactivate], children: [ { path: 'simple', component: SimpleCmp, canDeactivate: [recordingDeactivate], }, ], }, ], }, ], }, {path: 'simple', component: SimpleCmp}, ]); router.navigateByUrl('/grandparent/parent/child/simple'); await advance(fixture); expect(location.path()).toEqual('/grandparent/parent/child/simple'); router.navigateByUrl('/simple'); await advance(fixture); const child = fixture.debugElement.children[1].componentInstance; expect(log.map((a: any) => a.path)).toEqual(['simple', 'child', 'parent', 'grandparent']); expect(log[0].component instanceof SimpleCmp).toBeTruthy(); [1, 2, 3].forEach((i) => expect(log[i].component).toBeNull()); expect(child instanceof SimpleCmp).toBeTruthy(); expect(child).not.toBe(log[0].component); }); it('works with aux routes', async () => { const router: Router = TestBed.inject(Router); const location: Location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'two-outlets', component: TwoOutletsCmp, children: [ {path: 'a', component: BlankCmp}, { path: 'b', canDeactivate: [recordingDeactivate], component: SimpleCmp, outlet: 'aux', }, ], }, ]); router.navigateByUrl('/two-outlets/(a//aux:b)'); await advance(fixture); expect(location.path()).toEqual('/two-outlets/(a//aux:b)'); router.navigate(['two-outlets', {outlets: {aux: null}}]); await advance(fixture); expect(log.map((a: any) => a.path)).toEqual(['b']); expect(location.path()).toEqual('/two-outlets/a'); }); it('works with a nested route', async () => { const router: Router = TestBed.inject(Router); const location: Location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'team/:id', component: TeamCmp, children: [ {path: '', pathMatch: 'full', component: SimpleCmp}, { path: 'user/:name', component: UserCmp, canDeactivate: [ (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { return a.params['name'] === 'victor'; }, ], }, ], }, ]); router.navigateByUrl('/team/22/user/victor'); await advance(fixture); // this works because we can deactivate victor router.navigateByUrl('/team/33'); await advance(fixture); expect(location.path()).toEqual('/team/33'); router.navigateByUrl('/team/33/user/fedor'); await advance(fixture); // this doesn't work cause we cannot deactivate fedor router.navigateByUrl('/team/44'); await advance(fixture); expect(location.path()).toEqual('/team/33/user/fedor'); }); }); it('should use correct component to deactivate forChild route', async () => { const router: Router = TestBed.inject(Router); @Component({ selector: 'admin', template: '', standalone: false, }) class AdminComponent {} @NgModule({ declarations: [AdminComponent], imports: [ RouterModule.forChild([ { path: '', component: AdminComponent, canDeactivate: [recordingDeactivate], }, ]), ], }) class LazyLoadedModule {} const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'a', component: WrapperCmp, children: [{path: '', pathMatch: 'full', loadChildren: () => LazyLoadedModule}], }, {path: 'b', component: SimpleCmp}, ]); router.navigateByUrl('/a'); await advance(fixture); router.navigateByUrl('/b'); await advance(fixture); expect(log[0].component).toBeInstanceOf(AdminComponent); }); it('should not create a route state if navigation is canceled', async () => { const router: Router = TestBed.inject(Router); const location: Location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'main', component: TeamCmp, children: [ {path: 'component1', component: SimpleCmp, canDeactivate: [() => false]}, {path: 'component2', component: SimpleCmp}, ], }, ]); router.navigateByUrl('/main/component1'); await advance(fixture); router.navigateByUrl('/main/component2'); await advance(fixture); const teamCmp = fixture.debugElement.children[1].componentInstance; expect(teamCmp.route.firstChild.url.value[0].path).toEqual('component1'); expect(location.path()).toEqual('/main/component1'); }); it('should not run CanActivate when CanDeactivate returns false', async () => { const router: Router = TestBed.inject(Router); const location: Location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'main', component: TeamCmp, children: [ { path: 'component1', component: SimpleCmp, canDeactivate: [ () => { log.push('called'); let resolve: (result: boolean) => void; const promise = new Promise((res) => (resolve = res)); setTimeout(() => resolve(false), 0); return promise; }, ], }, { path: 'component2', component: SimpleCmp, canActivate: [ () => { log.push('canActivate called'); return true; }, ], }, ], }, ]); router.navigateByUrl('/main/component1'); await advance(fixture); expect(location.path()).toEqual('/main/component1'); router.navigateByUrl('/main/component2'); await advance(fixture); expect(location.path()).toEqual('/main/component1'); expect(log).toEqual(['called']); }); it('should call guards every time when navigating to the same url over and over again', async () => { const router: Router = TestBed.inject(Router); const location: Location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'simple', component: SimpleCmp, canDeactivate: [ () => { log.push('called'); return false; }, ], }, {path: 'blank', component: BlankCmp}, ]); router.navigateByUrl('/simple'); await advance(fixture); router.navigateByUrl('/blank'); await advance(fixture); expect(log).toEqual(['called']); expect(location.path()).toEqual('/simple'); router.navigateByUrl('/blank'); await advance(fixture); expect(log).toEqual(['called', 'called']); expect(location.path()).toEqual('/simple'); }); describe('next state', () => { let log: string[]; class ClassWithNextState { canDeactivate( component: TeamCmp, currentRoute: ActivatedRouteSnapshot, currentState: RouterStateSnapshot, nextState: RouterStateSnapshot, ): boolean { log.push(currentState.url, nextState.url); return true; } } beforeEach(() => { log = []; TestBed.configureTestingModule({ providers: [ClassWithNextState], }); }); it('should pass next state as the 4 argument when guard is a class', async () => { const router: Router = TestBed.inject(Router); const location: Location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'team/:id', component: TeamCmp, canDeactivate: [ ( component: TeamCmp, currentRoute: ActivatedRouteSnapshot, currentState: RouterStateSnapshot, nextState: RouterStateSnapshot, ) => inject(ClassWithNextState).canDeactivate( component, currentRoute, currentState, nextState, ), ], }, ]); router.navigateByUrl('/team/22'); await advance(fixture); expect(location.path()).toEqual('/team/22'); router.navigateByUrl('/team/33'); await advance(fixture); expect(location.path()).toEqual('/team/33'); expect(log).toEqual(['/team/22', '/team/33']); }); it('should pass next state as the 4 argument when guard is a function', async () => { const router: Router = TestBed.inject(Router); const location: Location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'team/:id', component: TeamCmp, canDeactivate: [ (cmp, currentRoute, currentState, nextState) => { log.push(currentState.url, nextState.url); return true; }, ], }, ]); router.navigateByUrl('/team/22'); await advance(fixture); expect(location.path()).toEqual('/team/22'); router.navigateByUrl('/team/33'); await advance(fixture); expect(location.path()).toEqual('/team/33'); expect(log).toEqual(['/team/22', '/team/33']); }); }); describe('should work when given a class', () => { class AlwaysTrue { canDeactivate(): boolean { return true; } } beforeEach(() => { TestBed.configureTestingModule({providers: [AlwaysTrue]}); }); it('works', async () => { const router: Router = TestBed.inject(Router); const location: Location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'team/:id', component: TeamCmp, canDeactivate: [() => inject(AlwaysTrue).canDeactivate()], }, ]); router.navigateByUrl('/team/22'); await advance(fixture); expect(location.path()).toEqual('/team/22'); router.navigateByUrl('/team/33'); await advance(fixture); expect(location.path()).toEqual('/team/33'); }); }); describe('should work when returns an observable', () => { it('works', async () => { const router: Router = TestBed.inject(Router); const location: Location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'team/:id', component: TeamCmp, canDeactivate: [ () => { return new Observable<boolean>((observer) => { observer.next(false); }); }, ], }, ]); router.navigateByUrl('/team/22'); await advance(fixture); expect(location.path()).toEqual('/team/22'); router.navigateByUrl('/team/33'); await advance(fixture); expect(location.path()).toEqual('/team/22'); }); }); }); describe('CanActivateChild', () => { describe('should be invoked when activating a child', () => { it('works', async () => { const router: Router = TestBed.inject(Router); const location: Location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: '', canActivateChild: [(a: any) => a.paramMap.get('id') === '22'], children: [{path: 'team/:id', component: TeamCmp}], }, ]); router.navigateByUrl('/team/22'); await advance(fixture); expect(location.path()).toEqual('/team/22'); router.navigateByUrl('/team/33')!.catch(() => {}); await advance(fixture); expect(location.path()).toEqual('/team/22'); }); }); it('should find the guard provided in lazy loaded module', async () => { const router: Router = TestBed.inject(Router); const location: Location = TestBed.inject(Location); @Component({ selector: 'admin', template: '<router-outlet></router-outlet>', standalone: false, }) class AdminComponent {} @Component({ selector: 'lazy', template: 'lazy-loaded', standalone: false, }) class LazyLoadedComponent {} @NgModule({ declarations: [AdminComponent, LazyLoadedComponent], imports: [ RouterModule.forChild([ { path: '', component: AdminComponent, children: [ { path: '', canActivateChild: [() => true], children: [{path: '', component: LazyLoadedComponent}], }, ], }, ]), ], }) class LazyLoadedModule {} const fixture = await createRoot(router, RootCmp); router.resetConfig([{path: 'admin', loadChildren: () => LazyLoadedModule}]); router.navigateByUrl('/admin'); await advance(fixture); expect(location.path()).toEqual('/admin'); expect(fixture.nativeElement).toHaveText('lazy-loaded'); }); }); describe('CanLoad', () => { let canLoadRunCount = 0; beforeEach(() => { canLoadRunCount = 0; TestBed.configureTestingModule({ providers: [ { provide: () => true, useValue: () => { canLoadRunCount++; return true; }, }, ], }); }); it('should not load children when CanLoad returns false', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); @Component({ selector: 'lazy', template: 'lazy-loaded', standalone: false, }) class LazyLoadedComponent {} @NgModule({ declarations: [LazyLoadedComponent], imports: [RouterModule.forChild([{path: 'loaded', component: LazyLoadedComponent}])], }) class LoadedModule {} const fixture = await createRoot(router, RootCmp); router.resetConfig([ {path: 'lazyFalse', canLoad: [() => false], loadChildren: () => LoadedModule}, {path: 'lazyTrue', canLoad: [() => true], loadChildren: () => LoadedModule}, ]); const recordedEvents: Event[] = []; router.events.forEach((e) => recordedEvents.push(e)); // failed navigation router.navigateByUrl('/lazyFalse/loaded'); await advance(fixture); expect(location.path()).toEqual(''); expectEvents(recordedEvents, [ [NavigationStart, '/lazyFalse/loaded'], // [GuardsCheckStart, '/lazyFalse/loaded'], [NavigationCancel, '/lazyFalse/loaded'], ]); expect((recordedEvents[1] as NavigationCancel).code).toBe( NavigationCancellationCode.GuardRejected, ); recordedEvents.splice(0); // successful navigation router.navigateByUrl('/lazyTrue/loaded'); await advance(fixture); expect(location.path()).toEqual('/lazyTrue/loaded'); expectEvents(recordedEvents, [ [NavigationStart, '/lazyTrue/loaded'], [RouteConfigLoadStart], [RouteConfigLoadEnd], [RoutesRecognized, '/lazyTrue/loaded'], [GuardsCheckStart, '/lazyTrue/loaded'], [ChildActivationStart], [ActivationStart], [ChildActivationStart], [ActivationStart], [GuardsCheckEnd, '/lazyTrue/loaded'], [ResolveStart, '/lazyTrue/loaded'], [ResolveEnd, '/lazyTrue/loaded'], [ActivationEnd], [ChildActivationEnd], [ActivationEnd], [ChildActivationEnd], [NavigationEnd, '/lazyTrue/loaded'], ]); }); it('should support navigating from within the guard', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'lazyFalse', canLoad: [ () => { router.navigate(['blank']); return false; }, ], loadChildren: jasmine.createSpy('lazyFalse'), }, {path: 'blank', component: BlankCmp}, ]); const recordedEvents: Event[] = []; router.events.forEach((e) => recordedEvents.push(e)); router.navigateByUrl('/lazyFalse/loaded'); await advance(fixture); expect(location.path()).toEqual('/blank'); expectEvents(recordedEvents, [ [NavigationStart, '/lazyFalse/loaded'], // No GuardCheck events as `canLoad` is a special guard that's not actually part of // the guard lifecycle. [NavigationCancel, '/lazyFalse/loaded'], [NavigationStart, '/blank'], [RoutesRecognized, '/blank'], [GuardsCheckStart, '/blank'], [ChildActivationStart], [ActivationStart], [GuardsCheckEnd, '/blank'], [ResolveStart, '/blank'], [ResolveEnd, '/blank'], [ActivationEnd], [ChildActivationEnd], [NavigationEnd, '/blank'], ]); expect((recordedEvents[1] as NavigationCancel).code).toBe( NavigationCancellationCode.SupersededByNewNavigation, ); }); it('should support returning UrlTree from within the guard', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'lazyFalse', canLoad: [() => inject(Router).createUrlTree(['blank'])], loadChildren: jasmine.createSpy('lazyFalse'), }, {path: 'blank', component: BlankCmp}, ]); const recordedEvents: Event[] = []; router.events.forEach((e) => recordedEvents.push(e)); router.navigateByUrl('/lazyFalse/loaded'); await advance(fixture); expect(location.path()).toEqual('/blank'); expectEvents(recordedEvents, [ [NavigationStart, '/lazyFalse/loaded'], // No GuardCheck events as `canLoad` is a special guard that's not actually part of // the guard lifecycle. [NavigationCancel, '/lazyFalse/loaded'], [NavigationStart, '/blank'], [RoutesRecognized, '/blank'], [GuardsCheckStart, '/blank'], [ChildActivationStart], [ActivationStart], [GuardsCheckEnd, '/blank'], [ResolveStart, '/blank'], [ResolveEnd, '/blank'], [ActivationEnd], [ChildActivationEnd], [NavigationEnd, '/blank'], ]); expect((recordedEvents[1] as NavigationCancel).code).toBe( NavigationCancellationCode.Redirect, ); }); // Regression where navigateByUrl with false CanLoad no longer resolved `false` value on // navigateByUrl promise: https://github.com/angular/angular/issues/26284 it('should resolve navigateByUrl promise after CanLoad executes', async () => { const router = TestBed.inject(Router); @Component({ selector: 'lazy', template: 'lazy-loaded', standalone: false, }) class LazyLoadedComponent {} @NgModule({ declarations: [LazyLoadedComponent], imports: [RouterModule.forChild([{path: 'loaded', component: LazyLoadedComponent}])], }) class LazyLoadedModule {} const fixture = await createRoot(router, RootCmp); router.resetConfig([ {path: 'lazy-false', canLoad: [() => false], loadChildren: () => LazyLoadedModule}, {path: 'lazy-true', canLoad: [() => true], loadChildren: () => LazyLoadedModule}, ]); let navFalseResult = true; let navTrueResult = false; router.navigateByUrl('/lazy-false').then((v) => { navFalseResult = v; }); await advance(fixture); router.navigateByUrl('/lazy-true').then((v) => { navTrueResult = v; }); await advance(fixture); expect(navFalseResult).toBe(false); expect(navTrueResult).toBe(true); }); it('should execute CanLoad only once', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); @Component({ selector: 'lazy', template: 'lazy-loaded', standalone: false, }) class LazyLoadedComponent {} @NgModule({ declarations: [LazyLoadedComponent], imports: [RouterModule.forChild([{path: 'loaded', component: LazyLoadedComponent}])], }) class LazyLoadedModule {} const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'lazy', canLoad: [ () => { canLoadRunCount++; return true; }, ], loadChildren: () => LazyLoadedModule, }, ]); router.navigateByUrl('/lazy/loaded'); await advance(fixture); expect(location.path()).toEqual('/lazy/loaded'); expect(canLoadRunCount).toEqual(1); router.navigateByUrl('/'); await advance(fixture); expect(location.path()).toEqual(''); router.navigateByUrl('/lazy/loaded'); await advance(fixture); expect(location.path()).toEqual('/lazy/loaded'); expect(canLoadRunCount).toEqual(1); }); it('cancels guard execution when a new navigation happens', async () => { @Injectable({providedIn: 'root'}) class DelayedGuard { static delayedExecutions = 0; static canLoadCalls = 0; canLoad() { DelayedGuard.canLoadCalls++; return of(true).pipe( switchMap((v) => new Promise((r) => setTimeout(r, 10)).then(() => v)), tap(() => { DelayedGuard.delayedExecutions++; }), ); } } const router = TestBed.inject(Router); router.resetConfig([ {path: 'a', canLoad: [DelayedGuard], loadChildren: () => [], component: SimpleCmp}, {path: 'team/:id', component: TeamCmp}, ]); const fixture = await createRoot(router, RootCmp); router.navigateByUrl('/a'); await timeout(1); // The delayed guard should have started expect(DelayedGuard.canLoadCalls).toEqual(1); await router.navigateByUrl('/team/1'); expect(fixture.nativeElement.innerHTML).toContain('team'); // The delayed guard should not execute the delayed condition because a new navigation // cancels the current one and unsubscribes from intermediate results. expect(DelayedGuard.delayedExecutions).toEqual(0); }); }); describe('should run CanLoad guards concurrently', () => { function delayObservable(delayMs: number): Observable<boolean> { return of(delayMs).pipe( switchMap((v) => new Promise((r) => setTimeout(r, delayMs)).then(() => v)), mapTo(true), ); } let log: string[]; const guard1 = () => { return delayObservable(15).pipe(tap({next: () => log.push('guard1')})); }; const guard2 = () => { return delayObservable(0).pipe(tap({next: () => log.push('guard2')})); }; const returnFalseAndNavigate = () => { log.push('returnFalseAndNavigate'); inject(Router).navigateByUrl('/redirected'); return false; }; const returnUrlTree = () => { const router = inject(Router); return delayObservable(30).pipe( mapTo(router.parseUrl('/redirected')), tap({next: () => log.push('returnUrlTree')}), ); }; beforeEach(() => { log = []; }); it('should only execute canLoad guards of routes being activated', async () => { const router = TestBed.inject(Router); router.resetConfig([ { path: 'lazy', canLoad: [guard1], loadChildren: () => of(ModuleWithBlankCmpAsRoute), }, {path: 'redirected', component: SimpleCmp}, // canLoad should not run for this route because 'lazy' activates first { path: '', canLoad: [returnFalseAndNavigate], loadChildren: () => of(ModuleWithBlankCmpAsRoute), }, ]); await router.navigateByUrl('/lazy'); expect(log.length).toEqual(1); expect(log).toEqual(['guard1']); }); it('should execute canLoad guards', async () => { const router = TestBed.inject(Router); router.resetConfig([ { path: 'lazy', canLoad: [guard1, guard2], loadChildren: () => ModuleWithBlankCmpAsRoute, }, ]); await router.navigateByUrl('/lazy'); expect(log.length).toEqual(2); expect(log).toEqual(['guard2', 'guard1']); }); it('should redirect with UrlTree if higher priority guards have resolved', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); router.resetConfig([ { path: 'lazy', canLoad: [returnUrlTree, guard1, guard2], loadChildren: () => ModuleWithBlankCmpAsRoute, }, {path: 'redirected', component: SimpleCmp}, ]); await router.navigateByUrl('/lazy'); expect(log.length).toEqual(3); expect(log).toEqual(['guard2', 'guard1', 'returnUrlTree']); expect(location.path()).toEqual('/redirected'); }); it('should redirect with UrlTree if UrlTree is lower priority', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); router.resetConfig([ { path: 'lazy', canLoad: [guard1, returnUrlTree], loadChildren: () => ModuleWithBlankCmpAsRoute, }, {path: 'redirected', component: SimpleCmp}, ]); await router.navigateByUrl('/lazy'); expect(log.length).toEqual(2); expect(log).toEqual(['guard1', 'returnUrlTree']); expect(location.path()).toEqual('/redirected'); }); }); describe('order', () => { class Logger { logs: string[] = []; add(thing: string) { this.logs.push(thing); } } beforeEach(() => { TestBed.configureTestingModule({ providers: [Logger], }); }); it('should call guards in the right order', async () => { const router = TestBed.inject(Router); const logger = TestBed.inject(Logger); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: '', canActivateChild: [() => (logger.add('canActivateChild_parent'), true)], children: [ { path: 'team/:id', canActivate: [() => (logger.add('canActivate_team'), true)], canDeactivate: [() => (logger.add('canDeactivate_team'), true)], component: TeamCmp, }, ], }, ]); router.navigateByUrl('/team/22'); await advance(fixture); router.navigateByUrl('/team/33'); await advance(fixture); expect(logger.logs).toEqual([ 'canActivateChild_parent', 'canActivate_team', 'canDeactivate_team', 'canActivateChild_parent', 'canActivate_team', ]); }); it('should call deactivate guards from bottom to top', async () => { const router = TestBed.inject(Router); const logger = TestBed.inject(Logger); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: '', children: [ { path: 'team/:id', canDeactivate: [() => (logger.add('canDeactivate_team'), true)], children: [ { path: '', component: SimpleCmp, canDeactivate: [() => (logger.add('canDeactivate_simple'), true)], }, ], component: TeamCmp, }, ], }, ]); router.navigateByUrl('/team/22'); await advance(fixture); router.navigateByUrl('/team/33'); await advance(fixture); expect(logger.logs).toEqual(['canDeactivate_simple', 'canDeactivate_team']); }); }); describe('canMatch', () => { @Injectable({providedIn: 'root'}) class ConfigurableGuard { result: Promise<boolean | UrlTree> | Observable<boolean | UrlTree> | boolean | UrlTree = false; canMatch() { return this.result; } } it('falls back to second route when canMatch returns false', async () => { const router = TestBed.inject(Router); router.resetConfig([ { path: 'a', canMatch: [() => inject(ConfigurableGuard).canMatch()], component: BlankCmp, }, {path: 'a', component: SimpleCmp}, ]); const fixture = await createRoot(router, RootCmp); router.navigateByUrl('/a'); await advance(fixture); expect(fixture.nativeElement.innerHTML).toContain('simple'); }); it('falls back to second route when canMatch returns EMPTY', async () => { const router = TestBed.inject(Router); router.resetConfig([ { path: 'a', canMatch: [() => EMPTY], component: BlankCmp, }, {path: 'a', component: SimpleCmp}, ]); const fixture = await createRoot(router, RootCmp); router.navigateByUrl('/a'); await advance(fixture); expect(fixture.nativeElement.innerHTML).toContain('simple'); }); it('uses route when canMatch returns true', async () => { const router = TestBed.inject(Router); TestBed.inject(ConfigurableGuard).result = Promise.resolve(true); router.resetConfig([ { path: 'a', canMatch: [() => inject(ConfigurableGuard).canMatch()], component: SimpleCmp, }, {path: 'a', component: BlankCmp}, ]); const fixture = await createRoot(router, RootCmp); router.navigateByUrl('/a'); await advance(fixture); expect(fixture.nativeElement.innerHTML).toContain('simple'); }); it('can return UrlTree from canMatch guard', async () => { const router = TestBed.inject(Router); TestBed.inject(ConfigurableGuard).result = Promise.resolve( router.createUrlTree(['/team/1']), ); router.resetConfig([ { path: 'a', canMatch: [() => inject(ConfigurableGuard).canMatch()], component: SimpleCmp, }, {path: 'team/:id', component: TeamCmp}, ]); const fixture = await createRoot(router, RootCmp); router.navigateByUrl('/a'); await advance(fixture); expect(fixture.nativeElement.innerHTML).toContain('team'); }); it('can return UrlTree from CanMatchFn guard', async () => { const canMatchTeamSection = new InjectionToken('CanMatchTeamSection'); const canMatchFactory: (router: Router) => CanMatchFn = (router: Router) => () => router.createUrlTree(['/team/1']); TestBed.overrideProvider(canMatchTeamSection, { useFactory: canMatchFactory, deps: [Router], }); const router = TestBed.inject(Router); router.resetConfig([ {path: 'a', canMatch: [canMatchTeamSection], component: SimpleCmp}, {path: 'team/:id', component: TeamCmp}, ]); const fixture = await createRoot(router, RootCmp); router.navigateByUrl('/a'); await advance(fixture); expect(fixture.nativeElement.innerHTML).toContain('team'); }); it('runs canMatch guards provided in lazy module', async () => { const router = TestBed.inject(Router); @Component({ selector: 'lazy', template: 'lazy-loaded-parent [<router-outlet></router-outlet>]', standalone: false, }) class ParentLazyLoadedComponent {} @Component({ selector: 'lazy', template: 'lazy-loaded-child', standalone: false, }) class ChildLazyLoadedComponent {} @Injectable() class LazyCanMatchFalse { canMatch() { return false; } } @Component({ template: 'restricted', standalone: false, }) class Restricted {} @NgModule({ declarations: [ParentLazyLoadedComponent, ChildLazyLoadedComponent, Restricted], providers: [LazyCanMatchFalse], imports: [ RouterModule.forChild([ { path: 'loaded', canMatch: [LazyCanMatchFalse], component: Restricted, children: [{path: 'child', component: Restricted}], }, { path: 'loaded', component: ParentLazyLoadedComponent, children: [{path: 'child', component: ChildLazyLoadedComponent}], }, ]), ], }) class LoadedModule {} const fixture = await createRoot(router, RootCmp); router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); router.navigateByUrl('/lazy/loaded/child'); await advance(fixture); expect(TestBed.inject(Location).path()).toEqual('/lazy/loaded/child'); expect(fixture.nativeElement).toHaveText('lazy-loaded-parent [lazy-loaded-child]'); }); it('cancels guard execution when a new navigation happens', async () => { @Injectable({providedIn: 'root'}) class DelayedGuard { static delayedExecutions = 0; canMatch() { return of(true).pipe( switchMap((v) => new Promise((r) => setTimeout(r, 10)).then(() => v)), tap(() => { DelayedGuard.delayedExecutions++; }), ); } } const router = TestBed.inject(Router); const delayedGuardSpy = spyOn(TestBed.inject(DelayedGuard), 'canMatch'); delayedGuardSpy.and.callThrough(); const configurableMatchSpy = spyOn(TestBed.inject(ConfigurableGuard), 'canMatch'); configurableMatchSpy.and.callFake(() => { router.navigateByUrl('/team/1'); return false; }); router.resetConfig([ {path: 'a', canMatch: [ConfigurableGuard, DelayedGuard], component: SimpleCmp}, {path: 'a', canMatch: [ConfigurableGuard, DelayedGuard], component: SimpleCmp}, {path: 'a', canMatch: [ConfigurableGuard, DelayedGuard], component: SimpleCmp}, {path: 'team/:id', component: TeamCmp}, ]); const fixture = await createRoot(router, RootCmp); router.navigateByUrl('/a'); await advance(fixture); expect(fixture.nativeElement.innerHTML).toContain('team'); expect(configurableMatchSpy.calls.count()).toEqual(1); // The delayed guard should not execute the delayed condition because the other guard // initiates a new navigation, which cancels the current one and unsubscribes from // intermediate results. expect(DelayedGuard.delayedExecutions).toEqual(0); // The delayed guard should still have executed once because guards are executed at the // same time expect(delayedGuardSpy.calls.count()).toEqual(1); }); it('receives PreMatchRouteSnapshot as third argument', async () => { const router = TestBed.inject(Router); const fixture = await createRoot(router, RootCmp); let capturedSnapshot: PartialMatchRouteSnapshot | undefined; router.resetConfig([ { path: 'a/:id', canMatch: [ (route: Route, segments: UrlSegment[], snapshot: PartialMatchRouteSnapshot) => { capturedSnapshot = snapshot; return true; }, ], component: SimpleCmp, }, ]); await router.navigateByUrl('/a/1?q=2#f'); expect(capturedSnapshot).toBeDefined(); expect(capturedSnapshot!.params['id']).toBe('1'); expect(capturedSnapshot!.queryParams['q']).toBe('2'); expect(capturedSnapshot!.fragment).toBe('f'); }); it('can redirect based on snapshot params', async () => { const router = TestBed.inject(Router); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'a/:id', canMatch: [ (route: Route, segments: UrlSegment[], snapshot: PartialMatchRouteSnapshot) => { const router = inject(Router); if (snapshot.params['id'] === '1') { return router.parseUrl('/b'); } return true; }, ], component: SimpleCmp, }, {path: 'b', component: BlankCmp}, ]); await router.navigateByUrl('/a/1'); await advance(fixture); expect(router.url).toEqual('/b'); await router.navigateByUrl('/a/2'); await advance(fixture); expect(router.url).toEqual('/a/2'); }); }); it('should allow guards as functions', async () => { @Component({ template: '', }) class BlankCmp {} const router = TestBed.inject(Router); const fixture = await createRoot(router, RootCmp); const guards = { canActivate() { return true; }, canDeactivate() { return true; }, canActivateChild() { return true; }, canMatch() { return true; }, canLoad() { return true; }, }; spyOn(guards, 'canActivate').and.callThrough(); spyOn(guards, 'canActivateChild').and.callThrough(); spyOn(guards, 'canDeactivate').and.callThrough(); spyOn(guards, 'canLoad').and.callThrough(); spyOn(guards, 'canMatch').and.callThrough(); router.resetConfig([ { path: '', component: BlankCmp, loadChildren: () => [{path: '', component: BlankCmp}], canActivate: [guards.canActivate], canActivateChild: [guards.canActivateChild], canLoad: [guards.canLoad], canDeactivate: [guards.canDeactivate], canMatch: [guards.canMatch], }, { path: 'other', component: BlankCmp, }, ]); router.navigateByUrl('/'); await advance(fixture); expect(guards.canMatch).toHaveBeenCalled(); expect(guards.canLoad).toHaveBeenCalled(); expect(guards.canActivate).toHaveBeenCalled(); expect(guards.canActivateChild).toHaveBeenCalled(); router.navigateByUrl('/other'); await advance(fixture); expect(guards.canDeactivate).toHaveBeenCalled(); }); it('should allow DI in plain function guards', async () => { @Component({ template: '', }) class BlankCmp {} @Injectable({providedIn: 'root'}) class State { value = true; } const router = TestBed.inject(Router); const fixture = await createRoot(router, RootCmp); const guards = { canActivate() { return inject(State).value; }, canDeactivate() { return inject(State).value; }, canActivateChild() { return inject(State).value; }, canMatch() { return inject(State).value; }, canLoad() { return inject(State).value; }, }; spyOn(guards, 'canActivate').and.callThrough(); spyOn(guards, 'canActivateChild').and.callThrough(); spyOn(guards, 'canDeactivate').and.callThrough(); spyOn(guards, 'canLoad').and.callThrough(); spyOn(guards, 'canMatch').and.callThrough(); router.resetConfig([ { path: '', component: BlankCmp, loadChildren: () => [{path: '', component: BlankCmp}], canActivate: [guards.canActivate], canActivateChild: [guards.canActivateChild], canLoad: [guards.canLoad], canDeactivate: [guards.canDeactivate], canMatch: [guards.canMatch], }, { path: 'other', component: BlankCmp, }, ]); router.navigateByUrl('/'); await advance(fixture); expect(guards.canMatch).toHaveBeenCalled(); expect(guards.canLoad).toHaveBeenCalled(); expect(guards.canActivate).toHaveBeenCalled(); expect(guards.canActivateChild).toHaveBeenCalled(); router.navigateByUrl('/other'); await advance(fixture); expect(guards.canDeactivate).toHaveBeenCalled(); }); it('can run functional guards serially', async () => { function runSerially( guards: CanActivateFn[] | CanActivateChildFn[], ): CanActivateFn | CanActivateChildFn { return (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { const injector = inject(EnvironmentInjector); const observables = guards.map((guard) => { const guardResult = injector.runInContext(() => guard(route, state)); return wrapIntoObservable(guardResult).pipe(first()); }); return concat(...observables).pipe( takeWhile((v) => v === true), last(), ); }; } const guardDone: string[] = []; const guard1: CanActivateFn = () => of(true).pipe( switchMap((v) => new Promise((r) => setTimeout(r, 4)).then(() => v)), tap(() => guardDone.push('guard1')), ); const guard2: CanActivateFn = () => of(true).pipe(tap(() => guardDone.push('guard2'))); const guard3: CanActivateFn = () => of(true).pipe( switchMap((v) => new Promise((r) => setTimeout(r, 2)).then(() => v)), tap(() => guardDone.push('guard3')), ); const guard4: CanActivateFn = () => of(true).pipe( switchMap((v) => new Promise((r) => setTimeout(r, 6)).then(() => v)), tap(() => guardDone.push('guard4')), ); const router = TestBed.inject(Router); router.resetConfig([ { path: '**', component: BlankCmp, canActivate: [runSerially([guard1, guard2, guard3, guard4])], }, ]); await router.navigateByUrl(''); expect(guardDone).toEqual(['guard1', 'guard2', 'guard3', 'guard4']); }); it('should run in injection context', async () => { @Injectable({providedIn: 'root'}) class MyService { canRun = false; } let resolveCount = 0; const routes = [ { path: 'a', children: [], resolve: { x: () => ++resolveCount, }, runGuardsAndResolvers: () => inject(MyService).canRun, }, ]; const router = TestBed.inject(Router); router.resetConfig(routes); const service = TestBed.inject(MyService); await router.navigateByUrl('/a'); expect(router.url).toEqual('/a'); // Always run on activation expect(resolveCount).toBe(1); service.canRun = false; await router.navigateByUrl('/a?q=1'); expect(resolveCount).toBe(1); service.canRun = true; await router.navigateByUrl('/a?q=2'); expect(resolveCount).toBe(2); }); describe('throwing redirect', () => { it('should redirect when a guard throws a RedirectCommand', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'a', canActivate: [ () => { throw new RedirectCommand(router.parseUrl('/b')); }, ], component: BlankCmp, }, { path: 'b', component: BlankCmp, }, ]); router.navigateByUrl('/a'); await advance(fixture); expect(location.path()).toEqual('/b'); }); it('should redirect when a resolver throws a RedirectCommand', async () => { const router = TestBed.inject(Router); const location = TestBed.inject(Location); const fixture = await createRoot(router, RootCmp); router.resetConfig([ { path: 'a', resolve: { data: () => { throw new RedirectCommand(router.parseUrl('/b')); }, }, component: BlankCmp, }, { path: 'b', component: BlankCmp, }, ]); router.navigateByUrl('/a'); await advance(fixture); expect(location.path()).toEqual('/b'); }); }); }); }