From 15c48113c23d95a622bcb798f62e33e751509cb9 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Tue, 19 Dec 2023 12:42:54 -0800 Subject: [PATCH] refactor(router): Update integration tests to cover navigation and history API (#53799) This commit updates the router integration tests to cover both the classic History and the new Navigation API. There is more work to be done here, but this commit works to prove the efficacy of the `FakeNavigation` implementation. PR Close #53799 --- package.json | 1 - packages/common/BUILD.bazel | 1 - .../common/src/navigation/navigation_types.ts | 181 + .../src/navigation/platform_navigation.ts | 4 +- packages/common/src/private_export.ts | 1 + .../testing/src/mock_platform_location.ts | 85 +- .../testing/src/navigation/fake_navigation.ts | 3 +- .../src/navigation/navigation_types.ts | 181 + .../provide_fake_platform_navigation.ts | 12 +- packages/common/testing/src/private_export.ts | 9 + packages/common/testing/src/testing.ts | 2 + packages/router/src/utils/view_transition.ts | 2 +- packages/router/test/integration.spec.ts | 11812 ++++++++-------- packages/tsconfig.json | 2 +- 14 files changed, 6396 insertions(+), 5900 deletions(-) create mode 100644 packages/common/src/navigation/navigation_types.ts create mode 100644 packages/common/testing/src/navigation/navigation_types.ts create mode 100644 packages/common/testing/src/private_export.ts diff --git a/package.json b/package.json index a62f3c52cec03..8003c75b3f77d 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,6 @@ "@types/chrome": "^0.0.258", "@types/convert-source-map": "^2.0.0", "@types/diff": "^5.0.0", - "@types/dom-navigation": "^1.0.2", "@types/dom-view-transitions": "^1.0.1", "@types/hammerjs": "2.0.45", "@types/jasmine": "^5.0.0", diff --git a/packages/common/BUILD.bazel b/packages/common/BUILD.bazel index 0a44be4dd4977..c7d2d9a364afb 100644 --- a/packages/common/BUILD.bazel +++ b/packages/common/BUILD.bazel @@ -30,7 +30,6 @@ ng_module( ), deps = [ "//packages/core", - "@npm//@types/dom-navigation", "@npm//rxjs", ], ) diff --git a/packages/common/src/navigation/navigation_types.ts b/packages/common/src/navigation/navigation_types.ts new file mode 100644 index 0000000000000..370ecf1ececbb --- /dev/null +++ b/packages/common/src/navigation/navigation_types.ts @@ -0,0 +1,181 @@ +/** + * @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.io/license + */ + +export interface NavigationEventMap { + navigate: NavigateEvent; + navigatesuccess: Event; + navigateerror: ErrorEvent; + currententrychange: NavigationCurrentEntryChangeEvent; +} + +export interface NavigationResult { + committed: Promise; + finished: Promise; +} + +export declare class Navigation extends EventTarget { + entries(): NavigationHistoryEntry[]; + readonly currentEntry: NavigationHistoryEntry|null; + updateCurrentEntry(options: NavigationUpdateCurrentEntryOptions): void; + readonly transition: NavigationTransition|null; + + readonly canGoBack: boolean; + readonly canGoForward: boolean; + + navigate(url: string, options?: NavigationNavigateOptions): NavigationResult; + reload(options?: NavigationReloadOptions): NavigationResult; + + traverseTo(key: string, options?: NavigationOptions): NavigationResult; + back(options?: NavigationOptions): NavigationResult; + forward(options?: NavigationOptions): NavigationResult; + + onnavigate: ((this: Navigation, ev: NavigateEvent) => any)|null; + onnavigatesuccess: ((this: Navigation, ev: Event) => any)|null; + onnavigateerror: ((this: Navigation, ev: ErrorEvent) => any)|null; + oncurrententrychange: ((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any)|null; + + addEventListener( + type: K, + listener: (this: Navigation, ev: NavigationEventMap[K]) => any, + options?: boolean|AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean|AddEventListenerOptions, + ): void; + removeEventListener( + type: K, + listener: (this: Navigation, ev: NavigationEventMap[K]) => any, + options?: boolean|EventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean|EventListenerOptions, + ): void; +} + +export declare class NavigationTransition { + readonly navigationType: NavigationTypeString; + readonly from: NavigationHistoryEntry; + readonly finished: Promise; +} + +export interface NavigationHistoryEntryEventMap { + dispose: Event; +} + +export declare class NavigationHistoryEntry extends EventTarget { + readonly key: string; + readonly id: string; + readonly url: string|null; + readonly index: number; + readonly sameDocument: boolean; + + getState(): unknown; + + ondispose: ((this: NavigationHistoryEntry, ev: Event) => any)|null; + + addEventListener( + type: K, + listener: (this: NavigationHistoryEntry, ev: NavigationHistoryEntryEventMap[K]) => any, + options?: boolean|AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean|AddEventListenerOptions, + ): void; + removeEventListener( + type: K, + listener: (this: NavigationHistoryEntry, ev: NavigationHistoryEntryEventMap[K]) => any, + options?: boolean|EventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean|EventListenerOptions, + ): void; +} + +type NavigationTypeString = 'reload'|'push'|'replace'|'traverse'; + +export interface NavigationUpdateCurrentEntryOptions { + state: unknown; +} + +export interface NavigationOptions { + info?: unknown; +} + +export interface NavigationNavigateOptions extends NavigationOptions { + state?: unknown; + history?: 'auto'|'push'|'replace'; +} + +export interface NavigationReloadOptions extends NavigationOptions { + state?: unknown; +} + +export declare class NavigationCurrentEntryChangeEvent extends Event { + constructor(type: string, eventInit?: NavigationCurrentEntryChangeEventInit); + + readonly navigationType: NavigationTypeString|null; + readonly from: NavigationHistoryEntry; +} + +export interface NavigationCurrentEntryChangeEventInit extends EventInit { + navigationType?: NavigationTypeString|null; + from: NavigationHistoryEntry; +} + +export declare class NavigateEvent extends Event { + constructor(type: string, eventInit?: NavigateEventInit); + + readonly navigationType: NavigationTypeString; + readonly canIntercept: boolean; + readonly userInitiated: boolean; + readonly hashChange: boolean; + readonly destination: NavigationDestination; + readonly signal: AbortSignal; + readonly formData: FormData|null; + readonly downloadRequest: string|null; + readonly info?: unknown; + + intercept(options?: NavigationInterceptOptions): void; + scroll(): void; +} + +export interface NavigateEventInit extends EventInit { + navigationType?: NavigationTypeString; + canIntercept?: boolean; + userInitiated?: boolean; + hashChange?: boolean; + destination: NavigationDestination; + signal: AbortSignal; + formData?: FormData|null; + downloadRequest?: string|null; + info?: unknown; +} + +export interface NavigationInterceptOptions { + handler?: () => Promise; + focusReset?: 'after-transition'|'manual'; + scroll?: 'after-transition'|'manual'; +} + +export declare class NavigationDestination { + readonly url: string; + readonly key: string|null; + readonly id: string|null; + readonly index: number; + readonly sameDocument: boolean; + + getState(): unknown; +} diff --git a/packages/common/src/navigation/platform_navigation.ts b/packages/common/src/navigation/platform_navigation.ts index 5030414afaba7..55d404ea31ada 100644 --- a/packages/common/src/navigation/platform_navigation.ts +++ b/packages/common/src/navigation/platform_navigation.ts @@ -8,11 +8,13 @@ import {Injectable} from '@angular/core'; +import {NavigateEvent, Navigation, NavigationCurrentEntryChangeEvent, NavigationHistoryEntry, NavigationNavigateOptions, NavigationOptions, NavigationReloadOptions, NavigationResult, NavigationTransition, NavigationUpdateCurrentEntryOptions} from './navigation_types'; + /** * This class wraps the platform Navigation API which allows server-specific and test * implementations. */ -@Injectable({providedIn: 'platform', useFactory: () => window.navigation}) +@Injectable({providedIn: 'platform', useFactory: () => (window as any).navigation}) export abstract class PlatformNavigation implements Navigation { abstract entries(): NavigationHistoryEntry[]; abstract currentEntry: NavigationHistoryEntry|null; diff --git a/packages/common/src/private_export.ts b/packages/common/src/private_export.ts index c4b334cd1773c..ec155798ca316 100644 --- a/packages/common/src/private_export.ts +++ b/packages/common/src/private_export.ts @@ -7,3 +7,4 @@ */ export {DomAdapter as ɵDomAdapter, getDOM as ɵgetDOM, setRootDomAdapter as ɵsetRootDomAdapter} from './dom_adapter'; +export {PlatformNavigation as ɵPlatformNavigation} from './navigation/platform_navigation'; diff --git a/packages/common/testing/src/mock_platform_location.ts b/packages/common/testing/src/mock_platform_location.ts index 5ab18e1ce7610..2a370900e0954 100644 --- a/packages/common/testing/src/mock_platform_location.ts +++ b/packages/common/testing/src/mock_platform_location.ts @@ -6,10 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ -import {LocationChangeEvent, LocationChangeListener, PlatformLocation} from '@angular/common'; -import {Inject, Injectable, InjectionToken, Optional} from '@angular/core'; +import {DOCUMENT, LocationChangeEvent, LocationChangeListener, PlatformLocation, ɵPlatformNavigation as PlatformNavigation} from '@angular/common'; +import {Inject, inject, Injectable, InjectionToken, Optional} from '@angular/core'; import {Subject} from 'rxjs'; +import {FakeNavigation} from './navigation/fake_navigation'; + /** * Parser from https://tools.ietf.org/html/rfc3986#appendix-B * ^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))? @@ -250,3 +252,82 @@ export class MockPlatformLocation implements PlatformLocation { } } } + +/** + * Mock implementation of URL state. + */ +@Injectable() +export class FakeNavigationPlatformLocation implements PlatformLocation { + private _platformNavigation = inject(PlatformNavigation) as FakeNavigation; + private window = inject(DOCUMENT).defaultView!; + + constructor() { + if (!(this._platformNavigation instanceof FakeNavigation)) { + throw new Error( + 'FakePlatformNavigation cannot be used without FakeNavigation. Use ' + + '`provideFakeNavigation` to have all these services provided together.', + ); + } + } + + private config = inject(MOCK_PLATFORM_LOCATION_CONFIG, {optional: true}); + getBaseHrefFromDOM(): string { + return this.config?.appBaseHref ?? ''; + } + + onPopState(fn: LocationChangeListener): VoidFunction { + this.window.addEventListener('popstate', fn); + return () => this.window.removeEventListener('popstate', fn); + } + + onHashChange(fn: LocationChangeListener): VoidFunction { + this.window.addEventListener('hashchange', fn as any); + return () => this.window.removeEventListener('hashchange', fn as any); + } + + get href(): string { + return this._platformNavigation.currentEntry.url!; + } + get protocol(): string { + return new URL(this._platformNavigation.currentEntry.url!).protocol; + } + get hostname(): string { + return new URL(this._platformNavigation.currentEntry.url!).hostname; + } + get port(): string { + return new URL(this._platformNavigation.currentEntry.url!).port; + } + get pathname(): string { + return new URL(this._platformNavigation.currentEntry.url!).pathname; + } + get search(): string { + return new URL(this._platformNavigation.currentEntry.url!).search; + } + get hash(): string { + return new URL(this._platformNavigation.currentEntry.url!).hash; + } + + pushState(state: any, title: string, url: string): void { + this._platformNavigation.pushState(state, title, url); + } + + replaceState(state: any, title: string, url: string): void { + this._platformNavigation.replaceState(state, title, url); + } + + forward(): void { + this._platformNavigation.forward(); + } + + back(): void { + this._platformNavigation.back(); + } + + historyGo(relativePosition: number = 0): void { + this._platformNavigation.go(relativePosition); + } + + getState(): unknown { + return this._platformNavigation.currentEntry.getHistoryState(); + } +} diff --git a/packages/common/testing/src/navigation/fake_navigation.ts b/packages/common/testing/src/navigation/fake_navigation.ts index b202db6db87c4..175cdbff39c3f 100644 --- a/packages/common/testing/src/navigation/fake_navigation.ts +++ b/packages/common/testing/src/navigation/fake_navigation.ts @@ -6,8 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -// Prevents deletion of `Event` from `globalThis` during module loading. -const Event = globalThis.Event; +import {NavigateEvent, Navigation, NavigationCurrentEntryChangeEvent, NavigationDestination, NavigationHistoryEntry, NavigationInterceptOptions, NavigationNavigateOptions, NavigationOptions, NavigationReloadOptions, NavigationResult, NavigationTransition, NavigationTypeString, NavigationUpdateCurrentEntryOptions} from './navigation_types'; /** * Fake implementation of user agent history and navigation behavior. This is a diff --git a/packages/common/testing/src/navigation/navigation_types.ts b/packages/common/testing/src/navigation/navigation_types.ts new file mode 100644 index 0000000000000..4b5c43884c352 --- /dev/null +++ b/packages/common/testing/src/navigation/navigation_types.ts @@ -0,0 +1,181 @@ +/** + * @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.io/license + */ + +export interface NavigationEventMap { + navigate: NavigateEvent; + navigatesuccess: Event; + navigateerror: ErrorEvent; + currententrychange: NavigationCurrentEntryChangeEvent; +} + +export interface NavigationResult { + committed: Promise; + finished: Promise; +} + +export declare class Navigation extends EventTarget { + entries(): NavigationHistoryEntry[]; + readonly currentEntry: NavigationHistoryEntry|null; + updateCurrentEntry(options: NavigationUpdateCurrentEntryOptions): void; + readonly transition: NavigationTransition|null; + + readonly canGoBack: boolean; + readonly canGoForward: boolean; + + navigate(url: string, options?: NavigationNavigateOptions): NavigationResult; + reload(options?: NavigationReloadOptions): NavigationResult; + + traverseTo(key: string, options?: NavigationOptions): NavigationResult; + back(options?: NavigationOptions): NavigationResult; + forward(options?: NavigationOptions): NavigationResult; + + onnavigate: ((this: Navigation, ev: NavigateEvent) => any)|null; + onnavigatesuccess: ((this: Navigation, ev: Event) => any)|null; + onnavigateerror: ((this: Navigation, ev: ErrorEvent) => any)|null; + oncurrententrychange: ((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any)|null; + + addEventListener( + type: K, + listener: (this: Navigation, ev: NavigationEventMap[K]) => any, + options?: boolean|AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean|AddEventListenerOptions, + ): void; + removeEventListener( + type: K, + listener: (this: Navigation, ev: NavigationEventMap[K]) => any, + options?: boolean|EventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean|EventListenerOptions, + ): void; +} + +export declare class NavigationTransition { + readonly navigationType: NavigationTypeString; + readonly from: NavigationHistoryEntry; + readonly finished: Promise; +} + +export interface NavigationHistoryEntryEventMap { + dispose: Event; +} + +export declare class NavigationHistoryEntry extends EventTarget { + readonly key: string; + readonly id: string; + readonly url: string|null; + readonly index: number; + readonly sameDocument: boolean; + + getState(): unknown; + + ondispose: ((this: NavigationHistoryEntry, ev: Event) => any)|null; + + addEventListener( + type: K, + listener: (this: NavigationHistoryEntry, ev: NavigationHistoryEntryEventMap[K]) => any, + options?: boolean|AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean|AddEventListenerOptions, + ): void; + removeEventListener( + type: K, + listener: (this: NavigationHistoryEntry, ev: NavigationHistoryEntryEventMap[K]) => any, + options?: boolean|EventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean|EventListenerOptions, + ): void; +} + +export type NavigationTypeString = 'reload'|'push'|'replace'|'traverse'; + +export interface NavigationUpdateCurrentEntryOptions { + state: unknown; +} + +export interface NavigationOptions { + info?: unknown; +} + +export interface NavigationNavigateOptions extends NavigationOptions { + state?: unknown; + history?: 'auto'|'push'|'replace'; +} + +export interface NavigationReloadOptions extends NavigationOptions { + state?: unknown; +} + +export declare class NavigationCurrentEntryChangeEvent extends Event { + constructor(type: string, eventInit?: NavigationCurrentEntryChangeEventInit); + + readonly navigationType: NavigationTypeString|null; + readonly from: NavigationHistoryEntry; +} + +export interface NavigationCurrentEntryChangeEventInit extends EventInit { + navigationType?: NavigationTypeString|null; + from: NavigationHistoryEntry; +} + +export declare class NavigateEvent extends Event { + constructor(type: string, eventInit?: NavigateEventInit); + + readonly navigationType: NavigationTypeString; + readonly canIntercept: boolean; + readonly userInitiated: boolean; + readonly hashChange: boolean; + readonly destination: NavigationDestination; + readonly signal: AbortSignal; + readonly formData: FormData|null; + readonly downloadRequest: string|null; + readonly info?: unknown; + + intercept(options?: NavigationInterceptOptions): void; + scroll(): void; +} + +export interface NavigateEventInit extends EventInit { + navigationType?: NavigationTypeString; + canIntercept?: boolean; + userInitiated?: boolean; + hashChange?: boolean; + destination: NavigationDestination; + signal: AbortSignal; + formData?: FormData|null; + downloadRequest?: string|null; + info?: unknown; +} + +export interface NavigationInterceptOptions { + handler?: () => Promise; + focusReset?: 'after-transition'|'manual'; + scroll?: 'after-transition'|'manual'; +} + +export declare class NavigationDestination { + readonly url: string; + readonly key: string|null; + readonly id: string|null; + readonly index: number; + readonly sameDocument: boolean; + + getState(): unknown; +} diff --git a/packages/common/testing/src/navigation/provide_fake_platform_navigation.ts b/packages/common/testing/src/navigation/provide_fake_platform_navigation.ts index e6a71ac67ab5e..b26e71057e737 100644 --- a/packages/common/testing/src/navigation/provide_fake_platform_navigation.ts +++ b/packages/common/testing/src/navigation/provide_fake_platform_navigation.ts @@ -6,25 +6,29 @@ * found in the LICENSE file at https://angular.io/license */ -import {Provider} from '@angular/core'; +import {DOCUMENT, PlatformLocation} from '@angular/common'; +import {inject, Provider} from '@angular/core'; // @ng_package: ignore-cross-repo-import import {PlatformNavigation} from '../../../src/navigation/platform_navigation'; +import {FakeNavigationPlatformLocation, MOCK_PLATFORM_LOCATION_CONFIG} from '../mock_platform_location'; import {FakeNavigation} from './fake_navigation'; /** * Return a provider for the `FakeNavigation` in place of the real Navigation API. - * - * @internal */ export function provideFakePlatformNavigation(): Provider[] { return [ { provide: PlatformNavigation, useFactory: () => { - return new FakeNavigation(window, 'https://test.com'); + const config = inject(MOCK_PLATFORM_LOCATION_CONFIG, {optional: true}); + return new FakeNavigation( + inject(DOCUMENT).defaultView!, + config?.startUrl as `http${string}` ?? 'http://_empty_/'); } }, + {provide: PlatformLocation, useClass: FakeNavigationPlatformLocation}, ]; } diff --git a/packages/common/testing/src/private_export.ts b/packages/common/testing/src/private_export.ts new file mode 100644 index 0000000000000..47f6c855b7d68 --- /dev/null +++ b/packages/common/testing/src/private_export.ts @@ -0,0 +1,9 @@ +/** + * @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.io/license + */ + +export {provideFakePlatformNavigation as ɵprovideFakePlatformNavigation} from './navigation/provide_fake_platform_navigation'; diff --git a/packages/common/testing/src/testing.ts b/packages/common/testing/src/testing.ts index 7eb5601515ded..90d059cecc441 100644 --- a/packages/common/testing/src/testing.ts +++ b/packages/common/testing/src/testing.ts @@ -11,6 +11,8 @@ * @description * Entry point for all public APIs of the common/testing package. */ + +export * from './private_export'; export {SpyLocation} from './location_mock'; export {MockLocationStrategy} from './mock_location_strategy'; export {MOCK_PLATFORM_LOCATION_CONFIG, MockPlatformLocation, MockPlatformLocationConfig} from './mock_platform_location'; diff --git a/packages/router/src/utils/view_transition.ts b/packages/router/src/utils/view_transition.ts index cff250daa246d..24b656ab4608b 100644 --- a/packages/router/src/utils/view_transition.ts +++ b/packages/router/src/utils/view_transition.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -/// +/// import {DOCUMENT} from '@angular/common'; import {afterNextRender, InjectionToken, Injector, NgZone, runInInjectionContext} from '@angular/core'; diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts index bc265f0d56d4f..8f4f0c920402a 100644 --- a/packages/router/test/integration.spec.ts +++ b/packages/router/test/integration.spec.ts @@ -6,12 +6,13 @@ * found in the LICENSE file at https://angular.io/license */ -import {CommonModule, HashLocationStrategy, Location, LocationStrategy, PlatformLocation, PopStateEvent} from '@angular/common'; -import {ApplicationRef, ChangeDetectionStrategy, Component, EnvironmentInjector, inject as coreInject, Inject, Injectable, InjectionToken, NgModule, NgModuleRef, NgZone, OnDestroy, QueryList, Type, ViewChild, ViewChildren, ɵConsole as Console, ɵNoopNgZone as NoopNgZone} from '@angular/core'; +import {CommonModule, HashLocationStrategy, Location, LocationStrategy} from '@angular/common'; +import {ɵprovideFakePlatformNavigation} from '@angular/common/testing'; +import {ChangeDetectionStrategy, Component, EnvironmentInjector, inject as coreInject, Inject, Injectable, InjectionToken, NgModule, NgModuleRef, NgZone, OnDestroy, QueryList, Type, ViewChildren, ɵConsole as Console, ɵNoopNgZone as NoopNgZone} from '@angular/core'; import {ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing'; import {By} from '@angular/platform-browser/src/dom/debug/by'; import {expect} from '@angular/platform-browser/testing/src/matchers'; -import {ActivatedRoute, ActivatedRouteSnapshot, ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, DefaultUrlSerializer, DetachedRouteHandle, Event, GuardsCheckEnd, GuardsCheckStart, Navigation, NavigationCancel, NavigationCancellationCode, NavigationEnd, NavigationError, NavigationSkipped, NavigationStart, ParamMap, Params, PreloadAllModules, PreloadingStrategy, PRIMARY_OUTLET, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router, RouteReuseStrategy, RouterEvent, RouterLink, RouterLinkActive, RouterModule, RouterOutlet, RouterPreloader, RouterStateSnapshot, RoutesRecognized, RunGuardsAndResolvers, UrlHandlingStrategy, UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from '@angular/router'; +import {ActivatedRoute, ActivatedRouteSnapshot, ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, DefaultUrlSerializer, DetachedRouteHandle, Event, GuardsCheckEnd, GuardsCheckStart, Navigation, NavigationCancel, NavigationCancellationCode, NavigationEnd, NavigationError, NavigationSkipped, NavigationStart, ParamMap, Params, PreloadAllModules, PreloadingStrategy, PRIMARY_OUTLET, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router, RouteReuseStrategy, RouterEvent, RouterLink, RouterLinkActive, RouterModule, RouterOutlet, RouterPreloader, RouterStateSnapshot, RoutesRecognized, RunGuardsAndResolvers, UrlHandlingStrategy, UrlSegment, UrlSegmentGroup, UrlTree} from '@angular/router'; import {RouterTestingHarness} from '@angular/router/testing'; import {concat, EMPTY, firstValueFrom, Observable, Observer, of, Subscription} from 'rxjs'; import {delay, filter, first, last, map, mapTo, takeWhile, tap} from 'rxjs/operators'; @@ -23,7090 +24,7127 @@ import {getLoadedRoutes} from '../src/utils/config'; const ROUTER_DIRECTIVES = [RouterLink, RouterLinkActive, RouterOutlet]; -describe('Integration', () => { - const noopConsole: Console = {log() {}, warn() {}}; +for (const browserAPI of ['navigation', 'history'] as const) { + describe(`${browserAPI}-based routing`, () => { + const noopConsole: Console = {log() {}, warn() {}}; - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [...ROUTER_DIRECTIVES, TestModule], - providers: [ - {provide: Console, useValue: noopConsole}, - provideRouter([{path: 'simple', component: SimpleCmp}]), - ] - }); - }); - - it('should navigate with a provided config', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); - - router.navigateByUrl('/simple'); - advance(fixture); - - expect(location.path()).toEqual('/simple'); - }))); - - it('should navigate from ngOnInit hook', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - router.resetConfig([ - {path: '', component: SimpleCmp}, - {path: 'one', component: RouteCmp}, - ]); - - const fixture = createRoot(router, RootCmpWithOnInit); - expect(location.path()).toEqual('/one'); - expect(fixture.nativeElement).toHaveText('route'); - }))); - - describe('navigation', function() { - it('should navigate to the current URL', fakeAsync(() => { - TestBed.configureTestingModule({ - providers: [ - provideRouter([], withRouterConfig({onSameUrlNavigation: 'reload'})), - ] - }); - const router = TestBed.inject(Router); - router.resetConfig([ - {path: '', component: SimpleCmp}, - {path: 'simple', component: SimpleCmp}, - ]); - - const events: (NavigationStart|NavigationEnd)[] = []; - router.events.subscribe(e => onlyNavigationStartAndEnd(e) && events.push(e)); - - router.navigateByUrl('/simple'); - tick(); - - router.navigateByUrl('/simple'); - tick(); - - expectEvents(events, [ - [NavigationStart, '/simple'], [NavigationEnd, '/simple'], [NavigationStart, '/simple'], - [NavigationEnd, '/simple'] - ]); - })); - - it('should override default onSameUrlNavigation with extras', async () => { - TestBed.configureTestingModule({ - providers: [ - provideRouter([], withRouterConfig({onSameUrlNavigation: 'ignore'})), - ] - }); - const router = TestBed.inject(Router); - router.resetConfig([ - {path: '', component: SimpleCmp}, - {path: 'simple', component: SimpleCmp}, - ]); - - const events: (NavigationStart|NavigationEnd)[] = []; - router.events.subscribe(e => onlyNavigationStartAndEnd(e) && events.push(e)); - - await router.navigateByUrl('/simple'); - await router.navigateByUrl('/simple'); - // By default, the second navigation is ignored - expectEvents(events, [[NavigationStart, '/simple'], [NavigationEnd, '/simple']]); - await router.navigateByUrl('/simple', {onSameUrlNavigation: 'reload'}); - // We overrode the `onSameUrlNavigation` value. This navigation should be processed. - expectEvents(events, [ - [NavigationStart, '/simple'], [NavigationEnd, '/simple'], [NavigationStart, '/simple'], - [NavigationEnd, '/simple'] - ]); - }); - - it('should override default onSameUrlNavigation with extras', async () => { + beforeEach(() => { TestBed.configureTestingModule({ + imports: [...ROUTER_DIRECTIVES, TestModule], providers: [ - provideRouter([], withRouterConfig({onSameUrlNavigation: 'reload'})), + {provide: Console, useValue: noopConsole}, + provideRouter([{path: 'simple', component: SimpleCmp}]), + browserAPI === 'navigation' ? ɵprovideFakePlatformNavigation() : [], ] }); - const router = TestBed.inject(Router); - router.resetConfig([ - {path: '', component: SimpleCmp}, - {path: 'simple', component: SimpleCmp}, - ]); - - const events: (NavigationStart|NavigationEnd)[] = []; - router.events.subscribe(e => onlyNavigationStartAndEnd(e) && events.push(e)); - - await router.navigateByUrl('/simple'); - await router.navigateByUrl('/simple'); - expectEvents(events, [ - [NavigationStart, '/simple'], [NavigationEnd, '/simple'], [NavigationStart, '/simple'], - [NavigationEnd, '/simple'] - ]); - - events.length = 0; - await router.navigateByUrl('/simple', {onSameUrlNavigation: 'ignore'}); - expectEvents(events, []); - }); - - it('should set transient navigation info', async () => { - let observedInfo: unknown; - const router = TestBed.inject(Router); - router.resetConfig([ - { - path: 'simple', - component: SimpleCmp, - canActivate: [() => { - observedInfo = coreInject(Router).getCurrentNavigation()?.extras?.info; - return true; - }] - }, - ]); - - await router.navigateByUrl('/simple', {info: 'navigation info'}); - expect(observedInfo).toEqual('navigation info'); - }); - - it('should set transient navigation info for routerlink', async () => { - let observedInfo: unknown; - const router = TestBed.inject(Router); - router.resetConfig([ - { - path: 'simple', - component: SimpleCmp, - canActivate: [() => { - observedInfo = coreInject(Router).getCurrentNavigation()?.extras?.info; - return true; - }] - }, - ]); - @Component({ - standalone: true, - imports: [RouterLink], - template: `` - }) - class App { - } - - const fixture = TestBed.createComponent(App); - fixture.autoDetectChanges(); - const anchor = fixture.nativeElement.querySelector('a'); - anchor.click(); - await fixture.whenStable(); - - // An example use-case might be to pass the clicked link along with the navigation information - expect(observedInfo).toBeInstanceOf(HTMLAnchorElement); - }); - - it('should make transient navigation info available in redirect', async () => { - let observedInfo: unknown; - const router = TestBed.inject(Router); - router.resetConfig([ - { - path: 'redirect', - component: SimpleCmp, - canActivate: [() => coreInject(Router).parseUrl('/simple')] - }, - { - path: 'simple', - component: SimpleCmp, - canActivate: [() => { - observedInfo = coreInject(Router).getCurrentNavigation()?.extras?.info; - return true; - }] - }, - ]); - - await router.navigateByUrl('/redirect', {info: 'navigation info'}); - expect(observedInfo).toBe('navigation info'); - expect(router.url).toEqual('/simple'); }); - it('should ignore empty paths in relative links', - fakeAsync(inject([Router], (router: Router) => { - router.resetConfig([{ - path: 'foo', - children: [{path: 'bar', children: [{path: '', component: RelativeLinkCmp}]}] - }]); - + it('should navigate with a provided config', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/foo/bar'); + router.navigateByUrl('/simple'); advance(fixture); - const link = fixture.nativeElement.querySelector('a'); - expect(link.getAttribute('href')).toEqual('/foo/simple'); + expect(location.path()).toEqual('/simple'); }))); - it('should set the restoredState to null when executing imperative navigations', - fakeAsync(inject([Router], (router: Router) => { + it('should navigate from ngOnInit hook', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { router.resetConfig([ {path: '', component: SimpleCmp}, - {path: 'simple', component: SimpleCmp}, + {path: 'one', component: RouteCmp}, ]); - const fixture = createRoot(router, RootCmp); - let event: NavigationStart; - router.events.subscribe(e => { - if (e instanceof NavigationStart) { - event = e; - } - }); - - router.navigateByUrl('/simple'); - tick(); - - expect(event!.navigationTrigger).toEqual('imperative'); - expect(event!.restoredState).toEqual(null); + const fixture = createRoot(router, RootCmpWithOnInit); + expect(location.path()).toEqual('/one'); + expect(fixture.nativeElement).toHaveText('route'); }))); - it('should set history.state if passed using imperative navigation', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - router.resetConfig([ - {path: '', component: SimpleCmp}, - {path: 'simple', component: SimpleCmp}, - ]); + describe('navigation', function() { + it('should navigate to the current URL', fakeAsync(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([], withRouterConfig({onSameUrlNavigation: 'reload'})), + ] + }); + const router = TestBed.inject(Router); + router.resetConfig([ + {path: '', component: SimpleCmp}, + {path: 'simple', component: SimpleCmp}, + ]); - const fixture = createRoot(router, RootCmp); - let navigation: Navigation = null!; - router.events.subscribe(e => { - if (e instanceof NavigationStart) { - navigation = router.getCurrentNavigation()!; - } - }); + const events: (NavigationStart|NavigationEnd)[] = []; + router.events.subscribe(e => onlyNavigationStartAndEnd(e) && events.push(e)); - router.navigateByUrl('/simple', {state: {foo: 'bar'}}); - tick(); + router.navigateByUrl('/simple'); + tick(); - const state = location.getState() as any; - expect(state.foo).toBe('bar'); - expect(state).toEqual({foo: 'bar', navigationId: 2}); - expect(navigation.extras.state).toBeDefined(); - expect(navigation.extras.state).toEqual({foo: 'bar'}); - }))); + router.navigateByUrl('/simple'); + tick(); - it('should set history.state when navigation with browser back and forward', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - router.resetConfig([ - {path: '', component: SimpleCmp}, - {path: 'simple', component: SimpleCmp}, - ]); + expectEvents(events, [ + [NavigationStart, '/simple'], [NavigationEnd, '/simple'], [NavigationStart, '/simple'], + [NavigationEnd, '/simple'] + ]); + })); - const fixture = createRoot(router, RootCmp); - let navigation: Navigation = null!; - router.events.subscribe(e => { - if (e instanceof NavigationStart) { - navigation = router.getCurrentNavigation()!; - } - }); + it('should override default onSameUrlNavigation with extras', async () => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([], withRouterConfig({onSameUrlNavigation: 'ignore'})), + ] + }); + const router = TestBed.inject(Router); + router.resetConfig([ + {path: '', component: SimpleCmp}, + {path: 'simple', component: SimpleCmp}, + ]); + + const events: (NavigationStart|NavigationEnd)[] = []; + router.events.subscribe(e => onlyNavigationStartAndEnd(e) && events.push(e)); + + await router.navigateByUrl('/simple'); + await router.navigateByUrl('/simple'); + // By default, the second navigation is ignored + expectEvents(events, [[NavigationStart, '/simple'], [NavigationEnd, '/simple']]); + await router.navigateByUrl('/simple', {onSameUrlNavigation: 'reload'}); + // We overrode the `onSameUrlNavigation` value. This navigation should be processed. + expectEvents(events, [ + [NavigationStart, '/simple'], [NavigationEnd, '/simple'], [NavigationStart, '/simple'], + [NavigationEnd, '/simple'] + ]); + }); - let state: Record = {foo: 'bar'}; - router.navigateByUrl('/simple', {state}); - tick(); - location.back(); - tick(); - location.forward(); - tick(); + it('should override default onSameUrlNavigation with extras', async () => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([], withRouterConfig({onSameUrlNavigation: 'reload'})), + ] + }); + const router = TestBed.inject(Router); + router.resetConfig([ + {path: '', component: SimpleCmp}, + {path: 'simple', component: SimpleCmp}, + ]); + + const events: (NavigationStart|NavigationEnd)[] = []; + router.events.subscribe(e => onlyNavigationStartAndEnd(e) && events.push(e)); + + await router.navigateByUrl('/simple'); + await router.navigateByUrl('/simple'); + expectEvents(events, [ + [NavigationStart, '/simple'], [NavigationEnd, '/simple'], [NavigationStart, '/simple'], + [NavigationEnd, '/simple'] + ]); + + events.length = 0; + await router.navigateByUrl('/simple', {onSameUrlNavigation: 'ignore'}); + expectEvents(events, []); + }); - expect(navigation.extras.state).toBeDefined(); - expect(navigation.extras.state).toEqual(state); + it('should set transient navigation info', async () => { + let observedInfo: unknown; + const router = TestBed.inject(Router); + router.resetConfig([ + { + path: 'simple', + component: SimpleCmp, + canActivate: [() => { + observedInfo = coreInject(Router).getCurrentNavigation()?.extras?.info; + return true; + }] + }, + ]); - // Manually set state rather than using navigate() - state = {bar: 'foo'}; - location.replaceState(location.path(), '', state); - location.back(); - tick(); - location.forward(); - tick(); + await router.navigateByUrl('/simple', {info: 'navigation info'}); + expect(observedInfo).toEqual('navigation info'); + }); - expect(navigation.extras.state).toBeDefined(); - expect(navigation.extras.state).toEqual(state); - }))); + it('should set transient navigation info for routerlink', async () => { + let observedInfo: unknown; + const router = TestBed.inject(Router); + router.resetConfig([ + { + path: 'simple', + component: SimpleCmp, + canActivate: [() => { + observedInfo = coreInject(Router).getCurrentNavigation()?.extras?.info; + return true; + }] + }, + ]); + @Component({ + standalone: true, + imports: [RouterLink], + template: `` + }) + class App { + } - it('should navigate correctly when using `Location#historyGo', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - router.resetConfig([ - {path: 'first', component: SimpleCmp}, - {path: 'second', component: SimpleCmp}, + const fixture = TestBed.createComponent(App); + fixture.autoDetectChanges(); + const anchor = fixture.nativeElement.querySelector('a'); + anchor.click(); + await fixture.whenStable(); - ]); + // An example use-case might be to pass the clicked link along with the navigation + // information + expect(observedInfo).toBeInstanceOf(HTMLAnchorElement); + }); - createRoot(router, RootCmp); + it('should make transient navigation info available in redirect', async () => { + let observedInfo: unknown; + const router = TestBed.inject(Router); + router.resetConfig([ + { + path: 'redirect', + component: SimpleCmp, + canActivate: [() => coreInject(Router).parseUrl('/simple')] + }, + { + path: 'simple', + component: SimpleCmp, + canActivate: [() => { + observedInfo = coreInject(Router).getCurrentNavigation()?.extras?.info; + return true; + }] + }, + ]); - router.navigateByUrl('/first'); - tick(); - router.navigateByUrl('/second'); - tick(); - expect(router.url).toEqual('/second'); + await router.navigateByUrl('/redirect', {info: 'navigation info'}); + expect(observedInfo).toBe('navigation info'); + expect(router.url).toEqual('/simple'); + }); - location.historyGo(-1); - tick(); - expect(router.url).toEqual('/first'); + it('should ignore empty paths in relative links', + fakeAsync(inject([Router], (router: Router) => { + router.resetConfig([{ + path: 'foo', + children: [{path: 'bar', children: [{path: '', component: RelativeLinkCmp}]}] + }]); - location.historyGo(1); - tick(); - expect(router.url).toEqual('/second'); + const fixture = createRoot(router, RootCmp); - location.historyGo(-100); - tick(); - expect(router.url).toEqual('/second'); + router.navigateByUrl('/foo/bar'); + advance(fixture); - location.historyGo(100); - tick(); - expect(router.url).toEqual('/second'); + const link = fixture.nativeElement.querySelector('a'); + expect(link.getAttribute('href')).toEqual('/foo/simple'); + }))); - location.historyGo(0); - tick(); - expect(router.url).toEqual('/second'); + it('should set the restoredState to null when executing imperative navigations', + fakeAsync(inject([Router], (router: Router) => { + router.resetConfig([ + {path: '', component: SimpleCmp}, + {path: 'simple', component: SimpleCmp}, + ]); - location.historyGo(); - tick(); - expect(router.url).toEqual('/second'); - }))); + const fixture = createRoot(router, RootCmp); + let event: NavigationStart; + router.events.subscribe(e => { + if (e instanceof NavigationStart) { + event = e; + } + }); - it('should not error if state is not {[key: string]: any}', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - router.resetConfig([ - {path: '', component: SimpleCmp}, - {path: 'simple', component: SimpleCmp}, - ]); + router.navigateByUrl('/simple'); + tick(); - const fixture = createRoot(router, RootCmp); - let navigation: Navigation = null!; - router.events.subscribe(e => { - if (e instanceof NavigationStart) { - navigation = router.getCurrentNavigation()!; - } - }); + expect(event!.navigationTrigger).toEqual('imperative'); + expect(event!.restoredState).toEqual(null); + }))); - location.replaceState('', '', 42); - router.navigateByUrl('/simple'); - tick(); - location.back(); - advance(fixture); + it('should set history.state if passed using imperative navigation', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + router.resetConfig([ + {path: '', component: SimpleCmp}, + {path: 'simple', component: SimpleCmp}, + ]); - // Angular does not support restoring state to the primitive. - expect(navigation.extras.state).toEqual(undefined); - expect(location.getState()).toEqual({navigationId: 3}); - }))); + const fixture = createRoot(router, RootCmp); + let navigation: Navigation = null!; + router.events.subscribe(e => { + if (e instanceof NavigationStart) { + navigation = router.getCurrentNavigation()!; + } + }); - it('should not pollute browser history when replaceUrl is set to true', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - router.resetConfig([ - {path: '', component: SimpleCmp}, {path: 'a', component: SimpleCmp}, - {path: 'b', component: SimpleCmp} - ]); + router.navigateByUrl('/simple', {state: {foo: 'bar'}}); + tick(); - createRoot(router, RootCmp); + const state = location.getState() as any; + expect(state.foo).toBe('bar'); + expect(state).toEqual({foo: 'bar', navigationId: 2}); + expect(navigation.extras.state).toBeDefined(); + expect(navigation.extras.state).toEqual({foo: 'bar'}); + }))); - const replaceSpy = spyOn(location, 'replaceState'); - router.navigateByUrl('/a', {replaceUrl: true}); - router.navigateByUrl('/b', {replaceUrl: true}); - tick(); + it('should set history.state when navigation with browser back and forward', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + router.resetConfig([ + {path: '', component: SimpleCmp}, + {path: 'simple', component: SimpleCmp}, + ]); - expect(replaceSpy.calls.count()).toEqual(1); - }))); + const fixture = createRoot(router, RootCmp); + let navigation: Navigation = null!; + router.events.subscribe(e => { + if (e instanceof NavigationStart) { + navigation = router.getCurrentNavigation()!; + } + }); - it('should skip navigation if another navigation is already scheduled', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - router.resetConfig([ - {path: '', component: SimpleCmp}, {path: 'a', component: SimpleCmp}, - {path: 'b', component: SimpleCmp} - ]); + let state: Record = {foo: 'bar'}; + router.navigateByUrl('/simple', {state}); + tick(); + location.back(); + tick(); + location.forward(); + tick(); - const fixture = createRoot(router, RootCmp); + expect(navigation.extras.state).toBeDefined(); + expect(navigation.extras.state).toEqual(state); - router.navigate( - ['/a'], {queryParams: {a: true}, queryParamsHandling: 'merge', replaceUrl: true}); - router.navigate( - ['/b'], {queryParams: {b: true}, queryParamsHandling: 'merge', replaceUrl: true}); - tick(); - - /** - * Why do we have '/b?b=true' and not '/b?a=true&b=true'? - * - * This is because the router has the right to stop a navigation mid-flight if another - * navigation has been already scheduled. This is why we can use a top-level guard - * to perform redirects. Calling `navigate` in such a guard will stop the navigation, and - * the components won't be instantiated. - * - * This is a fundamental property of the router: it only cares about its latest state. - * - * This means that components should only map params to something else, not reduce them. - * In other words, the following component is asking for trouble: - * - * ``` - * class MyComponent { - * constructor(a: ActivatedRoute) { - * a.params.scan(...) - * } - * } - * ``` - * - * This also means "queryParamsHandling: 'merge'" should only be used to merge with - * long-living query parameters (e.g., debug). - */ - expect(router.url).toEqual('/b?b=true'); - }))); - }); + // Manually set state rather than using navigate() + state = {bar: 'foo'}; + location.replaceState(location.path(), '', state); + location.back(); + tick(); + location.forward(); + tick(); - describe('navigation warning', () => { - const isInAngularZoneFn = NgZone.isInAngularZone; - let warnings: string[] = []; - let isInAngularZone = true; + expect(navigation.extras.state).toBeDefined(); + expect(navigation.extras.state).toEqual(state); + }))); - class MockConsole { - warn(message: string) { - warnings.push(message); - } - } + it('should navigate correctly when using `Location#historyGo', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + router.resetConfig([ + {path: 'first', component: SimpleCmp}, + {path: 'second', component: SimpleCmp}, - beforeEach(() => { - warnings = []; - isInAngularZone = true; - NgZone.isInAngularZone = () => isInAngularZone; - TestBed.overrideProvider(Console, {useValue: new MockConsole()}); - }); + ]); - afterEach(() => { - NgZone.isInAngularZone = isInAngularZoneFn; - }); + createRoot(router, RootCmp); - describe('with NgZone enabled', () => { - it('should warn when triggered outside Angular zone', - fakeAsync(inject([Router], (router: Router) => { - isInAngularZone = false; - router.navigateByUrl('/simple'); + router.navigateByUrl('/first'); + tick(); + router.navigateByUrl('/second'); + tick(); + expect(router.url).toEqual('/second'); - expect(warnings.length).toBe(1); - expect(warnings[0]) - .toBe( - `Navigation triggered outside Angular zone, did you forget to call 'ngZone.run()'?`); - }))); + location.historyGo(-1); + tick(); + expect(router.url).toEqual('/first'); - it('should not warn when triggered inside Angular zone', - fakeAsync(inject([Router], (router: Router) => { - router.navigateByUrl('/simple'); + location.historyGo(1); + tick(); + expect(router.url).toEqual('/second'); - expect(warnings.length).toBe(0); - }))); - }); + location.historyGo(-100); + tick(); + expect(router.url).toEqual('/second'); - describe('with NgZone disabled', () => { - beforeEach(() => { - TestBed.overrideProvider(NgZone, {useValue: new NoopNgZone()}); - }); + location.historyGo(100); + tick(); + expect(router.url).toEqual('/second'); - it('should not warn when triggered outside Angular zone', - fakeAsync(inject([Router], (router: Router) => { - isInAngularZone = false; - router.navigateByUrl('/simple'); + location.historyGo(0); + tick(); + expect(router.url).toEqual('/second'); - expect(warnings.length).toBe(0); + location.historyGo(); + tick(); + expect(router.url).toEqual('/second'); }))); - }); - }); - describe('should execute navigations serially', () => { - let log: Array = []; - - beforeEach(() => { - log = []; + it('should not error if state is not {[key: string]: any}', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + router.resetConfig([ + {path: '', component: SimpleCmp}, + {path: 'simple', component: SimpleCmp}, + ]); - TestBed.configureTestingModule({ - providers: [ - { - provide: 'trueRightAway', - useValue: () => { - log.push('trueRightAway'); - return true; - } - }, - { - provide: 'trueIn2Seconds', - useValue: () => { - log.push('trueIn2Seconds-start'); - let res: ((value: boolean) => void); - const p = new Promise(r => res = r); - setTimeout(() => { - log.push('trueIn2Seconds-end'); - res(true); - }, 2000); - return p; - } - } - ] - }); - }); + const fixture = createRoot(router, RootCmp); + let navigation: Navigation = null!; + router.events.subscribe(e => { + if (e instanceof NavigationStart) { + navigation = router.getCurrentNavigation()!; + } + }); - describe('route activation', () => { - @Component({template: ''}) - class Parent { - constructor(route: ActivatedRoute) { - route.params.subscribe((s: Params) => { - log.push(s); - }); - } - } + location.replaceState('', '', 42); + router.navigateByUrl('/simple'); + tick(); + location.back(); + advance(fixture); - @Component({ - template: ` - - - - ` - }) - class NamedOutletHost { - logDeactivate(route: string) { - log.push(route + ' deactivate'); - } - } + // Angular does not support restoring state to the primitive. + expect(navigation.extras.state).toEqual(undefined); + expect(location.getState()).toEqual({navigationId: 3}); + }))); - @Component({template: 'child1'}) - class Child1 { - constructor() { - log.push('child1 constructor'); - } - ngOnDestroy() { - log.push('child1 destroy'); - } - } + it('should not pollute browser history when replaceUrl is set to true', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + router.resetConfig([ + {path: '', component: SimpleCmp}, {path: 'a', component: SimpleCmp}, + {path: 'b', component: SimpleCmp} + ]); - @Component({template: 'child2'}) - class Child2 { - constructor() { - log.push('child2 constructor'); - } - ngOnDestroy() { - log.push('child2 destroy'); - } - } + createRoot(router, RootCmp); - @Component({template: 'child3'}) - class Child3 { - constructor() { - log.push('child3 constructor'); - } - ngOnDestroy() { - log.push('child3 destroy'); - } - } + const replaceSpy = spyOn(location, 'replaceState'); + router.navigateByUrl('/a', {replaceUrl: true}); + router.navigateByUrl('/b', {replaceUrl: true}); + tick(); - @NgModule({ - declarations: [Parent, NamedOutletHost, Child1, Child2, Child3], - imports: [RouterModule.forRoot([])] - }) - class TestModule { - } + expect(replaceSpy.calls.count()).toEqual(1); + }))); - it('should advance the parent route after deactivating its children', fakeAsync(() => { - TestBed.configureTestingModule({imports: [TestModule]}); - const router = TestBed.inject(Router); - const location = TestBed.inject(Location); - const fixture = createRoot(router, RootCmp); + it('should skip navigation if another navigation is already scheduled', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + router.resetConfig([ + {path: '', component: SimpleCmp}, {path: 'a', component: SimpleCmp}, + {path: 'b', component: SimpleCmp} + ]); - router.resetConfig([{ - path: 'parent/:id', - component: Parent, - children: [ - {path: 'child1', component: Child1}, - {path: 'child2', component: Child2}, - ] - }]); + const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/parent/1/child1'); - advance(fixture); + router.navigate( + ['/a'], {queryParams: {a: true}, queryParamsHandling: 'merge', replaceUrl: true}); + router.navigate( + ['/b'], {queryParams: {b: true}, queryParamsHandling: 'merge', replaceUrl: true}); + tick(); - router.navigateByUrl('/parent/2/child2'); - advance(fixture); + /** + * Why do we have '/b?b=true' and not '/b?a=true&b=true'? + * + * This is because the router has the right to stop a navigation mid-flight if another + * navigation has been already scheduled. This is why we can use a top-level guard + * to perform redirects. Calling `navigate` in such a guard will stop the navigation, and + * the components won't be instantiated. + * + * This is a fundamental property of the router: it only cares about its latest state. + * + * This means that components should only map params to something else, not reduce them. + * In other words, the following component is asking for trouble: + * + * ``` + * class MyComponent { + * constructor(a: ActivatedRoute) { + * a.params.scan(...) + * } + * } + * ``` + * + * This also means "queryParamsHandling: 'merge'" should only be used to merge with + * long-living query parameters (e.g., debug). + */ + expect(router.url).toEqual('/b?b=true'); + }))); + }); - expect(location.path()).toEqual('/parent/2/child2'); - expect(log).toEqual([ - {id: '1'}, - 'child1 constructor', - 'child1 destroy', - {id: '2'}, - 'child2 constructor', - ]); - })); + describe('navigation warning', () => { + const isInAngularZoneFn = NgZone.isInAngularZone; + let warnings: string[] = []; + let isInAngularZone = true; - it('should deactivate outlet children with componentless parent', fakeAsync(() => { - TestBed.configureTestingModule({imports: [TestModule]}); - const router = TestBed.inject(Router); - const fixture = createRoot(router, RootCmp); + class MockConsole { + warn(message: string) { + warnings.push(message); + } + } - router.resetConfig([ - { - path: 'named-outlets', - component: NamedOutletHost, - children: [ - { - path: 'home', - children: [ - {path: '', component: Child1, outlet: 'first'}, - {path: '', component: Child2, outlet: 'second'}, - {path: 'primary', component: Child3}, - ] - }, - { - path: 'about', - children: [ - {path: '', component: Child1, outlet: 'first'}, - {path: '', component: Child2, outlet: 'second'}, - ] - }, + beforeEach(() => { + warnings = []; + isInAngularZone = true; + NgZone.isInAngularZone = () => isInAngularZone; + TestBed.overrideProvider(Console, {useValue: new MockConsole()}); + }); - ] - }, - { - path: 'other', - component: Parent, - }, - ]); + afterEach(() => { + NgZone.isInAngularZone = isInAngularZoneFn; + }); - router.navigateByUrl('/named-outlets/home/primary'); - advance(fixture); - expect(log).toEqual([ - 'child3 constructor', // primary outlet always first - 'child1 constructor', - 'child2 constructor', - ]); - log.length = 0; + describe('with NgZone enabled', () => { + it('should warn when triggered outside Angular zone', + fakeAsync(inject([Router], (router: Router) => { + isInAngularZone = false; + router.navigateByUrl('/simple'); - router.navigateByUrl('/named-outlets/about'); - advance(fixture); - expect(log).toEqual([ - 'child3 destroy', - 'primary deactivate', - 'child1 destroy', - 'first deactivate', - 'child2 destroy', - 'second deactivate', - 'child1 constructor', - 'child2 constructor', - ]); - log.length = 0; + expect(warnings.length).toBe(1); + expect(warnings[0]) + .toBe( + `Navigation triggered outside Angular zone, did you forget to call 'ngZone.run()'?`); + }))); - router.navigateByUrl('/other'); - advance(fixture); - expect(log).toEqual([ - 'child1 destroy', - 'first deactivate', - 'child2 destroy', - 'second deactivate', - // route param subscription from 'Parent' component - {}, - ]); - })); + it('should not warn when triggered inside Angular zone', + fakeAsync(inject([Router], (router: Router) => { + router.navigateByUrl('/simple'); - it('should work between aux outlets under two levels of empty path parents', fakeAsync(() => { - TestBed.configureTestingModule({imports: [TestModule]}); - const router = TestBed.inject(Router); - router.resetConfig([{ - path: '', - children: [ - { - path: '', - component: NamedOutletHost, - children: [ - {path: 'one', component: Child1, outlet: 'first'}, - {path: 'two', component: Child2, outlet: 'first'}, - ] - }, - ] - }]); + expect(warnings.length).toBe(0); + }))); + }); - const fixture = createRoot(router, RootCmp); + describe('with NgZone disabled', () => { + beforeEach(() => { + TestBed.overrideProvider(NgZone, {useValue: new NoopNgZone()}); + }); - router.navigateByUrl('/(first:one)'); - advance(fixture); - expect(log).toEqual(['child1 constructor']); + it('should not warn when triggered outside Angular zone', + fakeAsync(inject([Router], (router: Router) => { + isInAngularZone = false; + router.navigateByUrl('/simple'); - log.length = 0; - router.navigateByUrl('/(first:two)'); - advance(fixture); - expect(log).toEqual([ - 'child1 destroy', - 'first deactivate', - 'child2 constructor', - ]); - })); + expect(warnings.length).toBe(0); + }))); + }); }); - it('should not wait for prior navigations to start a new navigation', - fakeAsync(inject([Router, Location], (router: Router) => { - const fixture = createRoot(router, RootCmp); + describe('should execute navigations serially', () => { + let log: Array = []; - router.resetConfig([ - {path: 'a', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']}, - {path: 'b', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']} - ]); + beforeEach(() => { + log = []; - router.navigateByUrl('/a'); - tick(100); - fixture.detectChanges(); + TestBed.configureTestingModule({ + providers: [ + { + provide: 'trueRightAway', + useValue: () => { + log.push('trueRightAway'); + return true; + } + }, + { + provide: 'trueIn2Seconds', + useValue: () => { + log.push('trueIn2Seconds-start'); + let res: ((value: boolean) => void); + const p = new Promise(r => res = r); + setTimeout(() => { + log.push('trueIn2Seconds-end'); + res(true); + }, 2000); + return p; + } + } + ] + }); + }); - router.navigateByUrl('/b'); - tick(100); // 200 - fixture.detectChanges(); + describe('route activation', () => { + @Component({template: ''}) + class Parent { + constructor(route: ActivatedRoute) { + route.params.subscribe((s: Params) => { + log.push(s); + }); + } + } - expect(log).toEqual( - ['trueRightAway', 'trueIn2Seconds-start', 'trueRightAway', 'trueIn2Seconds-start']); + @Component({ + template: ` + + + + ` + }) + class NamedOutletHost { + logDeactivate(route: string) { + log.push(route + ' deactivate'); + } + } - tick(2000); // 2200 - fixture.detectChanges(); + @Component({template: 'child1'}) + class Child1 { + constructor() { + log.push('child1 constructor'); + } + ngOnDestroy() { + log.push('child1 destroy'); + } + } - expect(log).toEqual([ - 'trueRightAway', 'trueIn2Seconds-start', 'trueRightAway', 'trueIn2Seconds-start', - 'trueIn2Seconds-end', 'trueIn2Seconds-end' - ]); - }))); - }); + @Component({template: 'child2'}) + class Child2 { + constructor() { + log.push('child2 constructor'); + } + ngOnDestroy() { + log.push('child2 destroy'); + } + } - it('Should work inside ChangeDetectionStrategy.OnPush components', fakeAsync(() => { - @Component({ - selector: 'root-cmp', - template: ``, - changeDetection: ChangeDetectionStrategy.OnPush, - }) - class OnPushOutlet { - } - - @Component({selector: 'need-cd', template: `{{'it works!'}}`}) - class NeedCdCmp { - } - - @NgModule({ - declarations: [OnPushOutlet, NeedCdCmp], - imports: [RouterModule.forRoot([])], - }) - class TestModule { - } - - TestBed.configureTestingModule({imports: [TestModule]}); - - const router: Router = TestBed.inject(Router); - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{ - path: 'on', - component: OnPushOutlet, - children: [{ - path: 'push', - component: NeedCdCmp, - }], - }]); - - advance(fixture); - router.navigateByUrl('on'); - advance(fixture); - router.navigateByUrl('on/push'); - advance(fixture); - - expect(fixture.nativeElement).toHaveText('it works!'); - })); - - it('should not error when no url left and no children are matching', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([ - {path: 'team/:id', component: TeamCmp, children: [{path: 'simple', component: SimpleCmp}]} - ]); - - router.navigateByUrl('/team/33/simple'); - advance(fixture); - - expect(location.path()).toEqual('/team/33/simple'); - expect(fixture.nativeElement).toHaveText('team 33 [ simple, right: ]'); - - router.navigateByUrl('/team/33'); - advance(fixture); - - expect(location.path()).toEqual('/team/33'); - expect(fixture.nativeElement).toHaveText('team 33 [ , right: ]'); - }))); - - it('should work when an outlet is in an ngIf', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{ - path: 'child', - component: OutletInNgIf, - children: [{path: 'simple', component: SimpleCmp}] - }]); - - router.navigateByUrl('/child/simple'); - advance(fixture); - - expect(location.path()).toEqual('/child/simple'); - }))); - - it('should work when an outlet is added/removed', fakeAsync(() => { - @Component({ - selector: 'someRoot', - template: `[
]` - }) - class RootCmpWithLink { - cond: boolean = true; - } - TestBed.configureTestingModule({declarations: [RootCmpWithLink]}); - - const router: Router = TestBed.inject(Router); - - const fixture = createRoot(router, RootCmpWithLink); - - router.resetConfig([ - {path: 'simple', component: SimpleCmp}, - {path: 'blank', component: BlankCmp}, - ]); - - router.navigateByUrl('/simple'); - advance(fixture); - expect(fixture.nativeElement).toHaveText('[simple]'); - - fixture.componentInstance.cond = false; - advance(fixture); - expect(fixture.nativeElement).toHaveText('[]'); - - fixture.componentInstance.cond = true; - advance(fixture); - expect(fixture.nativeElement).toHaveText('[simple]'); - })); - - it('should update location when navigating', fakeAsync(() => { - @Component({template: `record`}) - class RecordLocationCmp { - private storedPath: string; - constructor(loc: Location) { - this.storedPath = loc.path(); - } - } + @Component({template: 'child3'}) + class Child3 { + constructor() { + log.push('child3 constructor'); + } + ngOnDestroy() { + log.push('child3 destroy'); + } + } - @NgModule({declarations: [RecordLocationCmp]}) - class TestModule { - } + @NgModule({ + declarations: [Parent, NamedOutletHost, Child1, Child2, Child3], + imports: [RouterModule.forRoot([])] + }) + class TestModule { + } - TestBed.configureTestingModule({imports: [TestModule]}); + it('should advance the parent route after deactivating its children', fakeAsync(() => { + TestBed.configureTestingModule({imports: [TestModule]}); + const router = TestBed.inject(Router); + const location = TestBed.inject(Location); + const fixture = createRoot(router, RootCmp); - const router = TestBed.inject(Router); - const location = TestBed.inject(Location); - const fixture = createRoot(router, RootCmp); + router.resetConfig([{ + path: 'parent/:id', + component: Parent, + children: [ + {path: 'child1', component: Child1}, + {path: 'child2', component: Child2}, + ] + }]); - router.resetConfig([{path: 'record/:id', component: RecordLocationCmp}]); + router.navigateByUrl('/parent/1/child1'); + advance(fixture); - router.navigateByUrl('/record/22'); - advance(fixture); + router.navigateByUrl('/parent/2/child2'); + advance(fixture); - const c = fixture.debugElement.children[1].componentInstance; - expect(location.path()).toEqual('/record/22'); - expect(c.storedPath).toEqual('/record/22'); + expect(location.path()).toEqual('/parent/2/child2'); + expect(log).toEqual([ + {id: '1'}, + 'child1 constructor', + 'child1 destroy', + {id: '2'}, + 'child2 constructor', + ]); + })); - router.navigateByUrl('/record/33'); - advance(fixture); - expect(location.path()).toEqual('/record/33'); - })); + it('should deactivate outlet children with componentless parent', fakeAsync(() => { + TestBed.configureTestingModule({imports: [TestModule]}); + const router = TestBed.inject(Router); + const fixture = createRoot(router, RootCmp); - it('should skip location update when using NavigationExtras.skipLocationChange with navigateByUrl', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = TestBed.createComponent(RootCmp); - advance(fixture); + router.resetConfig([ + { + path: 'named-outlets', + component: NamedOutletHost, + children: [ + { + path: 'home', + children: [ + {path: '', component: Child1, outlet: 'first'}, + {path: '', component: Child2, outlet: 'second'}, + {path: 'primary', component: Child3}, + ] + }, + { + path: 'about', + children: [ + {path: '', component: Child1, outlet: 'first'}, + {path: '', component: Child2, outlet: 'second'}, + ] + }, - router.resetConfig([{path: 'team/:id', component: TeamCmp}]); + ] + }, + { + path: 'other', + component: Parent, + }, + ]); - router.navigateByUrl('/team/22'); - advance(fixture); - expect(location.path()).toEqual('/team/22'); + router.navigateByUrl('/named-outlets/home/primary'); + advance(fixture); + expect(log).toEqual([ + 'child3 constructor', // primary outlet always first + 'child1 constructor', + 'child2 constructor', + ]); + log.length = 0; - expect(fixture.nativeElement).toHaveText('team 22 [ , right: ]'); + router.navigateByUrl('/named-outlets/about'); + advance(fixture); + expect(log).toEqual([ + 'child3 destroy', + 'primary deactivate', + 'child1 destroy', + 'first deactivate', + 'child2 destroy', + 'second deactivate', + 'child1 constructor', + 'child2 constructor', + ]); + log.length = 0; - router.navigateByUrl('/team/33', {skipLocationChange: true}); - advance(fixture); + router.navigateByUrl('/other'); + advance(fixture); + expect(log).toEqual([ + 'child1 destroy', + 'first deactivate', + 'child2 destroy', + 'second deactivate', + // route param subscription from 'Parent' component + {}, + ]); + })); - expect(location.path()).toEqual('/team/22'); + it('should work between aux outlets under two levels of empty path parents', + fakeAsync(() => { + TestBed.configureTestingModule({imports: [TestModule]}); + const router = TestBed.inject(Router); + router.resetConfig([{ + path: '', + children: [ + { + path: '', + component: NamedOutletHost, + children: [ + {path: 'one', component: Child1, outlet: 'first'}, + {path: 'two', component: Child2, outlet: 'first'}, + ] + }, + ] + }]); - expect(fixture.nativeElement).toHaveText('team 33 [ , right: ]'); - }))); + const fixture = createRoot(router, RootCmp); - it('should skip location update when using NavigationExtras.skipLocationChange with navigate', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = TestBed.createComponent(RootCmp); - advance(fixture); + router.navigateByUrl('/(first:one)'); + advance(fixture); + expect(log).toEqual(['child1 constructor']); - router.resetConfig([{path: 'team/:id', component: TeamCmp}]); + log.length = 0; + router.navigateByUrl('/(first:two)'); + advance(fixture); + expect(log).toEqual([ + 'child1 destroy', + 'first deactivate', + 'child2 constructor', + ]); + })); + }); - router.navigate(['/team/22']); - advance(fixture); - expect(location.path()).toEqual('/team/22'); + it('should not wait for prior navigations to start a new navigation', + fakeAsync(inject([Router, Location], (router: Router) => { + const fixture = createRoot(router, RootCmp); - expect(fixture.nativeElement).toHaveText('team 22 [ , right: ]'); + router.resetConfig([ + {path: 'a', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']}, + {path: 'b', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']} + ]); - router.navigate(['/team/33'], {skipLocationChange: true}); - advance(fixture); + router.navigateByUrl('/a'); + tick(100); + fixture.detectChanges(); - expect(location.path()).toEqual('/team/22'); + router.navigateByUrl('/b'); + tick(100); // 200 + fixture.detectChanges(); - expect(fixture.nativeElement).toHaveText('team 33 [ , right: ]'); - }))); + expect(log).toEqual( + ['trueRightAway', 'trueIn2Seconds-start', 'trueRightAway', 'trueIn2Seconds-start']); - it('should navigate after navigation with skipLocationChange', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = TestBed.createComponent(RootCmpWithNamedOutlet); - advance(fixture); + tick(2000); // 2200 + fixture.detectChanges(); - router.resetConfig([{path: 'show', outlet: 'main', component: SimpleCmp}]); + expect(log).toEqual([ + 'trueRightAway', 'trueIn2Seconds-start', 'trueRightAway', 'trueIn2Seconds-start', + 'trueIn2Seconds-end', 'trueIn2Seconds-end' + ]); + }))); + }); - router.navigate([{outlets: {main: 'show'}}], {skipLocationChange: true}); - advance(fixture); - expect(location.path()).toEqual(''); + it('Should work inside ChangeDetectionStrategy.OnPush components', fakeAsync(() => { + @Component({ + selector: 'root-cmp', + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class OnPushOutlet { + } - expect(fixture.nativeElement).toHaveText('main [simple]'); + @Component({selector: 'need-cd', template: `{{'it works!'}}`}) + class NeedCdCmp { + } - router.navigate([{outlets: {main: null}}], {skipLocationChange: true}); - advance(fixture); + @NgModule({ + declarations: [OnPushOutlet, NeedCdCmp], + imports: [RouterModule.forRoot([])], + }) + class TestModule { + } - expect(location.path()).toEqual(''); + TestBed.configureTestingModule({imports: [TestModule]}); - expect(fixture.nativeElement).toHaveText('main []'); - }))); + const router: Router = TestBed.inject(Router); + const fixture = createRoot(router, RootCmp); - describe('"eager" urlUpdateStrategy', () => { - @Injectable() - class AuthGuard { - canActivateResult = true; + router.resetConfig([{ + path: 'on', + component: OnPushOutlet, + children: [{ + path: 'push', + component: NeedCdCmp, + }], + }]); - canActivate() { - return this.canActivateResult; - } - } - @Injectable() - class DelayedGuard { - canActivate() { - return of('').pipe(delay(1000), mapTo(true)); - } - } + advance(fixture); + router.navigateByUrl('on'); + advance(fixture); + router.navigateByUrl('on/push'); + advance(fixture); - beforeEach(() => { - const serializer = new DefaultUrlSerializer(); - TestBed.configureTestingModule({ - providers: [ - { - provide: 'authGuardFail', - useValue: (a: any, b: any) => { - return new Promise(res => { - setTimeout(() => res(serializer.parse('/login')), 1); - }); - } - }, - AuthGuard, - DelayedGuard, - ] - }); - }); - - - describe('urlUpdateStrategy: eager', () => { - beforeEach(() => { - TestBed.configureTestingModule( - {providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))]}); - }); - it('should eagerly update the URL', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = TestBed.createComponent(RootCmp); - advance(fixture); - - router.resetConfig([{path: 'team/:id', component: TeamCmp}]); - - router.navigateByUrl('/team/22'); - advance(fixture); - expect(location.path()).toEqual('/team/22'); - - expect(fixture.nativeElement).toHaveText('team 22 [ , right: ]'); - - router.events.subscribe(e => { - if (!(e instanceof GuardsCheckStart)) { - return; - } - expect(location.path()).toEqual('/team/33'); - expect(fixture.nativeElement).toHaveText('team 22 [ , right: ]'); - return of(null); - }); - router.navigateByUrl('/team/33'); - - advance(fixture); - expect(fixture.nativeElement).toHaveText('team 33 [ , right: ]'); - }))); - - it('should eagerly update the URL', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = TestBed.createComponent(RootCmp); - advance(fixture); - - router.resetConfig([ - {path: 'team/:id', component: SimpleCmp, canActivate: ['authGuardFail']}, - {path: 'login', component: AbsoluteSimpleLinkCmp} - ]); - - router.navigateByUrl('/team/22'); - advance(fixture); - expect(location.path()).toEqual('/team/22'); - - // Redirects to /login - advance(fixture, 1); - expect(location.path()).toEqual('/login'); - - // Perform the same logic again, and it should produce the same result - router.navigateByUrl('/team/22'); - advance(fixture); - expect(location.path()).toEqual('/team/22'); - - // Redirects to /login - advance(fixture, 1); - expect(location.path()).toEqual('/login'); - }))); - - it('should eagerly update URL after redirects are applied', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = TestBed.createComponent(RootCmp); - advance(fixture); - - router.resetConfig([{path: 'team/:id', component: TeamCmp}]); - - router.navigateByUrl('/team/22'); - advance(fixture); - expect(location.path()).toEqual('/team/22'); - - expect(fixture.nativeElement).toHaveText('team 22 [ , right: ]'); - - let urlAtNavStart = ''; - let urlAtRoutesRecognized = ''; - router.events.subscribe(e => { - if (e instanceof NavigationStart) { - urlAtNavStart = location.path(); - } - if (e instanceof RoutesRecognized) { - urlAtRoutesRecognized = location.path(); - } - }); - - router.navigateByUrl('/team/33'); - - advance(fixture); - expect(urlAtNavStart).toBe('/team/22'); - expect(urlAtRoutesRecognized).toBe('/team/33'); - expect(fixture.nativeElement).toHaveText('team 33 [ , right: ]'); - }))); + expect(fixture.nativeElement).toHaveText('it works!'); + })); - it('should set `state`', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - router.resetConfig([ - {path: '', component: SimpleCmp}, - {path: 'simple', component: SimpleCmp}, - ]); + it('should not error when no url left and no children are matching', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - const fixture = createRoot(router, RootCmp); - let navigation: Navigation = null!; - router.events.subscribe(e => { - if (e instanceof NavigationStart) { - navigation = router.getCurrentNavigation()!; - } - }); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [{path: 'simple', component: SimpleCmp}] + }]); - router.navigateByUrl('/simple', {state: {foo: 'bar'}}); - tick(); + router.navigateByUrl('/team/33/simple'); + advance(fixture); - const state = location.getState() as any; - expect(state).toEqual({foo: 'bar', navigationId: 2}); - expect(navigation.extras.state).toBeDefined(); - expect(navigation.extras.state).toEqual({foo: 'bar'}); - }))); + expect(location.path()).toEqual('/team/33/simple'); + expect(fixture.nativeElement).toHaveText('team 33 [ simple, right: ]'); - it('can renavigate to rejected URL', fakeAsync(() => { - const router = TestBed.inject(Router); - const canActivate = TestBed.inject(AuthGuard); - const location = TestBed.inject(Location); - router.resetConfig([ - {path: '', component: BlankCmp}, - { - path: 'simple', - component: SimpleCmp, - canActivate: [() => coreInject(AuthGuard).canActivate()] - }, - ]); - const fixture = createRoot(router, RootCmp); + router.navigateByUrl('/team/33'); + advance(fixture); - // Try to navigate to /simple but guard rejects - canActivate.canActivateResult = false; - router.navigateByUrl('/simple'); - advance(fixture); - expect(location.path()).toEqual(''); - expect(fixture.nativeElement.innerHTML).not.toContain('simple'); + expect(location.path()).toEqual('/team/33'); + expect(fixture.nativeElement).toHaveText('team 33 [ , right: ]'); + }))); - // Renavigate to /simple without guard rejection, should succeed. - canActivate.canActivateResult = true; - router.navigateByUrl('/simple'); - advance(fixture); - expect(location.path()).toEqual('/simple'); - expect(fixture.nativeElement.innerHTML).toContain('simple'); - })); + it('should work when an outlet is in an ngIf', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - it('can renavigate to same URL during in-flight navigation', fakeAsync(() => { - const router = TestBed.inject(Router); - const location = TestBed.inject(Location); - router.resetConfig([ - {path: '', component: BlankCmp}, - { - path: 'simple', - component: SimpleCmp, - canActivate: [() => coreInject(DelayedGuard).canActivate()] - }, - ]); - const fixture = createRoot(router, RootCmp); + router.resetConfig([{ + path: 'child', + component: OutletInNgIf, + children: [{path: 'simple', component: SimpleCmp}] + }]); - // Start navigating to /simple, but do not flush the guard delay - router.navigateByUrl('/simple'); - tick(); - // eager update strategy so URL is already updated. - expect(location.path()).toEqual('/simple'); - expect(fixture.nativeElement.innerHTML).not.toContain('simple'); + router.navigateByUrl('/child/simple'); + advance(fixture); - // Start an additional navigation to /simple and ensure at least one of those succeeds. - // It's not super important which one gets processed, but in the past, the router would - // cancel the in-flight one and not process the new one. - router.navigateByUrl('/simple'); - tick(1000); - expect(location.path()).toEqual('/simple'); - expect(fixture.nativeElement.innerHTML).toContain('simple'); - })); - }); - }); + expect(location.path()).toEqual('/child/simple'); + }))); - it('should navigate back and forward', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: - [{path: 'simple', component: SimpleCmp}, {path: 'user/:name', component: UserCmp}] - }]); - - let event: NavigationStart; - router.events.subscribe(e => { - if (e instanceof NavigationStart) { - event = e; + it('should work when an outlet is added/removed', fakeAsync(() => { + @Component({ + selector: 'someRoot', + template: `[
]` + }) + class RootCmpWithLink { + cond: boolean = true; } - }); - - router.navigateByUrl('/team/33/simple'); - advance(fixture); - expect(location.path()).toEqual('/team/33/simple'); - const simpleNavStart = event!; - - router.navigateByUrl('/team/22/user/victor'); - advance(fixture); - const userVictorNavStart = event!; - - - location.back(); - advance(fixture); - expect(location.path()).toEqual('/team/33/simple'); - expect(event!.navigationTrigger).toEqual('popstate'); - expect(event!.restoredState!.navigationId).toEqual(simpleNavStart.id); - - location.forward(); - advance(fixture); - expect(location.path()).toEqual('/team/22/user/victor'); - expect(event!.navigationTrigger).toEqual('popstate'); - expect(event!.restoredState!.navigationId).toEqual(userVictorNavStart.id); - }))); - - it('should navigate to the same url when config changes', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{path: 'a', component: SimpleCmp}]); - - router.navigate(['/a']); - advance(fixture); - expect(location.path()).toEqual('/a'); - expect(fixture.nativeElement).toHaveText('simple'); - - router.resetConfig([{path: 'a', component: RouteCmp}]); - - router.navigate(['/a']); - advance(fixture); - expect(location.path()).toEqual('/a'); - expect(fixture.nativeElement).toHaveText('route'); - }))); - - it('should navigate when locations changes', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: [{path: 'user/:name', component: UserCmp}] - }]); - - const recordedEvents: (NavigationStart|NavigationEnd)[] = []; - router.events.forEach(e => onlyNavigationStartAndEnd(e) && recordedEvents.push(e)); - - router.navigateByUrl('/team/22/user/victor'); - advance(fixture); - - location.go('/team/22/user/fedor'); - location.historyGo(0); - advance(fixture); - - location.go('/team/22/user/fedor'); - location.historyGo(0); - advance(fixture); - - expect(fixture.nativeElement).toHaveText('team 22 [ user fedor, right: ]'); - - expectEvents(recordedEvents, [ - [NavigationStart, '/team/22/user/victor'], [NavigationEnd, '/team/22/user/victor'], - [NavigationStart, '/team/22/user/fedor'], [NavigationEnd, '/team/22/user/fedor'] - ]); - }))); - - it('should update the location when the matched route does not change', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{path: '**', component: CollectParamsCmp}]); - - router.navigateByUrl('/one/two'); - advance(fixture); - const cmp = fixture.debugElement.children[1].componentInstance; - expect(location.path()).toEqual('/one/two'); - expect(fixture.nativeElement).toHaveText('collect-params'); - - expect(cmp.recordedUrls()).toEqual(['one/two']); - - router.navigateByUrl('/three/four'); - advance(fixture); - expect(location.path()).toEqual('/three/four'); - expect(fixture.nativeElement).toHaveText('collect-params'); - expect(cmp.recordedUrls()).toEqual(['one/two', 'three/four']); - }))); - - describe('duplicate in-flight navigations', () => { - @Injectable() - class RedirectingGuard { - skipLocationChange = false; - constructor(private router: Router) {} - canActivate() { - this.router.navigate(['/simple'], {skipLocationChange: this.skipLocationChange}); - return false; - } - } - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - { - provide: 'in1Second', - useValue: (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { - let res: any = null; - const p = new Promise(_ => res = _); - setTimeout(() => res(true), 1000); - return p; - } - }, - RedirectingGuard - ] - }); - }); - - it('should reset location if a navigation by location is successful', fakeAsync(() => { - const router = TestBed.inject(Router); - const location = TestBed.inject(Location); - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{path: 'simple', component: SimpleCmp, canActivate: ['in1Second']}]); - - // Trigger two location changes to the same URL. - // Because of the guard the order will look as follows: - // - location change 'simple' - // - start processing the change, start a guard - // - location change 'simple' - // - the first location change gets canceled, the URL gets reset to '/' - // - the second location change gets finished, the URL should be reset to '/simple' - location.go('/simple'); - location.historyGo(0); - location.historyGo(0); - - tick(2000); - advance(fixture); + TestBed.configureTestingModule({declarations: [RootCmpWithLink]}); - expect(location.path()).toEqual('/simple'); - })); + const router: Router = TestBed.inject(Router); - it('should skip duplicate location events', fakeAsync(() => { - const router = TestBed.inject(Router); - const location = TestBed.inject(Location); - const fixture = createRoot(router, RootCmp); + const fixture = createRoot(router, RootCmpWithLink); router.resetConfig([ - { - path: 'blocked', - component: BlankCmp, - canActivate: [() => coreInject(RedirectingGuard).canActivate()] - }, - {path: 'simple', component: SimpleCmp} + {path: 'simple', component: SimpleCmp}, + {path: 'blank', component: BlankCmp}, ]); + router.navigateByUrl('/simple'); advance(fixture); + expect(fixture.nativeElement).toHaveText('[simple]'); - location.go('/blocked'); - location.historyGo(0); + fixture.componentInstance.cond = false; + advance(fixture); + expect(fixture.nativeElement).toHaveText('[]'); + fixture.componentInstance.cond = true; advance(fixture); - expect(fixture.nativeElement.innerHTML).toContain('simple'); + expect(fixture.nativeElement).toHaveText('[simple]'); })); - it('should not cause URL thrashing', async () => { - TestBed.configureTestingModule( - {providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))]}); - - const router = TestBed.inject(Router); - const location = TestBed.inject(Location); - const fixture = TestBed.createComponent(RootCmp); - fixture.detectChanges(); - - router.resetConfig([ - {path: 'home', component: SimpleCmp}, { - path: 'blocked', - component: BlankCmp, - canActivate: [() => coreInject(RedirectingGuard).canActivate()] - }, - {path: 'simple', component: SimpleCmp} - ]); - - await router.navigateByUrl('/home'); - const urlChanges: string[] = []; - location.onUrlChange(change => { - urlChanges.push(change); - }); + it('should update location when navigating', fakeAsync(() => { + @Component({template: `record`}) + class RecordLocationCmp { + private storedPath: string; + constructor(loc: Location) { + this.storedPath = loc.path(); + } + } - await router.navigateByUrl('/blocked'); - await fixture.whenStable(); + @NgModule({declarations: [RecordLocationCmp]}) + class TestModule { + } - expect(fixture.nativeElement.innerHTML).toContain('simple'); - // We do not want the URL to flicker to `/home` between the /blocked and /simple routes - expect(urlChanges).toEqual(['/blocked', '/simple']); - }); + TestBed.configureTestingModule({imports: [TestModule]}); - it('can render a 404 page without changing the URL', fakeAsync(() => { - TestBed.configureTestingModule( - {providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))]}); const router = TestBed.inject(Router); - TestBed.inject(RedirectingGuard).skipLocationChange = true; const location = TestBed.inject(Location); const fixture = createRoot(router, RootCmp); - router.resetConfig([ - {path: 'home', component: SimpleCmp}, - { - path: 'blocked', - component: BlankCmp, - canActivate: [() => coreInject(RedirectingGuard).canActivate()] - }, - {path: 'simple', redirectTo: '404'}, - {path: '404', component: SimpleCmp}, - ]); - router.navigateByUrl('/home'); - advance(fixture); + router.resetConfig([{path: 'record/:id', component: RecordLocationCmp}]); - location.go('/blocked'); - location.historyGo(0); + router.navigateByUrl('/record/22'); advance(fixture); - expect(location.path()).toEqual('/blocked'); - expect(fixture.nativeElement.innerHTML).toContain('simple'); - })); - - it('should accurately track currentNavigation', fakeAsync(() => { - const router = TestBed.inject(Router); - router.resetConfig([ - {path: 'one', component: SimpleCmp, canActivate: ['in1Second']}, - {path: 'two', component: BlankCmp, canActivate: ['in1Second']}, - ]); - router.events.subscribe((e) => { - if (e instanceof NavigationStart) { - if (e.url === '/one') { - router.navigateByUrl('two'); - } - router.events.subscribe((e) => { - if (e instanceof GuardsCheckEnd) { - expect(router.getCurrentNavigation()?.extractedUrl.toString()).toEqual('/two'); - expect(router.getCurrentNavigation()?.extras).toBeDefined(); - } - }); - } - }); + const c = fixture.debugElement.children[1].componentInstance; + expect(location.path()).toEqual('/record/22'); + expect(c.storedPath).toEqual('/record/22'); - router.navigateByUrl('one'); - tick(1000); + router.navigateByUrl('/record/33'); + advance(fixture); + expect(location.path()).toEqual('/record/33'); })); - }); - - it('should support secondary routes', fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: [ - {path: 'user/:name', component: UserCmp}, - {path: 'simple', component: SimpleCmp, outlet: 'right'} - ] - }]); - - router.navigateByUrl('/team/22/(user/victor//right:simple)'); - advance(fixture); - - expect(fixture.nativeElement).toHaveText('team 22 [ user victor, right: simple ]'); - }))); - - it('should support secondary routes in separate commands', - fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: [ - {path: 'user/:name', component: UserCmp}, - {path: 'simple', component: SimpleCmp, outlet: 'right'} - ] - }]); - - router.navigateByUrl('/team/22/user/victor'); - advance(fixture); - router.navigate(['team/22', {outlets: {right: 'simple'}}]); - advance(fixture); - - expect(fixture.nativeElement).toHaveText('team 22 [ user victor, right: simple ]'); - }))); - - it('should support secondary routes as child of empty path parent', - fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{ - path: '', - component: TeamCmp, - children: [{path: 'simple', component: SimpleCmp, outlet: 'right'}] - }]); - - router.navigateByUrl('/(right:simple)'); - advance(fixture); - - expect(fixture.nativeElement).toHaveText('team [ , right: simple ]'); - }))); - - it('should deactivate outlets', fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: [ - {path: 'user/:name', component: UserCmp}, - {path: 'simple', component: SimpleCmp, outlet: 'right'} - ] - }]); - - router.navigateByUrl('/team/22/(user/victor//right:simple)'); - advance(fixture); - - router.navigateByUrl('/team/22/user/victor'); - advance(fixture); - - expect(fixture.nativeElement).toHaveText('team 22 [ user victor, right: ]'); - }))); - - it('should deactivate nested outlets', fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([ - { - path: 'team/:id', - component: TeamCmp, - children: [ - {path: 'user/:name', component: UserCmp}, - {path: 'simple', component: SimpleCmp, outlet: 'right'} - ] - }, - {path: '', component: BlankCmp} - ]); - router.navigateByUrl('/team/22/(user/victor//right:simple)'); - advance(fixture); + it('should skip location update when using NavigationExtras.skipLocationChange with navigateByUrl', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = TestBed.createComponent(RootCmp); + advance(fixture); - router.navigateByUrl('/'); - advance(fixture); + router.resetConfig([{path: 'team/:id', component: TeamCmp}]); - expect(fixture.nativeElement).toHaveText(''); - }))); + router.navigateByUrl('/team/22'); + advance(fixture); + expect(location.path()).toEqual('/team/22'); - it('should set query params and fragment', fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); + expect(fixture.nativeElement).toHaveText('team 22 [ , right: ]'); - router.resetConfig([{path: 'query', component: QueryParamsAndFragmentCmp}]); + router.navigateByUrl('/team/33', {skipLocationChange: true}); + advance(fixture); - router.navigateByUrl('/query?name=1#fragment1'); - advance(fixture); - expect(fixture.nativeElement).toHaveText('query: 1 fragment: fragment1'); + expect(location.path()).toEqual('/team/22'); - router.navigateByUrl('/query?name=2#fragment2'); - advance(fixture); - expect(fixture.nativeElement).toHaveText('query: 2 fragment: fragment2'); - }))); + expect(fixture.nativeElement).toHaveText('team 33 [ , right: ]'); + }))); - it('should handle empty or missing fragments', fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); + it('should skip location update when using NavigationExtras.skipLocationChange with navigate', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = TestBed.createComponent(RootCmp); + advance(fixture); - router.resetConfig([{path: 'query', component: QueryParamsAndFragmentCmp}]); + router.resetConfig([{path: 'team/:id', component: TeamCmp}]); - router.navigateByUrl('/query#'); - advance(fixture); - expect(fixture.nativeElement).toHaveText('query: fragment: '); + router.navigate(['/team/22']); + advance(fixture); + expect(location.path()).toEqual('/team/22'); - router.navigateByUrl('/query'); - advance(fixture); - expect(fixture.nativeElement).toHaveText('query: fragment: null'); - }))); + expect(fixture.nativeElement).toHaveText('team 22 [ , right: ]'); - it('should ignore null and undefined query params', - fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); + router.navigate(['/team/33'], {skipLocationChange: true}); + advance(fixture); - router.resetConfig([{path: 'query', component: EmptyQueryParamsCmp}]); + expect(location.path()).toEqual('/team/22'); - router.navigate(['query'], {queryParams: {name: 1, age: null, page: undefined}}); - advance(fixture); - const cmp = fixture.debugElement.children[1].componentInstance; - expect(cmp.recordedParams).toEqual([{name: '1'}]); - }))); + expect(fixture.nativeElement).toHaveText('team 33 [ , right: ]'); + }))); - it('should throw an error when one of the commands is null/undefined', - fakeAsync(inject([Router], (router: Router) => { - createRoot(router, RootCmp); + it('should navigate after navigation with skipLocationChange', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = TestBed.createComponent(RootCmpWithNamedOutlet); + advance(fixture); - router.resetConfig([{path: 'query', component: EmptyQueryParamsCmp}]); + router.resetConfig([{path: 'show', outlet: 'main', component: SimpleCmp}]); - expect(() => router.navigate([ - undefined, 'query' - ])).toThrowError(/The requested path contains undefined segment at index 0/); - }))); - - it('should push params only when they change', fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); + router.navigate([{outlets: {main: 'show'}}], {skipLocationChange: true}); + advance(fixture); + expect(location.path()).toEqual(''); - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: [{path: 'user/:name', component: UserCmp}] - }]); + expect(fixture.nativeElement).toHaveText('main [simple]'); - router.navigateByUrl('/team/22/user/victor'); - advance(fixture); - const team = fixture.debugElement.children[1].componentInstance; - const user = fixture.debugElement.children[1].children[1].componentInstance; + router.navigate([{outlets: {main: null}}], {skipLocationChange: true}); + advance(fixture); - expect(team.recordedParams).toEqual([{id: '22'}]); - expect(team.snapshotParams).toEqual([{id: '22'}]); - expect(user.recordedParams).toEqual([{name: 'victor'}]); - expect(user.snapshotParams).toEqual([{name: 'victor'}]); + expect(location.path()).toEqual(''); - router.navigateByUrl('/team/22/user/fedor'); - advance(fixture); + expect(fixture.nativeElement).toHaveText('main []'); + }))); - expect(team.recordedParams).toEqual([{id: '22'}]); - expect(team.snapshotParams).toEqual([{id: '22'}]); - expect(user.recordedParams).toEqual([{name: 'victor'}, {name: 'fedor'}]); - expect(user.snapshotParams).toEqual([{name: 'victor'}, {name: 'fedor'}]); - }))); + describe('"eager" urlUpdateStrategy', () => { + @Injectable() + class AuthGuard { + canActivateResult = true; - it('should work when navigating to /', fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); + canActivate() { + return this.canActivateResult; + } + } + @Injectable() + class DelayedGuard { + canActivate() { + return of('').pipe(delay(1000), mapTo(true)); + } + } - router.resetConfig([ - {path: '', pathMatch: 'full', component: SimpleCmp}, - {path: 'user/:name', component: UserCmp} - ]); + beforeEach(() => { + const serializer = new DefaultUrlSerializer(); + TestBed.configureTestingModule({ + providers: [ + { + provide: 'authGuardFail', + useValue: (a: any, b: any) => { + return new Promise(res => { + setTimeout(() => res(serializer.parse('/login')), 1); + }); + } + }, + AuthGuard, + DelayedGuard, + ] + }); + }); - router.navigateByUrl('/user/victor'); - advance(fixture); - expect(fixture.nativeElement).toHaveText('user victor'); + describe('urlUpdateStrategy: eager', () => { + beforeEach(() => { + TestBed.configureTestingModule( + {providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))]}); + }); + it('should eagerly update the URL', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = TestBed.createComponent(RootCmp); + advance(fixture); - router.navigateByUrl('/'); - advance(fixture); + router.resetConfig([{path: 'team/:id', component: TeamCmp}]); - expect(fixture.nativeElement).toHaveText('simple'); - }))); + router.navigateByUrl('/team/22'); + advance(fixture); + expect(location.path()).toEqual('/team/22'); - it('should cancel in-flight navigations', fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{path: 'user/:name', component: UserCmp}]); + expect(fixture.nativeElement).toHaveText('team 22 [ , right: ]'); - const recordedEvents: Event[] = []; - router.events.forEach(e => recordedEvents.push(e)); - - router.navigateByUrl('/user/init'); - advance(fixture); - - const user = fixture.debugElement.children[1].componentInstance; - - let r1: any, r2: any; - router.navigateByUrl('/user/victor').then(_ => r1 = _); - router.navigateByUrl('/user/fedor').then(_ => r2 = _); - advance(fixture); - - expect(r1).toEqual(false); // returns false because it was canceled - expect(r2).toEqual(true); // returns true because it was successful - - expect(fixture.nativeElement).toHaveText('user fedor'); - expect(user.recordedParams).toEqual([{name: 'init'}, {name: 'fedor'}]); - - expectEvents(recordedEvents, [ - [NavigationStart, '/user/init'], - [RoutesRecognized, '/user/init'], - [GuardsCheckStart, '/user/init'], - [ChildActivationStart], - [ActivationStart], - [GuardsCheckEnd, '/user/init'], - [ResolveStart, '/user/init'], - [ResolveEnd, '/user/init'], - [ActivationEnd], - [ChildActivationEnd], - [NavigationEnd, '/user/init'], - - [NavigationStart, '/user/victor'], - [NavigationCancel, '/user/victor'], - - [NavigationStart, '/user/fedor'], - [RoutesRecognized, '/user/fedor'], - [GuardsCheckStart, '/user/fedor'], - [ChildActivationStart], - [ActivationStart], - [GuardsCheckEnd, '/user/fedor'], - [ResolveStart, '/user/fedor'], - [ResolveEnd, '/user/fedor'], - [ActivationEnd], - [ChildActivationEnd], - [NavigationEnd, '/user/fedor'] - ]); - }))); - - it('should properly set currentNavigation when cancelling in-flight navigations', - fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{path: 'user/:name', component: UserCmp}]); - - router.navigateByUrl('/user/init'); - advance(fixture); - - router.navigateByUrl('/user/victor'); - expect(router.getCurrentNavigation()).not.toBe(null); - router.navigateByUrl('/user/fedor'); - // Due to https://github.com/angular/angular/issues/29389, this would be `false` - // when running a second navigation. - expect(router.getCurrentNavigation()).not.toBe(null); - advance(fixture); - - expect(router.getCurrentNavigation()).toBe(null); - expect(fixture.nativeElement).toHaveText('user fedor'); - }))); - - it('should handle failed navigations gracefully', fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{path: 'user/:name', component: UserCmp}]); - - const recordedEvents: Event[] = []; - router.events.forEach(e => recordedEvents.push(e)); - - let e: any; - router.navigateByUrl('/invalid').catch(_ => e = _); - advance(fixture); - expect(e.message).toContain('Cannot match any routes'); - - router.navigateByUrl('/user/fedor'); - advance(fixture); - - expect(fixture.nativeElement).toHaveText('user fedor'); - - expectEvents(recordedEvents, [ - [NavigationStart, '/invalid'], [NavigationError, '/invalid'], - - [NavigationStart, '/user/fedor'], [RoutesRecognized, '/user/fedor'], - [GuardsCheckStart, '/user/fedor'], [ChildActivationStart], [ActivationStart], - [GuardsCheckEnd, '/user/fedor'], [ResolveStart, '/user/fedor'], - [ResolveEnd, '/user/fedor'], [ActivationEnd], [ChildActivationEnd], - [NavigationEnd, '/user/fedor'] - ]); - }))); - - it('should be able to provide an error handler with DI dependencies', async () => { - @Injectable({providedIn: 'root'}) - class Handler { - handlerCalled = false; - } - TestBed.configureTestingModule({ - providers: [ - provideRouter( - [{ - path: 'throw', - canMatch: [() => { - throw new Error(''); - }], - component: BlankCmp - }], - withNavigationErrorHandler(() => coreInject(Handler).handlerCalled = true)), - ] - }); - const router = TestBed.inject(Router); - await expectAsync(router.navigateByUrl('/throw')).toBeRejected(); - expect(TestBed.inject(Handler).handlerCalled).toBeTrue(); - }); + router.events.subscribe(e => { + if (!(e instanceof GuardsCheckStart)) { + return; + } + expect(location.path()).toEqual('/team/33'); + expect(fixture.nativeElement).toHaveText('team 22 [ , right: ]'); + return of(null); + }); + router.navigateByUrl('/team/33'); - // Errors should behave the same for both deferred and eager URL update strategies - (['deferred', 'eager'] as const).forEach(urlUpdateStrategy => { - it('should dispatch NavigationError after the url has been reset back', fakeAsync(() => { - TestBed.configureTestingModule( - {providers: [provideRouter([], withRouterConfig({urlUpdateStrategy}))]}); - const router: Router = TestBed.inject(Router); - const location = TestBed.inject(Location); - const fixture = createRoot(router, RootCmp); + advance(fixture); + expect(fixture.nativeElement).toHaveText('team 33 [ , right: ]'); + }))); - router.resetConfig( - [{path: 'simple', component: SimpleCmp}, {path: 'throwing', component: ThrowingCmp}]); + it('should eagerly update the URL', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = TestBed.createComponent(RootCmp); + advance(fixture); - router.navigateByUrl('/simple'); - advance(fixture); + router.resetConfig([ + {path: 'team/:id', component: SimpleCmp, canActivate: ['authGuardFail']}, + {path: 'login', component: AbsoluteSimpleLinkCmp} + ]); - let routerUrlBeforeEmittingError = ''; - let locationUrlBeforeEmittingError = ''; - router.events.forEach(e => { - if (e instanceof NavigationError) { - routerUrlBeforeEmittingError = router.url; - locationUrlBeforeEmittingError = location.path(); - } - }); - router.navigateByUrl('/throwing').catch(() => null); - advance(fixture); + router.navigateByUrl('/team/22'); + advance(fixture); + expect(location.path()).toEqual('/team/22'); - expect(routerUrlBeforeEmittingError).toEqual('/simple'); - expect(locationUrlBeforeEmittingError).toEqual('/simple'); - })); + // Redirects to /login + advance(fixture, 1); + expect(location.path()).toEqual('/login'); - it('can renavigate to throwing component', fakeAsync(() => { - TestBed.configureTestingModule( - {providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))]}); - const router = TestBed.inject(Router); - const location = TestBed.inject(Location); - router.resetConfig([ - {path: '', component: BlankCmp}, - {path: 'throwing', component: ConditionalThrowingCmp}, - ]); - const fixture = createRoot(router, RootCmp); + // Perform the same logic again, and it should produce the same result + router.navigateByUrl('/team/22'); + advance(fixture); + expect(location.path()).toEqual('/team/22'); - // Try navigating to a component which throws an error during activation. - ConditionalThrowingCmp.throwError = true; - expect(() => { - router.navigateByUrl('/throwing'); - advance(fixture); - }).toThrow(); - expect(location.path()).toEqual(''); - expect(fixture.nativeElement.innerHTML).not.toContain('throwing'); + // Redirects to /login + advance(fixture, 1); + expect(location.path()).toEqual('/login'); + }))); - // Ensure we can re-navigate to that same URL and succeed. - ConditionalThrowingCmp.throwError = false; - router.navigateByUrl('/throwing'); - advance(fixture); - expect(location.path()).toEqual('/throwing'); - expect(fixture.nativeElement.innerHTML).toContain('throwing'); - })); + it('should eagerly update URL after redirects are applied', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = TestBed.createComponent(RootCmp); + advance(fixture); - it('should reset the url with the right state when navigation errors', fakeAsync(() => { - TestBed.configureTestingModule( - {providers: [provideRouter([], withRouterConfig({urlUpdateStrategy}))]}); - const router: Router = TestBed.inject(Router); - const location = TestBed.inject(Location); - const fixture = createRoot(router, RootCmp); + router.resetConfig([{path: 'team/:id', component: TeamCmp}]); - router.resetConfig([ - {path: 'simple1', component: SimpleCmp}, {path: 'simple2', component: SimpleCmp}, - {path: 'throwing', component: ThrowingCmp} - ]); + router.navigateByUrl('/team/22'); + advance(fixture); + expect(location.path()).toEqual('/team/22'); - let event: NavigationStart; - router.events.subscribe(e => { - if (e instanceof NavigationStart) { - event = e; - } - }); + expect(fixture.nativeElement).toHaveText('team 22 [ , right: ]'); - router.navigateByUrl('/simple1'); - advance(fixture); - const simple1NavStart = event!; + let urlAtNavStart = ''; + let urlAtRoutesRecognized = ''; + router.events.subscribe(e => { + if (e instanceof NavigationStart) { + urlAtNavStart = location.path(); + } + if (e instanceof RoutesRecognized) { + urlAtRoutesRecognized = location.path(); + } + }); - router.navigateByUrl('/throwing').catch(() => null); - advance(fixture); + router.navigateByUrl('/team/33'); - router.navigateByUrl('/simple2'); - advance(fixture); + advance(fixture); + expect(urlAtNavStart).toBe('/team/22'); + expect(urlAtRoutesRecognized).toBe('/team/33'); + expect(fixture.nativeElement).toHaveText('team 33 [ , right: ]'); + }))); - location.back(); - tick(); + it('should set `state`', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + router.resetConfig([ + {path: '', component: SimpleCmp}, + {path: 'simple', component: SimpleCmp}, + ]); - expect(event!.restoredState!.navigationId).toEqual(simple1NavStart.id); - })); + const fixture = createRoot(router, RootCmp); + let navigation: Navigation = null!; + router.events.subscribe(e => { + if (e instanceof NavigationStart) { + navigation = router.getCurrentNavigation()!; + } + }); - it('should not trigger another navigation when resetting the url back due to a NavigationError', - fakeAsync(() => { - TestBed.configureTestingModule( - {providers: [provideRouter([], withRouterConfig({urlUpdateStrategy}))]}); - const router = TestBed.inject(Router); - router.onSameUrlNavigation = 'reload'; + router.navigateByUrl('/simple', {state: {foo: 'bar'}}); + tick(); + + const state = location.getState() as any; + expect(state).toEqual({foo: 'bar', navigationId: 2}); + expect(navigation.extras.state).toBeDefined(); + expect(navigation.extras.state).toEqual({foo: 'bar'}); + }))); + + it('can renavigate to rejected URL', fakeAsync(() => { + const router = TestBed.inject(Router); + const canActivate = TestBed.inject(AuthGuard); + const location = TestBed.inject(Location); + router.resetConfig([ + {path: '', component: BlankCmp}, + { + path: 'simple', + component: SimpleCmp, + canActivate: [() => coreInject(AuthGuard).canActivate()] + }, + ]); + const fixture = createRoot(router, RootCmp); + + // Try to navigate to /simple but guard rejects + canActivate.canActivateResult = false; + router.navigateByUrl('/simple'); + advance(fixture); + expect(location.path()).toEqual(''); + expect(fixture.nativeElement.innerHTML).not.toContain('simple'); + + // Renavigate to /simple without guard rejection, should succeed. + canActivate.canActivateResult = true; + router.navigateByUrl('/simple'); + advance(fixture); + expect(location.path()).toEqual('/simple'); + expect(fixture.nativeElement.innerHTML).toContain('simple'); + })); + + it('can renavigate to same URL during in-flight navigation', fakeAsync(() => { + const router = TestBed.inject(Router); + const location = TestBed.inject(Location); + router.resetConfig([ + {path: '', component: BlankCmp}, + { + path: 'simple', + component: SimpleCmp, + canActivate: [() => coreInject(DelayedGuard).canActivate()] + }, + ]); + const fixture = createRoot(router, RootCmp); + + // Start navigating to /simple, but do not flush the guard delay + router.navigateByUrl('/simple'); + tick(); + // eager update strategy so URL is already updated. + expect(location.path()).toEqual('/simple'); + expect(fixture.nativeElement.innerHTML).not.toContain('simple'); + + // Start an additional navigation to /simple and ensure at least one of those succeeds. + // It's not super important which one gets processed, but in the past, the router would + // cancel the in-flight one and not process the new one. + router.navigateByUrl('/simple'); + tick(1000); + expect(location.path()).toEqual('/simple'); + expect(fixture.nativeElement.innerHTML).toContain('simple'); + })); + }); + }); + it('should navigate back and forward', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { const fixture = createRoot(router, RootCmp); - router.resetConfig( - [{path: 'simple', component: SimpleCmp}, {path: 'throwing', component: ThrowingCmp}]); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: + [{path: 'simple', component: SimpleCmp}, {path: 'user/:name', component: UserCmp}] + }]); - const events: any[] = []; - router.events.forEach((e: any) => { + let event: NavigationStart; + router.events.subscribe(e => { if (e instanceof NavigationStart) { - events.push(e.url); + event = e; } }); - router.navigateByUrl('/simple'); + router.navigateByUrl('/team/33/simple'); advance(fixture); + expect(location.path()).toEqual('/team/33/simple'); + const simpleNavStart = event!; - router.navigateByUrl('/throwing').catch(() => null); + router.navigateByUrl('/team/22/user/victor'); advance(fixture); + const userVictorNavStart = event!; - // we do not trigger another navigation to /simple - expect(events).toEqual(['/simple', '/throwing']); - })); - }); - it('should dispatch NavigationCancel after the url has been reset back', fakeAsync(() => { - TestBed.configureTestingModule( - {providers: [{provide: 'returnsFalse', useValue: () => false}]}); + location.back(); + advance(fixture); + expect(location.path()).toEqual('/team/33/simple'); + expect(event!.navigationTrigger).toEqual('popstate'); + expect(event!.restoredState!.navigationId).toEqual(simpleNavStart.id); - const router: Router = TestBed.inject(Router); - const location = TestBed.inject(Location); + location.forward(); + advance(fixture); + expect(location.path()).toEqual('/team/22/user/victor'); + expect(event!.navigationTrigger).toEqual('popstate'); + expect(event!.restoredState!.navigationId).toEqual(userVictorNavStart.id); + }))); - const fixture = createRoot(router, RootCmp); + it('should navigate to the same url when config changes', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - router.resetConfig([ - {path: 'simple', component: SimpleCmp}, { - path: 'throwing', - loadChildren: jasmine.createSpy('doesnotmatter'), - canLoad: ['returnsFalse'] - } - ]); - - router.navigateByUrl('/simple'); - advance(fixture); - - let routerUrlBeforeEmittingError = ''; - let locationUrlBeforeEmittingError = ''; - router.events.forEach(e => { - if (e instanceof NavigationCancel) { - expect(e.code).toBe(NavigationCancellationCode.GuardRejected); - routerUrlBeforeEmittingError = router.url; - locationUrlBeforeEmittingError = location.path(); - } - }); + router.resetConfig([{path: 'a', component: SimpleCmp}]); - location.go('/throwing'); - location.historyGo(0); - advance(fixture); + router.navigate(['/a']); + advance(fixture); + expect(location.path()).toEqual('/a'); + expect(fixture.nativeElement).toHaveText('simple'); - expect(routerUrlBeforeEmittingError).toEqual('/simple'); - expect(locationUrlBeforeEmittingError).toEqual('/simple'); - })); + router.resetConfig([{path: 'a', component: RouteCmp}]); - it('should support custom error handlers', fakeAsync(inject([Router], (router: Router) => { - router.errorHandler = (error) => 'resolvedValue'; - const fixture = createRoot(router, RootCmp); + router.navigate(['/a']); + advance(fixture); + expect(location.path()).toEqual('/a'); + expect(fixture.nativeElement).toHaveText('route'); + }))); - router.resetConfig([{path: 'user/:name', component: UserCmp}]); + it('should navigate when locations changes', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - const recordedEvents: any[] = []; - router.events.forEach(e => recordedEvents.push(e)); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [{path: 'user/:name', component: UserCmp}] + }]); - let e: any; - router.navigateByUrl('/invalid')!.then(_ => e = _); - advance(fixture); - expect(e).toEqual('resolvedValue'); + const recordedEvents: (NavigationStart|NavigationEnd)[] = []; + router.events.forEach(e => onlyNavigationStartAndEnd(e) && recordedEvents.push(e)); - expectEvents(recordedEvents, [[NavigationStart, '/invalid'], [NavigationError, '/invalid']]); - }))); + router.navigateByUrl('/team/22/user/victor'); + advance(fixture); - it('should recover from malformed uri errors', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - router.resetConfig([{path: 'simple', component: SimpleCmp}]); - const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/invalid/url%with%percent'); - advance(fixture); - expect(location.path()).toEqual(''); - }))); + location.go('/team/22/user/fedor'); + location.historyGo(0); + advance(fixture); - it('should not swallow errors', fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); + location.go('/team/22/user/fedor'); + location.historyGo(0); + advance(fixture); - router.resetConfig([{path: 'simple', component: SimpleCmp}]); + expect(fixture.nativeElement).toHaveText('team 22 [ user fedor, right: ]'); - router.navigateByUrl('/invalid'); - expect(() => advance(fixture)).toThrow(); + expectEvents(recordedEvents, [ + [NavigationStart, '/team/22/user/victor'], [NavigationEnd, '/team/22/user/victor'], + [NavigationStart, '/team/22/user/fedor'], [NavigationEnd, '/team/22/user/fedor'] + ]); + }))); - router.navigateByUrl('/invalid2'); - expect(() => advance(fixture)).toThrow(); - }))); + it('should update the location when the matched route does not change', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - it('should not swallow errors from browser state update', async () => { - const routerEvents: Event[] = []; - TestBed.inject(Router).resetConfig([{path: '**', component: BlankCmp}]); - TestBed.inject(Router).events.subscribe((e) => { - routerEvents.push(e); - }); - spyOn(TestBed.inject(Location), 'go').and.callFake(() => { - throw new Error(); - }); - try { - await RouterTestingHarness.create('/abc123'); - } catch { - } - // Ensure the first event is the start and that we get to the ResolveEnd event. If this is not - // true, then NavigationError may have been triggered at a time we don't expect here. - expect(routerEvents[0]).toBeInstanceOf(NavigationStart); - expect(routerEvents[routerEvents.length - 2]).toBeInstanceOf(ResolveEnd); + router.resetConfig([{path: '**', component: CollectParamsCmp}]); - expect(routerEvents[routerEvents.length - 1]).toBeInstanceOf(NavigationError); - }); + router.navigateByUrl('/one/two'); + advance(fixture); + const cmp = fixture.debugElement.children[1].componentInstance; + expect(location.path()).toEqual('/one/two'); + expect(fixture.nativeElement).toHaveText('collect-params'); - it('should replace state when path is equal to current path', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + expect(cmp.recordedUrls()).toEqual(['one/two']); - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: - [{path: 'simple', component: SimpleCmp}, {path: 'user/:name', component: UserCmp}] - }]); + router.navigateByUrl('/three/four'); + advance(fixture); + expect(location.path()).toEqual('/three/four'); + expect(fixture.nativeElement).toHaveText('collect-params'); + expect(cmp.recordedUrls()).toEqual(['one/two', 'three/four']); + }))); - router.navigateByUrl('/team/33/simple'); - advance(fixture); + describe('duplicate in-flight navigations', () => { + @Injectable() + class RedirectingGuard { + skipLocationChange = false; + constructor(private router: Router) {} + canActivate() { + this.router.navigate(['/simple'], {skipLocationChange: this.skipLocationChange}); + return false; + } + } - router.navigateByUrl('/team/22/user/victor'); - advance(fixture); + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: 'in1Second', + useValue: (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { + let res: any = null; + const p = new Promise(_ => res = _); + setTimeout(() => res(true), 1000); + return p; + } + }, + RedirectingGuard + ] + }); + }); - router.navigateByUrl('/team/22/user/victor'); - advance(fixture); + it('should reset location if a navigation by location is successful', fakeAsync(() => { + const router = TestBed.inject(Router); + const location = TestBed.inject(Location); + const fixture = createRoot(router, RootCmp); - location.back(); - advance(fixture); - expect(location.path()).toEqual('/team/33/simple'); - }))); + router.resetConfig([{path: 'simple', component: SimpleCmp, canActivate: ['in1Second']}]); - it('should handle componentless paths', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmpWithTwoOutlets); + // Trigger two location changes to the same URL. + // Because of the guard the order will look as follows: + // - location change 'simple' + // - start processing the change, start a guard + // - location change 'simple' + // - the first location change gets canceled, the URL gets reset to '/' + // - the second location change gets finished, the URL should be reset to '/simple' + location.go('/simple'); + location.historyGo(0); + location.historyGo(0); - router.resetConfig([ - { - path: 'parent/:id', - children: [ - {path: 'simple', component: SimpleCmp}, - {path: 'user/:name', component: UserCmp, outlet: 'right'} - ] - }, - {path: 'user/:name', component: UserCmp} - ]); - - - // navigate to a componentless route - router.navigateByUrl('/parent/11/(simple//right:user/victor)'); - advance(fixture); - expect(location.path()).toEqual('/parent/11/(simple//right:user/victor)'); - expect(fixture.nativeElement).toHaveText('primary [simple] right [user victor]'); - - // navigate to the same route with different params (reuse) - router.navigateByUrl('/parent/22/(simple//right:user/fedor)'); - advance(fixture); - expect(location.path()).toEqual('/parent/22/(simple//right:user/fedor)'); - expect(fixture.nativeElement).toHaveText('primary [simple] right [user fedor]'); - - // navigate to a normal route (check deactivation) - router.navigateByUrl('/user/victor'); - advance(fixture); - expect(location.path()).toEqual('/user/victor'); - expect(fixture.nativeElement).toHaveText('primary [user victor] right []'); - - // navigate back to a componentless route - router.navigateByUrl('/parent/11/(simple//right:user/victor)'); - advance(fixture); - expect(location.path()).toEqual('/parent/11/(simple//right:user/victor)'); - expect(fixture.nativeElement).toHaveText('primary [simple] right [user victor]'); - }))); - - it('should not deactivate aux routes when navigating from a componentless routes', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, TwoOutletsCmp); - - router.resetConfig([ - {path: 'simple', component: SimpleCmp}, - {path: 'componentless', children: [{path: 'simple', component: SimpleCmp}]}, - {path: 'user/:name', outlet: 'aux', component: UserCmp} - ]); - - router.navigateByUrl('/componentless/simple(aux:user/victor)'); - advance(fixture); - expect(location.path()).toEqual('/componentless/simple(aux:user/victor)'); - expect(fixture.nativeElement).toHaveText('[ simple, aux: user victor ]'); - - router.navigateByUrl('/simple(aux:user/victor)'); - advance(fixture); - expect(location.path()).toEqual('/simple(aux:user/victor)'); - expect(fixture.nativeElement).toHaveText('[ simple, aux: user victor ]'); - }))); - - it('should emit an event when an outlet gets activated', fakeAsync(() => { - @Component({ - selector: 'container', - template: - `` - }) - class Container { - activations: any[] = []; - deactivations: any[] = []; - - recordActivate(component: any): void { - this.activations.push(component); - } + tick(2000); + advance(fixture); - recordDeactivate(component: any): void { - this.deactivations.push(component); - } - } + expect(location.path()).toEqual('/simple'); + })); - TestBed.configureTestingModule({declarations: [Container]}); + it('should skip duplicate location events', fakeAsync(() => { + const router = TestBed.inject(Router); + const location = TestBed.inject(Location); + const fixture = createRoot(router, RootCmp); - const router: Router = TestBed.inject(Router); + router.resetConfig([ + { + path: 'blocked', + component: BlankCmp, + canActivate: [() => coreInject(RedirectingGuard).canActivate()] + }, + {path: 'simple', component: SimpleCmp} + ]); + router.navigateByUrl('/simple'); + advance(fixture); - const fixture = createRoot(router, Container); - const cmp = fixture.componentInstance; + location.go('/blocked'); + location.historyGo(0); - router.resetConfig( - [{path: 'blank', component: BlankCmp}, {path: 'simple', component: SimpleCmp}]); + advance(fixture); + expect(fixture.nativeElement.innerHTML).toContain('simple'); + })); - cmp.activations = []; - cmp.deactivations = []; + it('should not cause URL thrashing', async () => { + TestBed.configureTestingModule( + {providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))]}); - router.navigateByUrl('/blank'); - advance(fixture); + const router = TestBed.inject(Router); + const location = TestBed.inject(Location); + const fixture = TestBed.createComponent(RootCmp); + fixture.detectChanges(); - expect(cmp.activations.length).toEqual(1); - expect(cmp.activations[0] instanceof BlankCmp).toBe(true); + router.resetConfig([ + {path: 'home', component: SimpleCmp}, { + path: 'blocked', + component: BlankCmp, + canActivate: [() => coreInject(RedirectingGuard).canActivate()] + }, + {path: 'simple', component: SimpleCmp} + ]); - router.navigateByUrl('/simple'); - advance(fixture); + await router.navigateByUrl('/home'); + const urlChanges: string[] = []; + location.onUrlChange(change => { + urlChanges.push(change); + }); - expect(cmp.activations.length).toEqual(2); - expect(cmp.activations[1] instanceof SimpleCmp).toBe(true); - expect(cmp.deactivations.length).toEqual(1); - expect(cmp.deactivations[0] instanceof BlankCmp).toBe(true); - })); + await router.navigateByUrl('/blocked'); + await fixture.whenStable(); - it('should update url and router state before activating components', - fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); + expect(fixture.nativeElement.innerHTML).toContain('simple'); + // We do not want the URL to flicker to `/home` between the /blocked and /simple routes + expect(urlChanges).toEqual(['/blocked', '/simple']); + }); - router.resetConfig([{path: 'cmp', component: ComponentRecordingRoutePathAndUrl}]); - - router.navigateByUrl('/cmp'); - advance(fixture); - - const cmp: ComponentRecordingRoutePathAndUrl = - fixture.debugElement.children[1].componentInstance; - - expect(cmp.url).toBe('/cmp'); - expect(cmp.path.length).toEqual(2); - }))); + it('can render a 404 page without changing the URL', fakeAsync(() => { + TestBed.configureTestingModule( + {providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))]}); + const router = TestBed.inject(Router); + TestBed.inject(RedirectingGuard).skipLocationChange = true; + const location = TestBed.inject(Location); + const fixture = createRoot(router, RootCmp); + router.resetConfig([ + {path: 'home', component: SimpleCmp}, + { + path: 'blocked', + component: BlankCmp, + canActivate: [() => coreInject(RedirectingGuard).canActivate()] + }, + {path: 'simple', redirectTo: '404'}, + {path: '404', component: SimpleCmp}, + ]); + router.navigateByUrl('/home'); + advance(fixture); + location.go('/blocked'); + location.historyGo(0); + advance(fixture); + expect(location.path()).toEqual('/blocked'); + expect(fixture.nativeElement.innerHTML).toContain('simple'); + })); - describe('data', () => { - class ResolveSix { - resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): number { - return 6; - } - } + it('should accurately track currentNavigation', fakeAsync(() => { + const router = TestBed.inject(Router); + router.resetConfig([ + {path: 'one', component: SimpleCmp, canActivate: ['in1Second']}, + {path: 'two', component: BlankCmp, canActivate: ['in1Second']}, + ]); - @Component({selector: 'nested-cmp', template: 'nested-cmp'}) - class NestedComponentWithData { - data: any = []; - constructor(private route: ActivatedRoute) { - route.data.forEach(d => this.data.push(d)); - } - } + router.events.subscribe((e) => { + if (e instanceof NavigationStart) { + if (e.url === '/one') { + router.navigateByUrl('two'); + } + router.events.subscribe((e) => { + if (e instanceof GuardsCheckEnd) { + expect(router.getCurrentNavigation()?.extractedUrl.toString()).toEqual('/two'); + expect(router.getCurrentNavigation()?.extras).toBeDefined(); + } + }); + } + }); - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - {provide: 'resolveTwo', useValue: (a: any, b: any) => 2}, - {provide: 'resolveFour', useValue: (a: any, b: any) => 4}, - {provide: 'resolveSix', useClass: ResolveSix}, - {provide: 'resolveError', useValue: (a: any, b: any) => Promise.reject('error')}, - {provide: 'resolveNullError', useValue: (a: any, b: any) => Promise.reject(null)}, - {provide: 'resolveEmpty', useValue: (a: any, b: any) => EMPTY}, - {provide: 'numberOfUrlSegments', useValue: (a: any, b: any) => a.url.length}, - { - provide: 'overridingGuard', - useValue: (route: ActivatedRouteSnapshot) => { - route.data = {prop: 10}; - return true; - } - }, - ] - }); + router.navigateByUrl('one'); + tick(1000); + })); }); - it('should provide resolved data', fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmpWithTwoOutlets); + it('should support secondary routes', fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); router.resetConfig([{ - path: 'parent/:id', - data: {one: 1}, - resolve: {two: 'resolveTwo'}, + path: 'team/:id', + component: TeamCmp, children: [ - {path: '', data: {three: 3}, resolve: {four: 'resolveFour'}, component: RouteCmp}, - { - path: '', - data: {five: 5}, - resolve: {six: 'resolveSix'}, - component: RouteCmp, - outlet: 'right' - }, + {path: 'user/:name', component: UserCmp}, + {path: 'simple', component: SimpleCmp, outlet: 'right'} ] }]); - router.navigateByUrl('/parent/1'); - advance(fixture); - - const primaryCmp = fixture.debugElement.children[1].componentInstance; - const rightCmp = fixture.debugElement.children[3].componentInstance; - - expect(primaryCmp.route.snapshot.data).toEqual({one: 1, two: 2, three: 3, four: 4}); - expect(rightCmp.route.snapshot.data).toEqual({one: 1, two: 2, five: 5, six: 6}); - - const primaryRecorded: any[] = []; - primaryCmp.route.data.forEach((rec: any) => primaryRecorded.push(rec)); - - const rightRecorded: any[] = []; - rightCmp.route.data.forEach((rec: any) => rightRecorded.push(rec)); - - router.navigateByUrl('/parent/2'); + router.navigateByUrl('/team/22/(user/victor//right:simple)'); advance(fixture); - expect(primaryRecorded).toEqual([{one: 1, three: 3, two: 2, four: 4}]); - expect(rightRecorded).toEqual([{one: 1, five: 5, two: 2, six: 6}]); + expect(fixture.nativeElement).toHaveText('team 22 [ user victor, right: simple ]'); }))); - it('should handle errors', fakeAsync(inject([Router], (router: Router) => { + it('should support secondary routes in separate commands', + fakeAsync(inject([Router], (router: Router) => { const fixture = createRoot(router, RootCmp); - router.resetConfig( - [{path: 'simple', component: SimpleCmp, resolve: {error: 'resolveError'}}]); - - const recordedEvents: any[] = []; - router.events.subscribe(e => e instanceof RouterEvent && recordedEvents.push(e)); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [ + {path: 'user/:name', component: UserCmp}, + {path: 'simple', component: SimpleCmp, outlet: 'right'} + ] + }]); - let e: any = null; - router.navigateByUrl('/simple')!.catch(error => e = error); + router.navigateByUrl('/team/22/user/victor'); + advance(fixture); + router.navigate(['team/22', {outlets: {right: 'simple'}}]); advance(fixture); - expectEvents(recordedEvents, [ - [NavigationStart, '/simple'], [RoutesRecognized, '/simple'], - [GuardsCheckStart, '/simple'], [GuardsCheckEnd, '/simple'], [ResolveStart, '/simple'], - [NavigationError, '/simple'] - ]); - - expect(e).toEqual('error'); + expect(fixture.nativeElement).toHaveText('team 22 [ user victor, right: simple ]'); }))); - it('should handle empty errors', fakeAsync(inject([Router], (router: Router) => { + it('should support secondary routes as child of empty path parent', + fakeAsync(inject([Router], (router: Router) => { const fixture = createRoot(router, RootCmp); - router.resetConfig( - [{path: 'simple', component: SimpleCmp, resolve: {error: 'resolveNullError'}}]); - - const recordedEvents: any[] = []; - router.events.subscribe(e => e instanceof RouterEvent && recordedEvents.push(e)); + router.resetConfig([{ + path: '', + component: TeamCmp, + children: [{path: 'simple', component: SimpleCmp, outlet: 'right'}] + }]); - let e: any = 'some value'; - router.navigateByUrl('/simple').catch(error => e = error); + router.navigateByUrl('/(right:simple)'); advance(fixture); - expect(e).toEqual(null); + expect(fixture.nativeElement).toHaveText('team [ , right: simple ]'); }))); - it('should not navigate when all resolvers return empty result', - fakeAsync(inject([Router], (router: Router) => { + it('should deactivate outlets', fakeAsync(inject([Router], (router: Router) => { const fixture = createRoot(router, RootCmp); - router.resetConfig([ - {path: 'simple', component: SimpleCmp, resolve: {e1: 'resolveEmpty', e2: 'resolveEmpty'}} - ]); - - const recordedEvents: any[] = []; - router.events.subscribe(e => e instanceof RouterEvent && recordedEvents.push(e)); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [ + {path: 'user/:name', component: UserCmp}, + {path: 'simple', component: SimpleCmp, outlet: 'right'} + ] + }]); - let e: any = null; - router.navigateByUrl('/simple').catch(error => e = error); + router.navigateByUrl('/team/22/(user/victor//right:simple)'); advance(fixture); - expectEvents(recordedEvents, [ - [NavigationStart, '/simple'], - [RoutesRecognized, '/simple'], - [GuardsCheckStart, '/simple'], - [GuardsCheckEnd, '/simple'], - [ResolveStart, '/simple'], - [NavigationCancel, '/simple'], - ]); - - expect((recordedEvents[recordedEvents.length - 1] as NavigationCancel).code) - .toBe(NavigationCancellationCode.NoDataFromResolver); + router.navigateByUrl('/team/22/user/victor'); + advance(fixture); - expect(e).toEqual(null); + expect(fixture.nativeElement).toHaveText('team 22 [ user victor, right: ]'); }))); - it('should not navigate when at least one resolver returns empty result', - fakeAsync(inject([Router], (router: Router) => { + it('should deactivate nested outlets', fakeAsync(inject([Router], (router: Router) => { const fixture = createRoot(router, RootCmp); router.resetConfig([ - {path: 'simple', component: SimpleCmp, resolve: {e1: 'resolveTwo', e2: 'resolveEmpty'}} + { + path: 'team/:id', + component: TeamCmp, + children: [ + {path: 'user/:name', component: UserCmp}, + {path: 'simple', component: SimpleCmp, outlet: 'right'} + ] + }, + {path: '', component: BlankCmp} ]); - const recordedEvents: any[] = []; - router.events.subscribe(e => e instanceof RouterEvent && recordedEvents.push(e)); - - let e: any = null; - router.navigateByUrl('/simple').catch(error => e = error); + router.navigateByUrl('/team/22/(user/victor//right:simple)'); advance(fixture); - expectEvents(recordedEvents, [ - [NavigationStart, '/simple'], - [RoutesRecognized, '/simple'], - [GuardsCheckStart, '/simple'], - [GuardsCheckEnd, '/simple'], - [ResolveStart, '/simple'], - [NavigationCancel, '/simple'], - ]); - - expect(e).toEqual(null); - }))); - - it('should not navigate when all resolvers for a child route from forChild() returns empty result', - fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); - - @Component({selector: 'lazy-cmp', template: 'lazy-loaded-1'}) - class LazyComponent1 { - } - - - @NgModule({ - declarations: [LazyComponent1], - imports: [ - RouterModule.forChild([{ - path: 'loaded', - component: LazyComponent1, - resolve: {e1: 'resolveEmpty', e2: 'resolveEmpty'} - }]), - ], - }) - class LoadedModule { - } - - router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); - - const recordedEvents: any[] = []; - router.events.subscribe(e => e instanceof RouterEvent && recordedEvents.push(e)); - - let e: any = null; - router.navigateByUrl('lazy/loaded').catch(error => e = error); + router.navigateByUrl('/'); advance(fixture); - expectEvents(recordedEvents, [ - [NavigationStart, '/lazy/loaded'], - [RoutesRecognized, '/lazy/loaded'], - [GuardsCheckStart, '/lazy/loaded'], - [GuardsCheckEnd, '/lazy/loaded'], - [ResolveStart, '/lazy/loaded'], - [NavigationCancel, '/lazy/loaded'], - ]); - - expect(e).toEqual(null); + expect(fixture.nativeElement).toHaveText(''); }))); - it('should not navigate when at least one resolver for a child route from forChild() returns empty result', - fakeAsync(inject([Router], (router: Router) => { + it('should set query params and fragment', fakeAsync(inject([Router], (router: Router) => { const fixture = createRoot(router, RootCmp); - @Component({selector: 'lazy-cmp', template: 'lazy-loaded-1'}) - class LazyComponent1 { - } - - @NgModule({ - declarations: [LazyComponent1], - imports: [ - RouterModule.forChild([{ - path: 'loaded', - component: LazyComponent1, - resolve: {e1: 'resolveTwo', e2: 'resolveEmpty'} - }]), - ], - }) - class LoadedModule { - } - - router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); + router.resetConfig([{path: 'query', component: QueryParamsAndFragmentCmp}]); - const recordedEvents: any[] = []; - router.events.subscribe(e => e instanceof RouterEvent && recordedEvents.push(e)); - - let e: any = null; - router.navigateByUrl('lazy/loaded').catch(error => e = error); + router.navigateByUrl('/query?name=1#fragment1'); advance(fixture); + expect(fixture.nativeElement).toHaveText('query: 1 fragment: fragment1'); - expectEvents(recordedEvents, [ - [NavigationStart, '/lazy/loaded'], - [RoutesRecognized, '/lazy/loaded'], - [GuardsCheckStart, '/lazy/loaded'], - [GuardsCheckEnd, '/lazy/loaded'], - [ResolveStart, '/lazy/loaded'], - [NavigationCancel, '/lazy/loaded'], - ]); - - expect(e).toEqual(null); + router.navigateByUrl('/query?name=2#fragment2'); + advance(fixture); + expect(fixture.nativeElement).toHaveText('query: 2 fragment: fragment2'); }))); - it('should include target snapshot in NavigationError when resolver throws', async () => { - const router = TestBed.inject(Router); - const errorMessage = 'throwing resolver'; - @Injectable({providedIn: 'root'}) - class ThrowingResolver { - resolve() { - throw new Error(errorMessage); - } - } - - let caughtError: NavigationError|undefined; - router.events.subscribe(e => { - if (e instanceof NavigationError) { - caughtError = e; - } - }); - router.resetConfig( - [{path: 'throwing', resolve: {thrower: ThrowingResolver}, component: BlankCmp}]); - try { - await router.navigateByUrl('/throwing'); - fail('navigation should throw'); - } catch (e: unknown) { - expect((e as Error).message).toEqual(errorMessage); - } - - expect(caughtError).toBeDefined(); - expect(caughtError?.target).toBeDefined(); - }); - - it('should preserve resolved data', fakeAsync(inject([Router], (router: Router) => { + it('should handle empty or missing fragments', fakeAsync(inject([Router], (router: Router) => { const fixture = createRoot(router, RootCmp); - router.resetConfig([{ - path: 'parent', - resolve: {two: 'resolveTwo'}, - children: [ - {path: 'child1', component: CollectParamsCmp}, - {path: 'child2', component: CollectParamsCmp} - ] - }]); + router.resetConfig([{path: 'query', component: QueryParamsAndFragmentCmp}]); - router.navigateByUrl('/parent/child1'); + router.navigateByUrl('/query#'); advance(fixture); + expect(fixture.nativeElement).toHaveText('query: fragment: '); - router.navigateByUrl('/parent/child2'); + router.navigateByUrl('/query'); advance(fixture); - - const cmp: CollectParamsCmp = fixture.debugElement.children[1].componentInstance; - expect(cmp.route.snapshot.data).toEqual({two: 2}); + expect(fixture.nativeElement).toHaveText('query: fragment: null'); }))); - it('should override route static data with resolved data', + it('should ignore null and undefined query params', fakeAsync(inject([Router], (router: Router) => { const fixture = createRoot(router, RootCmp); - router.resetConfig([{ - path: '', - component: NestedComponentWithData, - resolve: {prop: 'resolveTwo'}, - data: {prop: 'static'}, - }]); + router.resetConfig([{path: 'query', component: EmptyQueryParamsCmp}]); - router.navigateByUrl('/'); + router.navigate(['query'], {queryParams: {name: 1, age: null, page: undefined}}); advance(fixture); const cmp = fixture.debugElement.children[1].componentInstance; - - expect(cmp.data).toEqual([{prop: 2}]); + expect(cmp.recordedParams).toEqual([{name: '1'}]); }))); - it('should correctly override inherited route static data with resolved data', + it('should throw an error when one of the commands is null/undefined', fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{ - path: 'a', - component: WrapperCmp, - resolve: {prop2: 'resolveTwo'}, - data: {prop: 'wrapper-a'}, - children: [ - // will inherit data from this child route because it has `path` and its parent has - // component - { - path: 'b', - data: {prop: 'nested-b'}, - resolve: {prop3: 'resolveFour'}, - children: [ - { - path: 'c', - children: - [{path: '', component: NestedComponentWithData, data: {prop3: 'nested'}}] - }, - ] - }, - ], - }]); + createRoot(router, RootCmp); - router.navigateByUrl('/a/b/c'); - advance(fixture); + router.resetConfig([{path: 'query', component: EmptyQueryParamsCmp}]); - const pInj = - fixture.debugElement.queryAll(By.directive(NestedComponentWithData))[0].injector!; - const cmp = pInj.get(NestedComponentWithData); - expect(cmp.data).toEqual([{prop: 'nested-b', prop3: 'nested'}]); + expect(() => router.navigate([ + undefined, 'query' + ])).toThrowError(/The requested path contains undefined segment at index 0/); }))); - it('should not override inherited resolved data with inherited static data', - fakeAsync(inject([Router], (router: Router) => { + it('should push params only when they change', fakeAsync(inject([Router], (router: Router) => { const fixture = createRoot(router, RootCmp); router.resetConfig([{ - path: 'a', - component: WrapperCmp, - resolve: {prop2: 'resolveTwo'}, - data: {prop: 'wrapper-a'}, - children: [ - // will inherit data from this child route because it has `path` and its parent has - // component - { - path: 'b', - data: {prop2: 'parent-b', prop: 'parent-b'}, - children: [ - { - path: 'c', - resolve: {prop2: 'resolveFour'}, - children: [ - { - path: '', - component: NestedComponentWithData, - data: {prop: 'nested-d'}, - }, - ] - }, - ] - }, - ], + path: 'team/:id', + component: TeamCmp, + children: [{path: 'user/:name', component: UserCmp}] }]); - router.navigateByUrl('/a/b/c'); + router.navigateByUrl('/team/22/user/victor'); + advance(fixture); + const team = fixture.debugElement.children[1].componentInstance; + const user = fixture.debugElement.children[1].children[1].componentInstance; + + expect(team.recordedParams).toEqual([{id: '22'}]); + expect(team.snapshotParams).toEqual([{id: '22'}]); + expect(user.recordedParams).toEqual([{name: 'victor'}]); + expect(user.snapshotParams).toEqual([{name: 'victor'}]); + + router.navigateByUrl('/team/22/user/fedor'); advance(fixture); - const pInj = - fixture.debugElement.queryAll(By.directive(NestedComponentWithData))[0].injector!; - const cmp = pInj.get(NestedComponentWithData); - expect(cmp.data).toEqual([{prop: 'nested-d', prop2: 4}]); + expect(team.recordedParams).toEqual([{id: '22'}]); + expect(team.snapshotParams).toEqual([{id: '22'}]); + expect(user.recordedParams).toEqual([{name: 'victor'}, {name: 'fedor'}]); + expect(user.snapshotParams).toEqual([{name: 'victor'}, {name: 'fedor'}]); }))); - it('should not override nested route static data when both are using resolvers', - fakeAsync(inject([Router], (router: Router) => { + it('should work when navigating to /', fakeAsync(inject([Router], (router: Router) => { const fixture = createRoot(router, RootCmp); router.resetConfig([ - { - path: 'child', - component: WrapperCmp, - resolve: {prop: 'resolveTwo'}, - children: [{ - path: '', - pathMatch: 'full', - component: NestedComponentWithData, - resolve: {prop: 'resolveFour'} - }] - }, + {path: '', pathMatch: 'full', component: SimpleCmp}, + {path: 'user/:name', component: UserCmp} ]); - router.navigateByUrl('/child'); + router.navigateByUrl('/user/victor'); + advance(fixture); + + expect(fixture.nativeElement).toHaveText('user victor'); + + router.navigateByUrl('/'); advance(fixture); - const pInj = fixture.debugElement.query(By.directive(NestedComponentWithData)).injector!; - const cmp = pInj.get(NestedComponentWithData); - expect(cmp.data).toEqual([{prop: 4}]); + expect(fixture.nativeElement).toHaveText('simple'); }))); - it('should not override child route\'s static data when both are using static data', - fakeAsync(inject([Router], (router: Router) => { + it('should cancel in-flight navigations', fakeAsync(inject([Router], (router: Router) => { const fixture = createRoot(router, RootCmp); - router.resetConfig([ - { - path: 'child', - component: WrapperCmp, - data: {prop: 'wrapper'}, - children: [{ - path: '', - pathMatch: 'full', - component: NestedComponentWithData, - data: {prop: 'inner'} - }] - }, - ]); + router.resetConfig([{path: 'user/:name', component: UserCmp}]); - router.navigateByUrl('/child'); + const recordedEvents: Event[] = []; + router.events.forEach(e => recordedEvents.push(e)); + + router.navigateByUrl('/user/init'); advance(fixture); - const pInj = fixture.debugElement.query(By.directive(NestedComponentWithData)).injector!; - const cmp = pInj.get(NestedComponentWithData); - expect(cmp.data).toEqual([{prop: 'inner'}]); - }))); + const user = fixture.debugElement.children[1].componentInstance; - it('should not override child route\'s static data when wrapper is using resolved data and the child route static data', - fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); + let r1: any, r2: any; + router.navigateByUrl('/user/victor').then(_ => r1 = _); + router.navigateByUrl('/user/fedor').then(_ => r2 = _); + advance(fixture); - router.resetConfig([ - { - path: 'nested', - component: WrapperCmp, - resolve: {prop: 'resolveTwo', prop2: 'resolveSix'}, - data: {prop3: 'wrapper-static', prop4: 'another-static'}, - children: [{ - path: '', - pathMatch: 'full', - component: NestedComponentWithData, - data: {prop: 'nested', prop4: 'nested-static'} - }] - }, - ]); + expect(r1).toEqual(false); // returns false because it was canceled + expect(r2).toEqual(true); // returns true because it was successful - router.navigateByUrl('/nested'); - advance(fixture); + expect(fixture.nativeElement).toHaveText('user fedor'); + expect(user.recordedParams).toEqual([{name: 'init'}, {name: 'fedor'}]); - const pInj = fixture.debugElement.query(By.directive(NestedComponentWithData)).injector!; - const cmp = pInj.get(NestedComponentWithData); - // Issue 34361 - `prop` should contain value defined in `data` object from the nested - // route. - expect(cmp.data).toEqual( - [{prop: 'nested', prop2: 6, prop3: 'wrapper-static', prop4: 'nested-static'}]); + expectEvents(recordedEvents, [ + [NavigationStart, '/user/init'], + [RoutesRecognized, '/user/init'], + [GuardsCheckStart, '/user/init'], + [ChildActivationStart], + [ActivationStart], + [GuardsCheckEnd, '/user/init'], + [ResolveStart, '/user/init'], + [ResolveEnd, '/user/init'], + [ActivationEnd], + [ChildActivationEnd], + [NavigationEnd, '/user/init'], + + [NavigationStart, '/user/victor'], + [NavigationCancel, '/user/victor'], + + [NavigationStart, '/user/fedor'], + [RoutesRecognized, '/user/fedor'], + [GuardsCheckStart, '/user/fedor'], + [ChildActivationStart], + [ActivationStart], + [GuardsCheckEnd, '/user/fedor'], + [ResolveStart, '/user/fedor'], + [ResolveEnd, '/user/fedor'], + [ActivationEnd], + [ChildActivationEnd], + [NavigationEnd, '/user/fedor'] + ]); }))); - it('should allow guards alter data resolved by routes', + it('should properly set currentNavigation when cancelling in-flight navigations', fakeAsync(inject([Router], (router: Router) => { - // This is not documented or recommended behavior but is here to prevent unexpected - // regressions. This behavior isn't necessary 'by design' but it was discovered during a - // refactor that some teams depend on it. const fixture = createRoot(router, RootCmp); - router.resetConfig([ - { - path: 'route', - component: NestedComponentWithData, - canActivate: ['overridingGuard'], - }, - ]); + router.resetConfig([{path: 'user/:name', component: UserCmp}]); + + router.navigateByUrl('/user/init'); + advance(fixture); - router.navigateByUrl('/route'); + router.navigateByUrl('/user/victor'); + expect(router.getCurrentNavigation()).not.toBe(null); + router.navigateByUrl('/user/fedor'); + // Due to https://github.com/angular/angular/issues/29389, this would be `false` + // when running a second navigation. + expect(router.getCurrentNavigation()).not.toBe(null); advance(fixture); - const pInj = fixture.debugElement.query(By.directive(NestedComponentWithData)).injector!; - const cmp = pInj.get(NestedComponentWithData); - expect(cmp.data).toEqual([{prop: 10}]); + expect(router.getCurrentNavigation()).toBe(null); + expect(fixture.nativeElement).toHaveText('user fedor'); }))); - it('should rerun resolvers when the urls segments of a wildcard route change', - fakeAsync(inject([Router, Location], (router: Router) => { + it('should handle failed navigations gracefully', + fakeAsync(inject([Router], (router: Router) => { const fixture = createRoot(router, RootCmp); - router.resetConfig([{ - path: '**', - component: CollectParamsCmp, - resolve: {numberOfUrlSegments: 'numberOfUrlSegments'} - }]); + router.resetConfig([{path: 'user/:name', component: UserCmp}]); - router.navigateByUrl('/one/two'); - advance(fixture); - const cmp = fixture.debugElement.children[1].componentInstance; + const recordedEvents: Event[] = []; + router.events.forEach(e => recordedEvents.push(e)); - expect(cmp.route.snapshot.data).toEqual({numberOfUrlSegments: 2}); + let e: any; + router.navigateByUrl('/invalid').catch(_ => e = _); + advance(fixture); + expect(e.message).toContain('Cannot match any routes'); - router.navigateByUrl('/one/two/three'); + router.navigateByUrl('/user/fedor'); advance(fixture); - expect(cmp.route.snapshot.data).toEqual({numberOfUrlSegments: 3}); - }))); + expect(fixture.nativeElement).toHaveText('user fedor'); - describe('should run resolvers for the same route concurrently', () => { - let log: string[]; - let observer: Observer; + expectEvents(recordedEvents, [ + [NavigationStart, '/invalid'], [NavigationError, '/invalid'], - beforeEach(() => { - log = []; - TestBed.configureTestingModule({ - providers: [ - { - provide: 'resolver1', - useValue: () => { - const obs$ = new Observable((obs) => { - observer = obs; - return () => {}; - }); - return obs$.pipe(map(() => log.push('resolver1'))); - } - }, - { - provide: 'resolver2', - useValue: () => { - return of(null).pipe(map(() => { - log.push('resolver2'); - observer.next(null); - observer.complete(); - })); - } - }, - ] - }); + [NavigationStart, '/user/fedor'], [RoutesRecognized, '/user/fedor'], + [GuardsCheckStart, '/user/fedor'], [ChildActivationStart], [ActivationStart], + [GuardsCheckEnd, '/user/fedor'], [ResolveStart, '/user/fedor'], + [ResolveEnd, '/user/fedor'], [ActivationEnd], [ChildActivationEnd], + [NavigationEnd, '/user/fedor'] + ]); + }))); + + it('should be able to provide an error handler with DI dependencies', async () => { + @Injectable({providedIn: 'root'}) + class Handler { + handlerCalled = false; + } + TestBed.configureTestingModule({ + providers: [ + provideRouter( + [{ + path: 'throw', + canMatch: [() => { + throw new Error(''); + }], + component: BlankCmp + }], + withNavigationErrorHandler(() => coreInject(Handler).handlerCalled = true)), + ] }); + const router = TestBed.inject(Router); + await expectAsync(router.navigateByUrl('/throw')).toBeRejected(); + expect(TestBed.inject(Handler).handlerCalled).toBeTrue(); + }); - it('works', fakeAsync(inject([Router], (router: Router) => { + // Errors should behave the same for both deferred and eager URL update strategies + (['deferred', 'eager'] as const).forEach(urlUpdateStrategy => { + it('should dispatch NavigationError after the url has been reset back', fakeAsync(() => { + TestBed.configureTestingModule( + {providers: [provideRouter([], withRouterConfig({urlUpdateStrategy}))]}); + const router: Router = TestBed.inject(Router); + const location = TestBed.inject(Location); const fixture = createRoot(router, RootCmp); - router.resetConfig([{ - path: 'a', - resolve: { - one: 'resolver1', - two: 'resolver2', - }, - component: SimpleCmp - }]); + router.resetConfig([ + {path: 'simple', component: SimpleCmp}, {path: 'throwing', component: ThrowingCmp} + ]); - router.navigateByUrl('/a'); + router.navigateByUrl('/simple'); advance(fixture); - expect(log).toEqual(['resolver2', 'resolver1']); - }))); + let routerUrlBeforeEmittingError = ''; + let locationUrlBeforeEmittingError = ''; + router.events.forEach(e => { + if (e instanceof NavigationError) { + routerUrlBeforeEmittingError = router.url; + locationUrlBeforeEmittingError = location.path(); + } + }); + router.navigateByUrl('/throwing').catch(() => null); + advance(fixture); + + expect(routerUrlBeforeEmittingError).toEqual('/simple'); + expect(locationUrlBeforeEmittingError).toEqual('/simple'); + })); + + it('can renavigate to throwing component', fakeAsync(() => { + TestBed.configureTestingModule( + {providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))]}); + const router = TestBed.inject(Router); + const location = TestBed.inject(Location); + router.resetConfig([ + {path: '', component: BlankCmp}, + {path: 'throwing', component: ConditionalThrowingCmp}, + ]); + const fixture = createRoot(router, RootCmp); + + // Try navigating to a component which throws an error during activation. + ConditionalThrowingCmp.throwError = true; + expect(() => { + router.navigateByUrl('/throwing'); + advance(fixture); + }).toThrow(); + expect(location.path()).toEqual(''); + expect(fixture.nativeElement.innerHTML).not.toContain('throwing'); + + // Ensure we can re-navigate to that same URL and succeed. + ConditionalThrowingCmp.throwError = false; + router.navigateByUrl('/throwing'); + advance(fixture); + expect(location.path()).toEqual('/throwing'); + expect(fixture.nativeElement.innerHTML).toContain('throwing'); + })); + + it('should reset the url with the right state when navigation errors', fakeAsync(() => { + TestBed.configureTestingModule( + {providers: [provideRouter([], withRouterConfig({urlUpdateStrategy}))]}); + const router: Router = TestBed.inject(Router); + const location = TestBed.inject(Location); + const fixture = createRoot(router, RootCmp); + + router.resetConfig([ + {path: 'simple1', component: SimpleCmp}, {path: 'simple2', component: SimpleCmp}, + {path: 'throwing', component: ThrowingCmp} + ]); + + let event: NavigationStart; + router.events.subscribe(e => { + if (e instanceof NavigationStart) { + event = e; + } + }); + + router.navigateByUrl('/simple1'); + advance(fixture); + const simple1NavStart = event!; + + router.navigateByUrl('/throwing').catch(() => null); + advance(fixture); + + router.navigateByUrl('/simple2'); + advance(fixture); + + location.back(); + tick(); + + expect(event!.restoredState!.navigationId).toEqual(simple1NavStart.id); + })); + + it('should not trigger another navigation when resetting the url back due to a NavigationError', + fakeAsync(() => { + TestBed.configureTestingModule( + {providers: [provideRouter([], withRouterConfig({urlUpdateStrategy}))]}); + const router = TestBed.inject(Router); + router.onSameUrlNavigation = 'reload'; + + const fixture = createRoot(router, RootCmp); + + router.resetConfig([ + {path: 'simple', component: SimpleCmp}, {path: 'throwing', component: ThrowingCmp} + ]); + + const events: any[] = []; + router.events.forEach((e: any) => { + if (e instanceof NavigationStart) { + events.push(e.url); + } + }); + + router.navigateByUrl('/simple'); + advance(fixture); + + router.navigateByUrl('/throwing').catch(() => null); + advance(fixture); + + // we do not trigger another navigation to /simple + expect(events).toEqual(['/simple', '/throwing']); + })); }); - it('can resolve symbol keys', fakeAsync(() => { - const router = TestBed.inject(Router); + it('should dispatch NavigationCancel after the url has been reset back', fakeAsync(() => { + TestBed.configureTestingModule( + {providers: [{provide: 'returnsFalse', useValue: () => false}]}); + + const router: Router = TestBed.inject(Router); + const location = TestBed.inject(Location); + const fixture = createRoot(router, RootCmp); - const symbolKey = Symbol('key'); - router.resetConfig( - [{path: 'simple', component: SimpleCmp, resolve: {[symbolKey]: 'resolveFour'}}]); + router.resetConfig([ + {path: 'simple', component: SimpleCmp}, { + path: 'throwing', + loadChildren: jasmine.createSpy('doesnotmatter'), + canLoad: ['returnsFalse'] + } + ]); router.navigateByUrl('/simple'); advance(fixture); - expect(router.routerState.root.snapshot.firstChild!.data[symbolKey]).toEqual(4); - })); - - it('should allow resolvers as pure functions', fakeAsync(() => { - const router = TestBed.inject(Router); - const fixture = createRoot(router, RootCmp); - const user = Symbol('user'); - - const userResolver: ResolveFn = (route: ActivatedRouteSnapshot) => - route.params['user']; - router.resetConfig( - [{path: ':user', component: SimpleCmp, resolve: {[user]: userResolver}}]); + let routerUrlBeforeEmittingError = ''; + let locationUrlBeforeEmittingError = ''; + router.events.forEach(e => { + if (e instanceof NavigationCancel) { + expect(e.code).toBe(NavigationCancellationCode.GuardRejected); + routerUrlBeforeEmittingError = router.url; + locationUrlBeforeEmittingError = location.path(); + } + }); - router.navigateByUrl('/atscott'); + location.go('/throwing'); + location.historyGo(0); advance(fixture); - expect(router.routerState.root.snapshot.firstChild!.data[user]).toEqual('atscott'); + expect(routerUrlBeforeEmittingError).toEqual('/simple'); + expect(locationUrlBeforeEmittingError).toEqual('/simple'); })); - it('should allow DI in resolvers as pure functions', fakeAsync(() => { - const router = TestBed.inject(Router); + it('should support custom error handlers', fakeAsync(inject([Router], (router: Router) => { + router.errorHandler = (error) => 'resolvedValue'; const fixture = createRoot(router, RootCmp); - const user = Symbol('user'); - @Injectable({providedIn: 'root'}) - class LoginState { - user = 'atscott'; - } + router.resetConfig([{path: 'user/:name', component: UserCmp}]); - router.resetConfig([{ - path: '**', - component: SimpleCmp, - resolve: { - [user]: () => coreInject(LoginState).user, - }, - }]); + const recordedEvents: any[] = []; + router.events.forEach(e => recordedEvents.push(e)); - router.navigateByUrl('/'); + let e: any; + router.navigateByUrl('/invalid')!.then(_ => e = _); advance(fixture); + expect(e).toEqual('resolvedValue'); - expect(router.routerState.root.snapshot.firstChild!.data[user]).toEqual('atscott'); - })); - }); + expectEvents( + recordedEvents, [[NavigationStart, '/invalid'], [NavigationError, '/invalid']]); + }))); - describe('router links', () => { - it('should support skipping location update for anchor router links', + it('should recover from malformed uri errors', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = TestBed.createComponent(RootCmp); + router.resetConfig([{path: 'simple', component: SimpleCmp}]); + const fixture = createRoot(router, RootCmp); + router.navigateByUrl('/invalid/url%with%percent'); advance(fixture); + expect(location.path()).toEqual(''); + }))); - router.resetConfig([{path: 'team/:id', component: TeamCmp}]); - - router.navigateByUrl('/team/22'); - advance(fixture); - expect(location.path()).toEqual('/team/22'); - expect(fixture.nativeElement).toHaveText('team 22 [ , right: ]'); + it('should not swallow errors', fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - const teamCmp = fixture.debugElement.childNodes[1].componentInstance; + router.resetConfig([{path: 'simple', component: SimpleCmp}]); - teamCmp.routerLink = ['/team/0']; - advance(fixture); - const anchor = fixture.debugElement.query(By.css('a')).nativeElement; - anchor.click(); - advance(fixture); - expect(fixture.nativeElement).toHaveText('team 0 [ , right: ]'); - expect(location.path()).toEqual('/team/22'); + router.navigateByUrl('/invalid'); + expect(() => advance(fixture)).toThrow(); - teamCmp.routerLink = ['/team/1']; - advance(fixture); - const button = fixture.debugElement.query(By.css('button')).nativeElement; - button.click(); - advance(fixture); - expect(fixture.nativeElement).toHaveText('team 1 [ , right: ]'); - expect(location.path()).toEqual('/team/22'); + router.navigateByUrl('/invalid2'); + expect(() => advance(fixture)).toThrow(); }))); - it('should support string router links', fakeAsync(inject([Router], (router: Router) => { + it('should not swallow errors from browser state update', async () => { + const routerEvents: Event[] = []; + TestBed.inject(Router).resetConfig([{path: '**', component: BlankCmp}]); + TestBed.inject(Router).events.subscribe((e) => { + routerEvents.push(e); + }); + spyOn(TestBed.inject(Location), 'go').and.callFake(() => { + throw new Error(); + }); + try { + await RouterTestingHarness.create('/abc123'); + } catch { + } + // Ensure the first event is the start and that we get to the ResolveEnd event. If this is not + // true, then NavigationError may have been triggered at a time we don't expect here. + expect(routerEvents[0]).toBeInstanceOf(NavigationStart); + expect(routerEvents[routerEvents.length - 2]).toBeInstanceOf(ResolveEnd); + + expect(routerEvents[routerEvents.length - 1]).toBeInstanceOf(NavigationError); + }); + + it('should replace state when path is equal to current path', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { const fixture = createRoot(router, RootCmp); router.resetConfig([{ path: 'team/:id', component: TeamCmp, children: - [{path: 'link', component: StringLinkCmp}, {path: 'simple', component: SimpleCmp}] + [{path: 'simple', component: SimpleCmp}, {path: 'user/:name', component: UserCmp}] }]); - router.navigateByUrl('/team/22/link'); + router.navigateByUrl('/team/33/simple'); advance(fixture); - expect(fixture.nativeElement).toHaveText('team 22 [ link, right: ]'); - const native = fixture.nativeElement.querySelector('a'); - expect(native.getAttribute('href')).toEqual('/team/33/simple'); - expect(native.getAttribute('target')).toEqual('_self'); - native.click(); + router.navigateByUrl('/team/22/user/victor'); advance(fixture); - expect(fixture.nativeElement).toHaveText('team 33 [ simple, right: ]'); - }))); + router.navigateByUrl('/team/22/user/victor'); + advance(fixture); - it('should not preserve query params and fragment by default', fakeAsync(() => { - @Component({ - selector: 'someRoot', - template: `Link` - }) - class RootCmpWithLink { - } + location.back(); + advance(fixture); + expect(location.path()).toEqual('/team/33/simple'); + }))); - TestBed.configureTestingModule({declarations: [RootCmpWithLink]}); - const router: Router = TestBed.inject(Router); + it('should handle componentless paths', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmpWithTwoOutlets); - const fixture = createRoot(router, RootCmpWithLink); + router.resetConfig([ + { + path: 'parent/:id', + children: [ + {path: 'simple', component: SimpleCmp}, + {path: 'user/:name', component: UserCmp, outlet: 'right'} + ] + }, + {path: 'user/:name', component: UserCmp} + ]); - router.resetConfig([{path: 'home', component: SimpleCmp}]); - const native = fixture.nativeElement.querySelector('a'); + // navigate to a componentless route + router.navigateByUrl('/parent/11/(simple//right:user/victor)'); + advance(fixture); + expect(location.path()).toEqual('/parent/11/(simple//right:user/victor)'); + expect(fixture.nativeElement).toHaveText('primary [simple] right [user victor]'); - router.navigateByUrl('/home?q=123#fragment'); + // navigate to the same route with different params (reuse) + router.navigateByUrl('/parent/22/(simple//right:user/fedor)'); advance(fixture); - expect(native.getAttribute('href')).toEqual('/home'); - })); + expect(location.path()).toEqual('/parent/22/(simple//right:user/fedor)'); + expect(fixture.nativeElement).toHaveText('primary [simple] right [user fedor]'); - it('should not throw when commands is null or undefined', fakeAsync(() => { - @Component({ - selector: 'someCmp', - template: ` - Link - - Link - - ` - }) - class CmpWithLink { - } + // navigate to a normal route (check deactivation) + router.navigateByUrl('/user/victor'); + advance(fixture); + expect(location.path()).toEqual('/user/victor'); + expect(fixture.nativeElement).toHaveText('primary [user victor] right []'); - TestBed.configureTestingModule({declarations: [CmpWithLink]}); - const router: Router = TestBed.inject(Router); + // navigate back to a componentless route + router.navigateByUrl('/parent/11/(simple//right:user/victor)'); + advance(fixture); + expect(location.path()).toEqual('/parent/11/(simple//right:user/victor)'); + expect(fixture.nativeElement).toHaveText('primary [simple] right [user victor]'); + }))); - let fixture: ComponentFixture = createRoot(router, CmpWithLink); - router.resetConfig([{path: 'home', component: SimpleCmp}]); - const anchors = fixture.nativeElement.querySelectorAll('a'); - const buttons = fixture.nativeElement.querySelectorAll('button'); - expect(() => anchors[0].click()).not.toThrow(); - expect(() => anchors[1].click()).not.toThrow(); - expect(() => buttons[0].click()).not.toThrow(); - expect(() => buttons[1].click()).not.toThrow(); - })); + it('should not deactivate aux routes when navigating from a componentless routes', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, TwoOutletsCmp); - it('should not throw when some command is null', fakeAsync(() => { - @Component({ - selector: 'someCmp', - template: - `Link` - }) - class CmpWithLink { - } + router.resetConfig([ + {path: 'simple', component: SimpleCmp}, + {path: 'componentless', children: [{path: 'simple', component: SimpleCmp}]}, + {path: 'user/:name', outlet: 'aux', component: UserCmp} + ]); - TestBed.configureTestingModule({declarations: [CmpWithLink]}); - const router: Router = TestBed.inject(Router); + router.navigateByUrl('/componentless/simple(aux:user/victor)'); + advance(fixture); + expect(location.path()).toEqual('/componentless/simple(aux:user/victor)'); + expect(fixture.nativeElement).toHaveText('[ simple, aux: user victor ]'); - expect(() => createRoot(router, CmpWithLink)).not.toThrow(); - })); + router.navigateByUrl('/simple(aux:user/victor)'); + advance(fixture); + expect(location.path()).toEqual('/simple(aux:user/victor)'); + expect(fixture.nativeElement).toHaveText('[ simple, aux: user victor ]'); + }))); - it('should not throw when some command is undefined', fakeAsync(() => { + it('should emit an event when an outlet gets activated', fakeAsync(() => { @Component({ - selector: 'someCmp', + selector: 'container', template: - `Link` + `` }) - class CmpWithLink { - } - - TestBed.configureTestingModule({declarations: [CmpWithLink]}); - const router: Router = TestBed.inject(Router); + class Container { + activations: any[] = []; + deactivations: any[] = []; - expect(() => createRoot(router, CmpWithLink)).not.toThrow(); - })); + recordActivate(component: any): void { + this.activations.push(component); + } - it('should update hrefs when query params or fragment change', fakeAsync(() => { - @Component({ - selector: 'someRoot', - template: - `Link` - }) - class RootCmpWithLink { + recordDeactivate(component: any): void { + this.deactivations.push(component); + } } - TestBed.configureTestingModule({declarations: [RootCmpWithLink]}); + + TestBed.configureTestingModule({declarations: [Container]}); + const router: Router = TestBed.inject(Router); - const fixture = createRoot(router, RootCmpWithLink); - router.resetConfig([{path: 'home', component: SimpleCmp}]); + const fixture = createRoot(router, Container); + const cmp = fixture.componentInstance; + + router.resetConfig( + [{path: 'blank', component: BlankCmp}, {path: 'simple', component: SimpleCmp}]); - const native = fixture.nativeElement.querySelector('a'); + cmp.activations = []; + cmp.deactivations = []; - router.navigateByUrl('/home?q=123'); + router.navigateByUrl('/blank'); advance(fixture); - expect(native.getAttribute('href')).toEqual('/home?q=123'); - router.navigateByUrl('/home?q=456'); - advance(fixture); - expect(native.getAttribute('href')).toEqual('/home?q=456'); + expect(cmp.activations.length).toEqual(1); + expect(cmp.activations[0] instanceof BlankCmp).toBe(true); - router.navigateByUrl('/home?q=456#1'); + router.navigateByUrl('/simple'); advance(fixture); - expect(native.getAttribute('href')).toEqual('/home?q=456#1'); - })); - it('should correctly use the preserve strategy', fakeAsync(() => { - @Component({ - selector: 'someRoot', - template: - `Link` - }) - class RootCmpWithLink { - } - TestBed.configureTestingModule({declarations: [RootCmpWithLink]}); - const router: Router = TestBed.inject(Router); - const fixture = createRoot(router, RootCmpWithLink); + expect(cmp.activations.length).toEqual(2); + expect(cmp.activations[1] instanceof SimpleCmp).toBe(true); + expect(cmp.deactivations.length).toEqual(1); + expect(cmp.deactivations[0] instanceof BlankCmp).toBe(true); + })); - router.resetConfig([{path: 'home', component: SimpleCmp}]); + it('should update url and router state before activating components', + fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - const native = fixture.nativeElement.querySelector('a'); + router.resetConfig([{path: 'cmp', component: ComponentRecordingRoutePathAndUrl}]); - router.navigateByUrl('/home?a=123'); + router.navigateByUrl('/cmp'); advance(fixture); - expect(native.getAttribute('href')).toEqual('/home?a=123'); - })); - it('should correctly use the merge strategy', fakeAsync(() => { - @Component({ - selector: 'someRoot', - template: - `Link` - }) - class RootCmpWithLink { - } - TestBed.configureTestingModule({declarations: [RootCmpWithLink]}); - const router: Router = TestBed.inject(Router); - const fixture = createRoot(router, RootCmpWithLink); - - router.resetConfig([{path: 'home', component: SimpleCmp}]); + const cmp: ComponentRecordingRoutePathAndUrl = + fixture.debugElement.children[1].componentInstance; - const native = fixture.nativeElement.querySelector('a'); + expect(cmp.url).toBe('/cmp'); + expect(cmp.path.length).toEqual(2); + }))); - router.navigateByUrl('/home?a=123&removeMe=123'); - advance(fixture); - expect(native.getAttribute('href')).toEqual('/home?a=123&q=456'); - })); - it('should support using links on non-a tags', fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: [ - {path: 'link', component: StringLinkButtonCmp}, {path: 'simple', component: SimpleCmp} - ] - }]); + describe('data', () => { + class ResolveSix { + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): number { + return 6; + } + } - router.navigateByUrl('/team/22/link'); - advance(fixture); - expect(fixture.nativeElement).toHaveText('team 22 [ link, right: ]'); + @Component({selector: 'nested-cmp', template: 'nested-cmp'}) + class NestedComponentWithData { + data: any = []; + constructor(private route: ActivatedRoute) { + route.data.forEach(d => this.data.push(d)); + } + } - const button = fixture.nativeElement.querySelector('button'); - expect(button.getAttribute('tabindex')).toEqual('0'); - button.click(); - advance(fixture); + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + {provide: 'resolveTwo', useValue: (a: any, b: any) => 2}, + {provide: 'resolveFour', useValue: (a: any, b: any) => 4}, + {provide: 'resolveSix', useClass: ResolveSix}, + {provide: 'resolveError', useValue: (a: any, b: any) => Promise.reject('error')}, + {provide: 'resolveNullError', useValue: (a: any, b: any) => Promise.reject(null)}, + {provide: 'resolveEmpty', useValue: (a: any, b: any) => EMPTY}, + {provide: 'numberOfUrlSegments', useValue: (a: any, b: any) => a.url.length}, + { + provide: 'overridingGuard', + useValue: (route: ActivatedRouteSnapshot) => { + route.data = {prop: 10}; + return true; + } + }, + ] + }); + }); - expect(fixture.nativeElement).toHaveText('team 33 [ simple, right: ]'); - }))); + it('should provide resolved data', fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmpWithTwoOutlets); - it('should support absolute router links', fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); + router.resetConfig([{ + path: 'parent/:id', + data: {one: 1}, + resolve: {two: 'resolveTwo'}, + children: [ + {path: '', data: {three: 3}, resolve: {four: 'resolveFour'}, component: RouteCmp}, + { + path: '', + data: {five: 5}, + resolve: {six: 'resolveSix'}, + component: RouteCmp, + outlet: 'right' + }, + ] + }]); - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: - [{path: 'link', component: AbsoluteLinkCmp}, {path: 'simple', component: SimpleCmp}] - }]); + router.navigateByUrl('/parent/1'); + advance(fixture); - router.navigateByUrl('/team/22/link'); - advance(fixture); - expect(fixture.nativeElement).toHaveText('team 22 [ link, right: ]'); + const primaryCmp = fixture.debugElement.children[1].componentInstance; + const rightCmp = fixture.debugElement.children[3].componentInstance; - const native = fixture.nativeElement.querySelector('a'); - expect(native.getAttribute('href')).toEqual('/team/33/simple'); - native.click(); - advance(fixture); + expect(primaryCmp.route.snapshot.data).toEqual({one: 1, two: 2, three: 3, four: 4}); + expect(rightCmp.route.snapshot.data).toEqual({one: 1, two: 2, five: 5, six: 6}); - expect(fixture.nativeElement).toHaveText('team 33 [ simple, right: ]'); - }))); + const primaryRecorded: any[] = []; + primaryCmp.route.data.forEach((rec: any) => primaryRecorded.push(rec)); - it('should support relative router links', fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); + const rightRecorded: any[] = []; + rightCmp.route.data.forEach((rec: any) => rightRecorded.push(rec)); - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: - [{path: 'link', component: RelativeLinkCmp}, {path: 'simple', component: SimpleCmp}] - }]); + router.navigateByUrl('/parent/2'); + advance(fixture); - router.navigateByUrl('/team/22/link'); - advance(fixture); - expect(fixture.nativeElement).toHaveText('team 22 [ link, right: ]'); + expect(primaryRecorded).toEqual([{one: 1, three: 3, two: 2, four: 4}]); + expect(rightRecorded).toEqual([{one: 1, five: 5, two: 2, six: 6}]); + }))); - const native = fixture.nativeElement.querySelector('a'); - expect(native.getAttribute('href')).toEqual('/team/22/simple'); - native.click(); - advance(fixture); + it('should handle errors', fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - expect(fixture.nativeElement).toHaveText('team 22 [ simple, right: ]'); - }))); + router.resetConfig( + [{path: 'simple', component: SimpleCmp, resolve: {error: 'resolveError'}}]); - it('should support top-level link', fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RelativeLinkInIfCmp); - advance(fixture); + const recordedEvents: any[] = []; + router.events.subscribe(e => e instanceof RouterEvent && recordedEvents.push(e)); - router.resetConfig( - [{path: 'simple', component: SimpleCmp}, {path: '', component: BlankCmp}]); + let e: any = null; + router.navigateByUrl('/simple')!.catch(error => e = error); + advance(fixture); - router.navigateByUrl('/'); - advance(fixture); - expect(fixture.nativeElement).toHaveText(''); - const cmp = fixture.componentInstance; + expectEvents(recordedEvents, [ + [NavigationStart, '/simple'], [RoutesRecognized, '/simple'], + [GuardsCheckStart, '/simple'], [GuardsCheckEnd, '/simple'], [ResolveStart, '/simple'], + [NavigationError, '/simple'] + ]); - cmp.show = true; - advance(fixture); + expect(e).toEqual('error'); + }))); - expect(fixture.nativeElement).toHaveText('link'); - const native = fixture.nativeElement.querySelector('a'); + it('should handle empty errors', fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - expect(native.getAttribute('href')).toEqual('/simple'); - native.click(); - advance(fixture); + router.resetConfig( + [{path: 'simple', component: SimpleCmp, resolve: {error: 'resolveNullError'}}]); - expect(fixture.nativeElement).toHaveText('linksimple'); - }))); + const recordedEvents: any[] = []; + router.events.subscribe(e => e instanceof RouterEvent && recordedEvents.push(e)); - it('should support query params and fragments', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + let e: any = 'some value'; + router.navigateByUrl('/simple').catch(error => e = error); + advance(fixture); - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: [ - {path: 'link', component: LinkWithQueryParamsAndFragment}, - {path: 'simple', component: SimpleCmp} - ] - }]); + expect(e).toEqual(null); + }))); - router.navigateByUrl('/team/22/link'); - advance(fixture); + it('should not navigate when all resolvers return empty result', + fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - const native = fixture.nativeElement.querySelector('a'); - expect(native.getAttribute('href')).toEqual('/team/22/simple?q=1#f'); - native.click(); - advance(fixture); + router.resetConfig([{ + path: 'simple', + component: SimpleCmp, + resolve: {e1: 'resolveEmpty', e2: 'resolveEmpty'} + }]); - expect(fixture.nativeElement).toHaveText('team 22 [ simple, right: ]'); + const recordedEvents: any[] = []; + router.events.subscribe(e => e instanceof RouterEvent && recordedEvents.push(e)); - expect(location.path(true)).toEqual('/team/22/simple?q=1#f'); - }))); + let e: any = null; + router.navigateByUrl('/simple').catch(error => e = error); + advance(fixture); - describe('should support history and state', () => { - let component: typeof LinkWithState|typeof DivLinkWithState; - it('for anchor elements', () => { - // Test logic in afterEach to reduce duplication - component = LinkWithState; - }); + expectEvents(recordedEvents, [ + [NavigationStart, '/simple'], + [RoutesRecognized, '/simple'], + [GuardsCheckStart, '/simple'], + [GuardsCheckEnd, '/simple'], + [ResolveStart, '/simple'], + [NavigationCancel, '/simple'], + ]); - it('for non-anchor elements', () => { - // Test logic in afterEach to reduce duplication - component = DivLinkWithState; - }); + expect((recordedEvents[recordedEvents.length - 1] as NavigationCancel).code) + .toBe(NavigationCancellationCode.NoDataFromResolver); - afterEach(fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + expect(e).toEqual(null); + }))); - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: [{path: 'link', component}, {path: 'simple', component: SimpleCmp}] - }]); + it('should not navigate when at least one resolver returns empty result', + fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/team/22/link'); - advance(fixture); + router.resetConfig([ + {path: 'simple', component: SimpleCmp, resolve: {e1: 'resolveTwo', e2: 'resolveEmpty'}} + ]); - const native = fixture.nativeElement.querySelector('#link'); - native.click(); - advance(fixture); + const recordedEvents: any[] = []; + router.events.subscribe(e => e instanceof RouterEvent && recordedEvents.push(e)); - expect(fixture.nativeElement).toHaveText('team 22 [ simple, right: ]'); + let e: any = null; + router.navigateByUrl('/simple').catch(error => e = error); + advance(fixture); - // Check the history entry - expect(location.getState()).toEqual({foo: 'bar', navigationId: 3}); - }))); - }); + expectEvents(recordedEvents, [ + [NavigationStart, '/simple'], + [RoutesRecognized, '/simple'], + [GuardsCheckStart, '/simple'], + [GuardsCheckEnd, '/simple'], + [ResolveStart, '/simple'], + [NavigationCancel, '/simple'], + ]); - it('should set href on area elements', fakeAsync(() => { - @Component({ - selector: 'someRoot', - template: `` - }) - class RootCmpWithArea { - } + expect(e).toEqual(null); + }))); - TestBed.configureTestingModule({declarations: [RootCmpWithArea]}); - const router: Router = TestBed.inject(Router); + it('should not navigate when all resolvers for a child route from forChild() returns empty result', + fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - const fixture = createRoot(router, RootCmpWithArea); + @Component({selector: 'lazy-cmp', template: 'lazy-loaded-1'}) + class LazyComponent1 { + } - router.resetConfig([{path: 'home', component: SimpleCmp}]); - const native = fixture.nativeElement.querySelector('area'); - expect(native.getAttribute('href')).toEqual('/home'); - })); - }); + @NgModule({ + declarations: [LazyComponent1], + imports: [ + RouterModule.forChild([{ + path: 'loaded', + component: LazyComponent1, + resolve: {e1: 'resolveEmpty', e2: 'resolveEmpty'} + }]), + ], + }) + class LoadedModule { + } - describe('redirects', () => { - it('should work', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); - router.resetConfig([ - {path: 'old/team/:id', redirectTo: 'team/:id'}, {path: 'team/:id', component: TeamCmp} - ]); + const recordedEvents: any[] = []; + router.events.subscribe(e => e instanceof RouterEvent && recordedEvents.push(e)); - router.navigateByUrl('old/team/22'); - advance(fixture); + let e: any = null; + router.navigateByUrl('lazy/loaded').catch(error => e = error); + advance(fixture); - expect(location.path()).toEqual('/team/22'); - }))); + expectEvents(recordedEvents, [ + [NavigationStart, '/lazy/loaded'], + [RoutesRecognized, '/lazy/loaded'], + [GuardsCheckStart, '/lazy/loaded'], + [GuardsCheckEnd, '/lazy/loaded'], + [ResolveStart, '/lazy/loaded'], + [NavigationCancel, '/lazy/loaded'], + ]); - it('can redirect from componentless named outlets', fakeAsync(() => { - const router = TestBed.inject(Router); - const fixture = createRoot(router, RootCmp); + expect(e).toEqual(null); + }))); - router.resetConfig([ - {path: 'main', outlet: 'aux', component: BlankCmp}, - {path: '', pathMatch: 'full', outlet: 'aux', redirectTo: 'main'}, - ]); + it('should not navigate when at least one resolver for a child route from forChild() returns empty result', + fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - router.navigateByUrl(''); - advance(fixture); + @Component({selector: 'lazy-cmp', template: 'lazy-loaded-1'}) + class LazyComponent1 { + } - expect(TestBed.inject(Location).path()).toEqual('/(aux:main)'); - })); + @NgModule({ + declarations: [LazyComponent1], + imports: [ + RouterModule.forChild([{ + path: 'loaded', + component: LazyComponent1, + resolve: {e1: 'resolveTwo', e2: 'resolveEmpty'} + }]), + ], + }) + class LoadedModule { + } - it('should update Navigation object after redirects are applied', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); - let initialUrl, afterRedirectUrl; + router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); - router.resetConfig([ - {path: 'old/team/:id', redirectTo: 'team/:id'}, {path: 'team/:id', component: TeamCmp} - ]); + const recordedEvents: any[] = []; + router.events.subscribe(e => e instanceof RouterEvent && recordedEvents.push(e)); - router.events.subscribe(e => { - if (e instanceof NavigationStart) { - const navigation = router.getCurrentNavigation(); - initialUrl = navigation && navigation.finalUrl; - } - if (e instanceof RoutesRecognized) { - const navigation = router.getCurrentNavigation(); - afterRedirectUrl = navigation && navigation.finalUrl; - } - }); + let e: any = null; + router.navigateByUrl('lazy/loaded').catch(error => e = error); + advance(fixture); - router.navigateByUrl('old/team/22'); - advance(fixture); + expectEvents(recordedEvents, [ + [NavigationStart, '/lazy/loaded'], + [RoutesRecognized, '/lazy/loaded'], + [GuardsCheckStart, '/lazy/loaded'], + [GuardsCheckEnd, '/lazy/loaded'], + [ResolveStart, '/lazy/loaded'], + [NavigationCancel, '/lazy/loaded'], + ]); - expect(initialUrl).toBeUndefined(); - expect(router.serializeUrl(afterRedirectUrl as any)).toBe('/team/22'); - }))); + expect(e).toEqual(null); + }))); - it('should not break the back button when trigger by location change', fakeAsync(() => { - TestBed.configureTestingModule( - {providers: [{provide: LocationStrategy, useClass: HashLocationStrategy}]}); - const router = TestBed.inject(Router); - const location = TestBed.inject(Location); - const fixture = TestBed.createComponent(RootCmp); - advance(fixture); - router.resetConfig([ - {path: 'initial', component: BlankCmp}, {path: 'old/team/:id', redirectTo: 'team/:id'}, - {path: 'team/:id', component: TeamCmp} - ]); + it('should include target snapshot in NavigationError when resolver throws', async () => { + const router = TestBed.inject(Router); + const errorMessage = 'throwing resolver'; + @Injectable({providedIn: 'root'}) + class ThrowingResolver { + resolve() { + throw new Error(errorMessage); + } + } - location.go('initial'); - location.historyGo(0); - location.go('old/team/22'); - location.historyGo(0); + let caughtError: NavigationError|undefined; + router.events.subscribe(e => { + if (e instanceof NavigationError) { + caughtError = e; + } + }); + router.resetConfig( + [{path: 'throwing', resolve: {thrower: ThrowingResolver}, component: BlankCmp}]); + try { + await router.navigateByUrl('/throwing'); + fail('navigation should throw'); + } catch (e: unknown) { + expect((e as Error).message).toEqual(errorMessage); + } - // initial navigation - router.initialNavigation(); - advance(fixture); - expect(location.path()).toEqual('/team/22'); + expect(caughtError).toBeDefined(); + expect(caughtError?.target).toBeDefined(); + }); - location.back(); - advance(fixture); - expect(location.path()).toEqual('/initial'); + it('should preserve resolved data', fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - // location change - location.go('/old/team/33'); - location.historyGo(0); + router.resetConfig([{ + path: 'parent', + resolve: {two: 'resolveTwo'}, + children: [ + {path: 'child1', component: CollectParamsCmp}, + {path: 'child2', component: CollectParamsCmp} + ] + }]); - advance(fixture); - expect(location.path()).toEqual('/team/33'); + router.navigateByUrl('/parent/child1'); + advance(fixture); - location.back(); - advance(fixture); - expect(location.path()).toEqual('/initial'); - })); - }); - describe('guards', () => { - describe('CanActivate', () => { - describe('should not activate a route when CanActivate returns false', () => { - beforeEach(() => { - TestBed.configureTestingModule( - {providers: [{provide: 'alwaysFalse', useValue: (a: any, b: any) => false}]}); - }); + router.navigateByUrl('/parent/child2'); + advance(fixture); - it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + const cmp: CollectParamsCmp = fixture.debugElement.children[1].componentInstance; + expect(cmp.route.snapshot.data).toEqual({two: 2}); + }))); - const recordedEvents: Event[] = []; - router.events.forEach(e => recordedEvents.push(e)); + it('should override route static data with resolved data', + fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - router.resetConfig( - [{path: 'team/:id', component: TeamCmp, canActivate: ['alwaysFalse']}]); + router.resetConfig([{ + path: '', + component: NestedComponentWithData, + resolve: {prop: 'resolveTwo'}, + data: {prop: 'static'}, + }]); - router.navigateByUrl('/team/22'); - advance(fixture); + router.navigateByUrl('/'); + advance(fixture); + const cmp = fixture.debugElement.children[1].componentInstance; - 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); - }))); - }); + expect(cmp.data).toEqual([{prop: 2}]); + }))); - describe( - 'should not activate a route when CanActivate returns false (componentless route)', - () => { - beforeEach(() => { - TestBed.configureTestingModule( - {providers: [{provide: 'alwaysFalse', useValue: (a: any, b: any) => false}]}); - }); + it('should correctly override inherited route static data with resolved data', + fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - it('works', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + router.resetConfig([{ + path: 'a', + component: WrapperCmp, + resolve: {prop2: 'resolveTwo'}, + data: {prop: 'wrapper-a'}, + children: [ + // will inherit data from this child route because it has `path` and its parent has + // component + { + path: 'b', + data: {prop: 'nested-b'}, + resolve: {prop3: 'resolveFour'}, + children: [ + { + path: 'c', + children: + [{path: '', component: NestedComponentWithData, data: {prop3: 'nested'}}] + }, + ] + }, + ], + }]); - router.resetConfig([{ - path: 'parent', - canActivate: ['alwaysFalse'], - children: [{path: 'team/:id', component: TeamCmp}] - }]); + router.navigateByUrl('/a/b/c'); + advance(fixture); - router.navigateByUrl('parent/team/22'); - advance(fixture); + const pInj = + fixture.debugElement.queryAll(By.directive(NestedComponentWithData))[0].injector!; + const cmp = pInj.get(NestedComponentWithData); + expect(cmp.data).toEqual([{prop: 'nested-b', prop3: 'nested'}]); + }))); - expect(location.path()).toEqual(''); - }))); - }); + it('should not override inherited resolved data with inherited static data', + fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - describe('should activate a route when CanActivate returns true', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [{ - provide: 'alwaysTrue', - useValue: (a: ActivatedRouteSnapshot, s: RouterStateSnapshot) => true - }] - }); - }); + router.resetConfig([{ + path: 'a', + component: WrapperCmp, + resolve: {prop2: 'resolveTwo'}, + data: {prop: 'wrapper-a'}, + children: [ + // will inherit data from this child route because it has `path` and its parent has + // component + { + path: 'b', + data: {prop2: 'parent-b', prop: 'parent-b'}, + children: [ + { + path: 'c', + resolve: {prop2: 'resolveFour'}, + children: [ + { + path: '', + component: NestedComponentWithData, + data: {prop: 'nested-d'}, + }, + ] + }, + ] + }, + ], + }]); - it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + router.navigateByUrl('/a/b/c'); + advance(fixture); - router.resetConfig( - [{path: 'team/:id', component: TeamCmp, canActivate: ['alwaysTrue']}]); + const pInj = + fixture.debugElement.queryAll(By.directive(NestedComponentWithData))[0].injector!; + const cmp = pInj.get(NestedComponentWithData); + expect(cmp.data).toEqual([{prop: 'nested-d', prop2: 4}]); + }))); - router.navigateByUrl('/team/22'); - advance(fixture); - expect(location.path()).toEqual('/team/22'); - }))); - }); + it('should not override nested route static data when both are using resolvers', + fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - describe('should work when given a class', () => { - class AlwaysTrue { - canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean { - return true; - } - } + router.resetConfig([ + { + path: 'child', + component: WrapperCmp, + resolve: {prop: 'resolveTwo'}, + children: [{ + path: '', + pathMatch: 'full', + component: NestedComponentWithData, + resolve: {prop: 'resolveFour'} + }] + }, + ]); - beforeEach(() => { - TestBed.configureTestingModule({providers: [AlwaysTrue]}); - }); + router.navigateByUrl('/child'); + advance(fixture); - it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + const pInj = fixture.debugElement.query(By.directive(NestedComponentWithData)).injector!; + const cmp = pInj.get(NestedComponentWithData); + expect(cmp.data).toEqual([{prop: 4}]); + }))); - router.resetConfig( - [{path: 'team/:id', component: TeamCmp, canActivate: [AlwaysTrue]}]); + it('should not override child route\'s static data when both are using static data', + fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/team/22'); - advance(fixture); + router.resetConfig([ + { + path: 'child', + component: WrapperCmp, + data: {prop: 'wrapper'}, + children: [{ + path: '', + pathMatch: 'full', + component: NestedComponentWithData, + data: {prop: 'inner'} + }] + }, + ]); - expect(location.path()).toEqual('/team/22'); - }))); - }); + router.navigateByUrl('/child'); + advance(fixture); - describe('should work when returns an observable', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [{ - provide: 'CanActivate', - useValue: (a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { - return new Observable((observer) => { - observer.next(false); - }); - } - }] - }); - }); + const pInj = fixture.debugElement.query(By.directive(NestedComponentWithData)).injector!; + const cmp = pInj.get(NestedComponentWithData); + expect(cmp.data).toEqual([{prop: 'inner'}]); + }))); + it('should not override child route\'s static data when wrapper is using resolved data and the child route static data', + fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + router.resetConfig([ + { + path: 'nested', + component: WrapperCmp, + resolve: {prop: 'resolveTwo', prop2: 'resolveSix'}, + data: {prop3: 'wrapper-static', prop4: 'another-static'}, + children: [{ + path: '', + pathMatch: 'full', + component: NestedComponentWithData, + data: {prop: 'nested', prop4: 'nested-static'} + }] + }, + ]); - router.resetConfig( - [{path: 'team/:id', component: TeamCmp, canActivate: ['CanActivate']}]); + router.navigateByUrl('/nested'); + advance(fixture); - router.navigateByUrl('/team/22'); - advance(fixture); - expect(location.path()).toEqual(''); - }))); - }); + const pInj = fixture.debugElement.query(By.directive(NestedComponentWithData)).injector!; + const cmp = pInj.get(NestedComponentWithData); + // Issue 34361 - `prop` should contain value defined in `data` object from the nested + // route. + expect(cmp.data).toEqual( + [{prop: 'nested', prop2: 6, prop3: 'wrapper-static', prop4: 'nested-static'}]); + }))); - describe('should work when returns a promise', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [{ - provide: 'CanActivate', - useValue: (a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { - if (a.params['id'] === '22') { - return Promise.resolve(true); - } else { - return Promise.resolve(false); - } - } - }] - }); - }); + it('should allow guards alter data resolved by routes', + fakeAsync(inject([Router], (router: Router) => { + // This is not documented or recommended behavior but is here to prevent unexpected + // regressions. This behavior isn't necessary 'by design' but it was discovered during a + // refactor that some teams depend on it. + const fixture = createRoot(router, RootCmp); + + router.resetConfig([ + { + path: 'route', + component: NestedComponentWithData, + canActivate: ['overridingGuard'], + }, + ]); + router.navigateByUrl('/route'); + advance(fixture); - it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + const pInj = fixture.debugElement.query(By.directive(NestedComponentWithData)).injector!; + const cmp = pInj.get(NestedComponentWithData); + expect(cmp.data).toEqual([{prop: 10}]); + }))); - router.resetConfig( - [{path: 'team/:id', component: TeamCmp, canActivate: ['CanActivate']}]); + it('should rerun resolvers when the urls segments of a wildcard route change', + fakeAsync(inject([Router, Location], (router: Router) => { + const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/team/22'); - advance(fixture); - expect(location.path()).toEqual('/team/22'); + router.resetConfig([{ + path: '**', + component: CollectParamsCmp, + resolve: {numberOfUrlSegments: 'numberOfUrlSegments'} + }]); - router.navigateByUrl('/team/33'); - advance(fixture); - expect(location.path()).toEqual('/team/22'); - }))); - }); + router.navigateByUrl('/one/two'); + advance(fixture); + const cmp = fixture.debugElement.children[1].componentInstance; + + expect(cmp.route.snapshot.data).toEqual({numberOfUrlSegments: 2}); + + router.navigateByUrl('/one/two/three'); + advance(fixture); + + expect(cmp.route.snapshot.data).toEqual({numberOfUrlSegments: 3}); + }))); + + describe('should run resolvers for the same route concurrently', () => { + let log: string[]; + let observer: Observer; - describe('should reset the location when cancelling a navigation', () => { beforeEach(() => { + log = []; TestBed.configureTestingModule({ providers: [ { - provide: 'alwaysFalse', - useValue: (a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { - return false; + provide: 'resolver1', + useValue: () => { + const obs$ = new Observable((obs) => { + observer = obs; + return () => {}; + }); + return obs$.pipe(map(() => log.push('resolver1'))); + } + }, + { + provide: 'resolver2', + useValue: () => { + return of(null).pipe(map(() => { + log.push('resolver2'); + observer.next(null); + observer.complete(); + })); } }, - {provide: LocationStrategy, useClass: HashLocationStrategy} ] }); }); - it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + it('works', fakeAsync(inject([Router], (router: Router) => { const fixture = createRoot(router, RootCmp); - router.resetConfig([ - {path: 'one', component: SimpleCmp}, - {path: 'two', component: SimpleCmp, canActivate: ['alwaysFalse']} - ]); + router.resetConfig([{ + path: 'a', + resolve: { + one: 'resolver1', + two: 'resolver2', + }, + component: SimpleCmp + }]); - router.navigateByUrl('/one'); + router.navigateByUrl('/a'); advance(fixture); - expect(location.path()).toEqual('/one'); - location.go('/two'); - location.historyGo(0); - advance(fixture); - expect(location.path()).toEqual('/one'); + expect(log).toEqual(['resolver2', 'resolver1']); }))); }); - describe('should redirect to / when guard returns false', () => { - beforeEach(() => TestBed.configureTestingModule({ - providers: [{ - provide: 'returnFalseAndNavigate', - useFactory: (router: Router) => () => { - router.navigate(['/']); - return false; - }, - deps: [Router] - }] - })); + it('can resolve symbol keys', fakeAsync(() => { + const router = TestBed.inject(Router); + const fixture = createRoot(router, RootCmp); + const symbolKey = Symbol('key'); - it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - router.resetConfig([ - { - path: '', - component: SimpleCmp, - }, - {path: 'one', component: RouteCmp, canActivate: ['returnFalseAndNavigate']} - ]); + router.resetConfig( + [{path: 'simple', component: SimpleCmp, resolve: {[symbolKey]: 'resolveFour'}}]); - const fixture = TestBed.createComponent(RootCmp); - router.navigateByUrl('/one'); - advance(fixture); - expect(location.path()).toEqual(''); - expect(fixture.nativeElement).toHaveText('simple'); - }))); - }); + router.navigateByUrl('/simple'); + advance(fixture); - describe('should redirect when guard returns UrlTree', () => { - beforeEach(() => TestBed.configureTestingModule({ - providers: [ - { - provide: 'returnUrlTree', - useFactory: (router: Router) => () => { - return router.parseUrl('/redirected'); - }, - deps: [Router] - }, - { - provide: 'returnRootUrlTree', - useFactory: (router: Router) => () => { - return router.parseUrl('/'); - }, - deps: [Router] - } - ] - })); + expect(router.routerState.root.snapshot.firstChild!.data[symbolKey]).toEqual(4); + })); - it('works', fakeAsync(inject([Router, Location], (router: Router, location: 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: ['returnUrlTree']}, - {path: 'redirected', component: SimpleCmp} - ]); + it('should allow resolvers as pure functions', fakeAsync(() => { + const router = TestBed.inject(Router); + const fixture = createRoot(router, RootCmp); + const user = Symbol('user'); - const fixture = TestBed.createComponent(RootCmp); - router.navigateByUrl('/one'); + const userResolver: ResolveFn = (route: ActivatedRouteSnapshot) => + route.params['user']; + router.resetConfig( + [{path: ':user', component: SimpleCmp, resolve: {[user]: userResolver}}]); - advance(fixture); + router.navigateByUrl('/atscott'); + 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'], - ]); - }))); + expect(router.routerState.root.snapshot.firstChild!.data[user]).toEqual('atscott'); + })); - it('works with root url', - fakeAsync(inject([Router, Location], (router: Router, location: 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: ['returnRootUrlTree']} - ]); + it('should allow DI in resolvers as pure functions', fakeAsync(() => { + const router = TestBed.inject(Router); + const fixture = createRoot(router, RootCmp); + const user = Symbol('user'); - const fixture = TestBed.createComponent(RootCmp); - router.navigateByUrl('/one'); + @Injectable({providedIn: 'root'}) + class LoginState { + user = 'atscott'; + } - advance(fixture); + router.resetConfig([{ + path: '**', + component: SimpleCmp, + resolve: { + [user]: () => coreInject(LoginState).user, + }, + }]); - 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, '/'], - ]); - }))); + router.navigateByUrl('/'); + advance(fixture); - it('replaces URL when URL is updated eagerly so back button can still work', - fakeAsync(() => { - 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: ['returnUrlTree']}, - {path: 'redirected', component: SimpleCmp} - ]); - createRoot(router, RootCmp); - router.navigateByUrl('/one'); - const urlChanges: string[] = []; - location.onUrlChange((change) => { - urlChanges.push(change); - }); + expect(router.routerState.root.snapshot.firstChild!.data[user]).toEqual('atscott'); + })); + }); - tick(); + describe('router links', () => { + it('should support skipping location update for anchor router links', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = TestBed.createComponent(RootCmp); + advance(fixture); - expect(location.path()).toEqual('/redirected'); - expect(urlChanges).toEqual(['/one', '/redirected']); - location.back(); - tick(); - expect(location.path()).toEqual(''); - })); + router.resetConfig([{path: 'team/:id', component: TeamCmp}]); - it('should resolve navigateByUrl promise after redirect finishes', fakeAsync(() => { - 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: ['returnUrlTree']}, - {path: 'redirected', component: SimpleCmp} - ]); - const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/one').then(v => { - resolvedPath = location.path(); - }); + router.navigateByUrl('/team/22'); + advance(fixture); + expect(location.path()).toEqual('/team/22'); + expect(fixture.nativeElement).toHaveText('team 22 [ , right: ]'); - tick(); - expect(resolvedPath).toBe('/redirected'); - })); - }); + const teamCmp = fixture.debugElement.childNodes[1].componentInstance; - describe('runGuardsAndResolvers', () => { - let guardRunCount = 0; - let resolverRunCount = 0; + teamCmp.routerLink = ['/team/0']; + advance(fixture); + const anchor = fixture.debugElement.query(By.css('a')).nativeElement; + anchor.click(); + advance(fixture); + expect(fixture.nativeElement).toHaveText('team 0 [ , right: ]'); + expect(location.path()).toEqual('/team/22'); - beforeEach(() => { - guardRunCount = 0; - resolverRunCount = 0; - TestBed.configureTestingModule({ - providers: [ - { - provide: 'guard', - useValue: () => { - guardRunCount++; - return true; - } - }, - {provide: 'resolver', useValue: () => resolverRunCount++} - ] - }); - }); + teamCmp.routerLink = ['/team/1']; + advance(fixture); + const button = fixture.debugElement.query(By.css('button')).nativeElement; + button.click(); + advance(fixture); + expect(fixture.nativeElement).toHaveText('team 1 [ , right: ]'); + expect(location.path()).toEqual('/team/22'); + }))); - function configureRouter(router: Router, runGuardsAndResolvers: RunGuardsAndResolvers): - ComponentFixture { - const fixture = createRoot(router, RootCmpWithTwoOutlets); + it('should support string router links', fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - router.resetConfig([ - { - path: 'a', - runGuardsAndResolvers, - component: RouteCmp, - canActivate: ['guard'], - resolve: {data: 'resolver'} - }, - {path: 'b', component: SimpleCmp, outlet: 'right'}, { - path: 'c/:param', - runGuardsAndResolvers, - component: RouteCmp, - canActivate: ['guard'], - resolve: {data: 'resolver'} - }, - { - path: 'd/:param', - component: WrapperCmp, - runGuardsAndResolvers, - children: [ - { - path: 'e/:param', - component: SimpleCmp, - canActivate: ['guard'], - resolve: {data: 'resolver'}, - }, - ] - }, - { - path: 'throwing', - runGuardsAndResolvers, - component: ThrowingCmp, - canActivate: ['guard'], - resolve: {data: 'resolver'} - } - ]); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: + [{path: 'link', component: StringLinkCmp}, {path: 'simple', component: SimpleCmp}] + }]); - router.navigateByUrl('/a'); - advance(fixture); - return fixture; - } + router.navigateByUrl('/team/22/link'); + advance(fixture); + expect(fixture.nativeElement).toHaveText('team 22 [ link, right: ]'); + const native = fixture.nativeElement.querySelector('a'); + expect(native.getAttribute('href')).toEqual('/team/33/simple'); + expect(native.getAttribute('target')).toEqual('_self'); + native.click(); + advance(fixture); - it('should rerun guards and resolvers when params change', - fakeAsync(inject([Router], (router: Router) => { - const fixture = configureRouter(router, 'paramsChange'); + expect(fixture.nativeElement).toHaveText('team 33 [ simple, right: ]'); + }))); - const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; - const recordedData: Data[] = []; - cmp.route.data.subscribe((data) => recordedData.push(data)); + it('should not preserve query params and fragment by default', fakeAsync(() => { + @Component({ + selector: 'someRoot', + template: `Link` + }) + class RootCmpWithLink { + } - expect(guardRunCount).toEqual(1); - expect(recordedData).toEqual([{data: 0}]); + TestBed.configureTestingModule({declarations: [RootCmpWithLink]}); + const router: Router = TestBed.inject(Router); - router.navigateByUrl('/a;p=1'); - advance(fixture); - expect(guardRunCount).toEqual(2); - expect(recordedData).toEqual([{data: 0}, {data: 1}]); + const fixture = createRoot(router, RootCmpWithLink); - router.navigateByUrl('/a;p=2'); - advance(fixture); - expect(guardRunCount).toEqual(3); - expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]); + router.resetConfig([{path: 'home', component: SimpleCmp}]); - router.navigateByUrl('/a;p=2?q=1'); - advance(fixture); - expect(guardRunCount).toEqual(3); - expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]); - }))); + const native = fixture.nativeElement.querySelector('a'); - it('should rerun guards and resolvers when query params change', - fakeAsync(inject([Router], (router: Router) => { - const fixture = configureRouter(router, 'paramsOrQueryParamsChange'); + router.navigateByUrl('/home?q=123#fragment'); + advance(fixture); + expect(native.getAttribute('href')).toEqual('/home'); + })); - const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; - const recordedData: Data[] = []; - cmp.route.data.subscribe((data) => recordedData.push(data)); + it('should not throw when commands is null or undefined', fakeAsync(() => { + @Component({ + selector: 'someCmp', + template: ` + Link + + Link + + ` + }) + class CmpWithLink { + } - expect(guardRunCount).toEqual(1); - expect(recordedData).toEqual([{data: 0}]); + TestBed.configureTestingModule({declarations: [CmpWithLink]}); + const router: Router = TestBed.inject(Router); + + let fixture: ComponentFixture = createRoot(router, CmpWithLink); + router.resetConfig([{path: 'home', component: SimpleCmp}]); + const anchors = fixture.nativeElement.querySelectorAll('a'); + const buttons = fixture.nativeElement.querySelectorAll('button'); + expect(() => anchors[0].click()).not.toThrow(); + expect(() => anchors[1].click()).not.toThrow(); + expect(() => buttons[0].click()).not.toThrow(); + expect(() => buttons[1].click()).not.toThrow(); + })); - router.navigateByUrl('/a;p=1'); - advance(fixture); - expect(guardRunCount).toEqual(2); - expect(recordedData).toEqual([{data: 0}, {data: 1}]); + it('should not throw when some command is null', fakeAsync(() => { + @Component({ + selector: 'someCmp', + template: + `Link` + }) + class CmpWithLink { + } - router.navigateByUrl('/a;p=2'); - advance(fixture); - expect(guardRunCount).toEqual(3); - expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]); + TestBed.configureTestingModule({declarations: [CmpWithLink]}); + const router: Router = TestBed.inject(Router); - router.navigateByUrl('/a;p=2?q=1'); - advance(fixture); - expect(guardRunCount).toEqual(4); - expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}, {data: 3}]); + expect(() => createRoot(router, CmpWithLink)).not.toThrow(); + })); - router.navigateByUrl('/a;p=2(right:b)?q=1'); - advance(fixture); - expect(guardRunCount).toEqual(4); - expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}, {data: 3}]); - }))); + it('should not throw when some command is undefined', fakeAsync(() => { + @Component({ + selector: 'someCmp', + template: + `Link` + }) + class CmpWithLink { + } - it('should always rerun guards and resolvers', - fakeAsync(inject([Router], (router: Router) => { - const fixture = configureRouter(router, 'always'); + TestBed.configureTestingModule({declarations: [CmpWithLink]}); + const router: Router = TestBed.inject(Router); - const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; - const recordedData: Data[] = []; - cmp.route.data.subscribe((data) => recordedData.push(data)); + expect(() => createRoot(router, CmpWithLink)).not.toThrow(); + })); - expect(guardRunCount).toEqual(1); - expect(recordedData).toEqual([{data: 0}]); + it('should update hrefs when query params or fragment change', fakeAsync(() => { + @Component({ + selector: 'someRoot', + template: + `Link` + }) + class RootCmpWithLink { + } + TestBed.configureTestingModule({declarations: [RootCmpWithLink]}); + const router: Router = TestBed.inject(Router); + const fixture = createRoot(router, RootCmpWithLink); - router.navigateByUrl('/a;p=1'); - advance(fixture); - expect(guardRunCount).toEqual(2); - expect(recordedData).toEqual([{data: 0}, {data: 1}]); + router.resetConfig([{path: 'home', component: SimpleCmp}]); - router.navigateByUrl('/a;p=2'); - advance(fixture); - expect(guardRunCount).toEqual(3); - expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]); + const native = fixture.nativeElement.querySelector('a'); - router.navigateByUrl('/a;p=2?q=1'); - advance(fixture); - expect(guardRunCount).toEqual(4); - expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}, {data: 3}]); + router.navigateByUrl('/home?q=123'); + advance(fixture); + expect(native.getAttribute('href')).toEqual('/home?q=123'); - router.navigateByUrl('/a;p=2(right:b)?q=1'); - advance(fixture); - expect(guardRunCount).toEqual(5); - expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}, {data: 3}, {data: 4}]); + router.navigateByUrl('/home?q=456'); + advance(fixture); + expect(native.getAttribute('href')).toEqual('/home?q=456'); - // Issue #39030, always running guards and resolvers should not throw - // when navigating away from a component with a throwing constructor. - expect(() => { - router.navigateByUrl('/throwing').catch(() => {}); - advance(fixture); - router.navigateByUrl('/a;p=1'); - advance(fixture); - }).not.toThrow(); - }))); + router.navigateByUrl('/home?q=456#1'); + advance(fixture); + expect(native.getAttribute('href')).toEqual('/home?q=456#1'); + })); - it('should rerun rerun guards and resolvers when path params change', - fakeAsync(inject([Router], (router: Router) => { - const fixture = configureRouter(router, 'pathParamsChange'); + it('should correctly use the preserve strategy', fakeAsync(() => { + @Component({ + selector: 'someRoot', + template: + `Link` + }) + class RootCmpWithLink { + } + TestBed.configureTestingModule({declarations: [RootCmpWithLink]}); + const router: Router = TestBed.inject(Router); + const fixture = createRoot(router, RootCmpWithLink); - const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; - const recordedData: Data[] = []; - cmp.route.data.subscribe((data) => recordedData.push(data)); + router.resetConfig([{path: 'home', component: SimpleCmp}]); - // First navigation has already run - expect(guardRunCount).toEqual(1); - expect(recordedData).toEqual([{data: 0}]); + const native = fixture.nativeElement.querySelector('a'); - // Changing any optional params will not result in running guards or resolvers - router.navigateByUrl('/a;p=1'); - advance(fixture); - expect(guardRunCount).toEqual(1); - expect(recordedData).toEqual([{data: 0}]); + router.navigateByUrl('/home?a=123'); + advance(fixture); + expect(native.getAttribute('href')).toEqual('/home?a=123'); + })); - router.navigateByUrl('/a;p=2'); - advance(fixture); - expect(guardRunCount).toEqual(1); - expect(recordedData).toEqual([{data: 0}]); + it('should correctly use the merge strategy', fakeAsync(() => { + @Component({ + selector: 'someRoot', + template: + `Link` + }) + class RootCmpWithLink { + } + TestBed.configureTestingModule({declarations: [RootCmpWithLink]}); + const router: Router = TestBed.inject(Router); + const fixture = createRoot(router, RootCmpWithLink); - router.navigateByUrl('/a;p=2?q=1'); - advance(fixture); - expect(guardRunCount).toEqual(1); - expect(recordedData).toEqual([{data: 0}]); + router.resetConfig([{path: 'home', component: SimpleCmp}]); - router.navigateByUrl('/a;p=2(right:b)?q=1'); - advance(fixture); - expect(guardRunCount).toEqual(1); - expect(recordedData).toEqual([{data: 0}]); + const native = fixture.nativeElement.querySelector('a'); - // Change to new route with path param should run guards and resolvers - router.navigateByUrl('/c/paramValue'); - advance(fixture); + router.navigateByUrl('/home?a=123&removeMe=123'); + advance(fixture); + expect(native.getAttribute('href')).toEqual('/home?a=123&q=456'); + })); - expect(guardRunCount).toEqual(2); + it('should support using links on non-a tags', + fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - // Modifying a path param should run guards and resolvers - router.navigateByUrl('/c/paramValueChanged'); - advance(fixture); - expect(guardRunCount).toEqual(3); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [ + {path: 'link', component: StringLinkButtonCmp}, + {path: 'simple', component: SimpleCmp} + ] + }]); - // Adding optional params should not cause guards/resolvers to run - router.navigateByUrl('/c/paramValueChanged;p=1?q=2'); - advance(fixture); - expect(guardRunCount).toEqual(3); - }))); + router.navigateByUrl('/team/22/link'); + advance(fixture); + expect(fixture.nativeElement).toHaveText('team 22 [ link, right: ]'); - it('should rerun when a parent segment changes', - fakeAsync(inject([Router], (router: Router) => { - const fixture = configureRouter(router, 'pathParamsChange'); + const button = fixture.nativeElement.querySelector('button'); + expect(button.getAttribute('tabindex')).toEqual('0'); + button.click(); + advance(fixture); - const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; + expect(fixture.nativeElement).toHaveText('team 33 [ simple, right: ]'); + }))); - // Land on an initial page - router.navigateByUrl('/d/1;dd=11/e/2;dd=22'); - advance(fixture); + it('should support absolute router links', fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - expect(guardRunCount).toEqual(2); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [ + {path: 'link', component: AbsoluteLinkCmp}, {path: 'simple', component: SimpleCmp} + ] + }]); - // Changes cause re-run on the config with the guard - router.navigateByUrl('/d/1;dd=11/e/3;ee=22'); - advance(fixture); + router.navigateByUrl('/team/22/link'); + advance(fixture); + expect(fixture.nativeElement).toHaveText('team 22 [ link, right: ]'); - expect(guardRunCount).toEqual(3); + const native = fixture.nativeElement.querySelector('a'); + expect(native.getAttribute('href')).toEqual('/team/33/simple'); + native.click(); + advance(fixture); - // Changes to the parent also cause re-run - router.navigateByUrl('/d/2;dd=11/e/3;ee=22'); - advance(fixture); + expect(fixture.nativeElement).toHaveText('team 33 [ simple, right: ]'); + }))); - expect(guardRunCount).toEqual(4); - }))); + it('should support relative router links', fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - it('should rerun rerun guards and resolvers when path or query params change', - fakeAsync(inject([Router], (router: Router) => { - const fixture = configureRouter(router, 'pathParamsOrQueryParamsChange'); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [ + {path: 'link', component: RelativeLinkCmp}, {path: 'simple', component: SimpleCmp} + ] + }]); - const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; - const recordedData: Data[] = []; - cmp.route.data.subscribe((data) => recordedData.push(data)); + router.navigateByUrl('/team/22/link'); + advance(fixture); + expect(fixture.nativeElement).toHaveText('team 22 [ link, right: ]'); - // First navigation has already run - expect(guardRunCount).toEqual(1); - expect(recordedData).toEqual([{data: 0}]); + const native = fixture.nativeElement.querySelector('a'); + expect(native.getAttribute('href')).toEqual('/team/22/simple'); + native.click(); + advance(fixture); - // Changing matrix params will not result in running guards or resolvers - router.navigateByUrl('/a;p=1'); - advance(fixture); - expect(guardRunCount).toEqual(1); - expect(recordedData).toEqual([{data: 0}]); + expect(fixture.nativeElement).toHaveText('team 22 [ simple, right: ]'); + }))); - router.navigateByUrl('/a;p=2'); - advance(fixture); - expect(guardRunCount).toEqual(1); - expect(recordedData).toEqual([{data: 0}]); + it('should support top-level link', fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RelativeLinkInIfCmp); + advance(fixture); - // Adding query params will re-run guards/resolvers - router.navigateByUrl('/a;p=2?q=1'); - advance(fixture); - expect(guardRunCount).toEqual(2); - expect(recordedData).toEqual([{data: 0}, {data: 1}]); + router.resetConfig( + [{path: 'simple', component: SimpleCmp}, {path: '', component: BlankCmp}]); - // Changing query params will re-run guards/resolvers - router.navigateByUrl('/a;p=2?q=2'); - advance(fixture); - expect(guardRunCount).toEqual(3); - expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]); - }))); + router.navigateByUrl('/'); + advance(fixture); + expect(fixture.nativeElement).toHaveText(''); + const cmp = fixture.componentInstance; - it('should allow a predicate function to determine when to run guards and resolvers', - fakeAsync(inject([Router], (router: Router) => { - const fixture = configureRouter(router, (from, to) => to.paramMap.get('p') === '2'); + cmp.show = true; + advance(fixture); - const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; - const recordedData: Data[] = []; - cmp.route.data.subscribe((data) => recordedData.push(data)); + expect(fixture.nativeElement).toHaveText('link'); + const native = fixture.nativeElement.querySelector('a'); - // First navigation has already run - expect(guardRunCount).toEqual(1); - expect(recordedData).toEqual([{data: 0}]); + expect(native.getAttribute('href')).toEqual('/simple'); + native.click(); + advance(fixture); - // Adding `p` param shouldn't cause re-run - router.navigateByUrl('/a;p=1'); - advance(fixture); - expect(guardRunCount).toEqual(1); - expect(recordedData).toEqual([{data: 0}]); + expect(fixture.nativeElement).toHaveText('linksimple'); + }))); - // Re-run should trigger on p=2 - router.navigateByUrl('/a;p=2'); - advance(fixture); - expect(guardRunCount).toEqual(2); - expect(recordedData).toEqual([{data: 0}, {data: 1}]); + it('should support query params and fragments', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - // Any other changes don't pass the predicate - router.navigateByUrl('/a;p=3?q=1'); - advance(fixture); - expect(guardRunCount).toEqual(2); - expect(recordedData).toEqual([{data: 0}, {data: 1}]); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [ + {path: 'link', component: LinkWithQueryParamsAndFragment}, + {path: 'simple', component: SimpleCmp} + ] + }]); - // Changing query params will re-run guards/resolvers - router.navigateByUrl('/a;p=3?q=2'); - advance(fixture); - expect(guardRunCount).toEqual(2); - expect(recordedData).toEqual([{data: 0}, {data: 1}]); - }))); - }); + router.navigateByUrl('/team/22/link'); + advance(fixture); - describe('should wait for parent to complete', () => { - let log: string[]; + const native = fixture.nativeElement.querySelector('a'); + expect(native.getAttribute('href')).toEqual('/team/22/simple?q=1#f'); + native.click(); + advance(fixture); - beforeEach(() => { - log = []; - TestBed.configureTestingModule({ - providers: [ - { - provide: 'parentGuard', - useValue: () => { - return delayPromise(10).then(() => { - log.push('parent'); - return true; - }); - } - }, - { - provide: 'childGuard', - useValue: () => { - return delayPromise(5).then(() => { - log.push('child'); - return true; - }); - } - } - ] - }); + expect(fixture.nativeElement).toHaveText('team 22 [ simple, right: ]'); + + expect(location.path(true)).toEqual('/team/22/simple?q=1#f'); + }))); + + describe('should support history and state', () => { + let component: typeof LinkWithState|typeof DivLinkWithState; + it('for anchor elements', () => { + // Test logic in afterEach to reduce duplication + component = LinkWithState; }); - function delayPromise(delay: number): Promise { - let resolve: (val: boolean) => void; - const promise = new Promise(res => resolve = res); - setTimeout(() => resolve(true), delay); - return promise; - } + it('for non-anchor elements', () => { + // Test logic in afterEach to reduce duplication + component = DivLinkWithState; + }); - it('works', fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); + afterEach(fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - router.resetConfig([{ - path: 'parent', - canActivate: ['parentGuard'], - children: [ - {path: 'child', component: SimpleCmp, canActivate: ['childGuard']}, - ] - }]); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [{path: 'link', component}, {path: 'simple', component: SimpleCmp}] + }]); - router.navigateByUrl('/parent/child'); - advance(fixture); - tick(15); - expect(log).toEqual(['parent', 'child']); - }))); - }); - }); + router.navigateByUrl('/team/22/link'); + advance(fixture); - describe('CanDeactivate', () => { - let log: any; + const native = fixture.nativeElement.querySelector('#link'); + native.click(); + advance(fixture); - beforeEach(() => { - log = []; + expect(fixture.nativeElement).toHaveText('team 22 [ simple, right: ]'); - TestBed.configureTestingModule({ - providers: [ - { - provide: 'CanDeactivateParent', - useValue: (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { - return a.params['id'] === '22'; - } - }, - { - provide: 'CanDeactivateTeam', - useValue: (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { - return c.route.snapshot.params['id'] === '22'; - } - }, - { - provide: 'CanDeactivateUser', - useValue: (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { - return a.params['name'] === 'victor'; - } - }, - { - provide: 'RecordingDeactivate', - useValue: (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { - log.push({path: a.routeConfig!.path, component: c}); - return true; - } - }, - { - provide: 'alwaysFalse', - useValue: (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { - return false; - } - }, - { - provide: 'alwaysFalseAndLogging', - useValue: (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { - log.push('called'); - return false; - } - }, - { - provide: 'alwaysFalseWithDelayAndLogging', - useValue: () => { - log.push('called'); - let resolve: (result: boolean) => void; - const promise = new Promise(res => resolve = res); - setTimeout(() => resolve(false), 0); - return promise; - } - }, - { - provide: 'canActivate_alwaysTrueAndLogging', - useValue: () => { - log.push('canActivate called'); - return true; - } - }, - ] - }); + // Check the history entry + expect(location.getState()).toEqual({foo: 'bar', navigationId: 3}); + }))); }); - describe('should not deactivate a route when CanDeactivate returns false', () => { - it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig( - [{path: 'team/:id', component: TeamCmp, canDeactivate: ['CanDeactivateTeam']}]); + it('should set href on area elements', fakeAsync(() => { + @Component({ + selector: 'someRoot', + template: `` + }) + class RootCmpWithArea { + } - router.navigateByUrl('/team/22'); - advance(fixture); - expect(location.path()).toEqual('/team/22'); + TestBed.configureTestingModule({declarations: [RootCmpWithArea]}); + const router: Router = TestBed.inject(Router); - let successStatus: boolean = false; - router.navigateByUrl('/team/33')!.then(res => successStatus = res); - advance(fixture); - expect(location.path()).toEqual('/team/33'); - expect(successStatus).toEqual(true); + const fixture = createRoot(router, RootCmpWithArea); - let canceledStatus: boolean = false; - router.navigateByUrl('/team/44')!.then(res => canceledStatus = res); - advance(fixture); - expect(location.path()).toEqual('/team/33'); - expect(canceledStatus).toEqual(false); - }))); + router.resetConfig([{path: 'home', component: SimpleCmp}]); - it('works with componentless routes', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = 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'); - advance(fixture); - expect(location.path()).toEqual('/grandparent/parent/child/simple'); - - router.navigateByUrl('/simple'); - 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', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = 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)'); - advance(fixture); - expect(location.path()).toEqual('/two-outlets/(a//aux:b)'); - - router.navigate(['two-outlets', {outlets: {aux: null}}]); - advance(fixture); - - expect(log.map((a: any) => a.path)).toEqual(['b']); - expect(location.path()).toEqual('/two-outlets/a'); - }))); - - it('works with a nested route', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: [ - {path: '', pathMatch: 'full', component: SimpleCmp}, - {path: 'user/:name', component: UserCmp, canDeactivate: ['CanDeactivateUser']} - ] - }]); - - router.navigateByUrl('/team/22/user/victor'); - advance(fixture); - - // this works because we can deactivate victor - router.navigateByUrl('/team/33'); - advance(fixture); - expect(location.path()).toEqual('/team/33'); - - router.navigateByUrl('/team/33/user/fedor'); - advance(fixture); - - // this doesn't work cause we cannot deactivate fedor - router.navigateByUrl('/team/44'); - advance(fixture); - expect(location.path()).toEqual('/team/33/user/fedor'); - }))); - }); - - it('should use correct component to deactivate forChild route', - fakeAsync(inject([Router], (router: Router) => { - @Component({selector: 'admin', template: ''}) - class AdminComponent { - } - - @NgModule({ - declarations: [AdminComponent], - imports: [RouterModule.forChild([{ - path: '', - component: AdminComponent, - canDeactivate: ['RecordingDeactivate'], - }])], - }) - class LazyLoadedModule { - } + const native = fixture.nativeElement.querySelector('area'); + expect(native.getAttribute('href')).toEqual('/home'); + })); + }); + describe('redirects', () => { + it('should work', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { const fixture = createRoot(router, RootCmp); router.resetConfig([ - { - path: 'a', - component: WrapperCmp, - children: [ - {path: '', pathMatch: 'full', loadChildren: () => LazyLoadedModule}, - ] - }, - {path: 'b', component: SimpleCmp}, + {path: 'old/team/:id', redirectTo: 'team/:id'}, {path: 'team/:id', component: TeamCmp} ]); - router.navigateByUrl('/a'); - advance(fixture); - router.navigateByUrl('/b'); + router.navigateByUrl('old/team/22'); advance(fixture); - expect(log[0].component).toBeInstanceOf(AdminComponent); + expect(location.path()).toEqual('/team/22'); }))); - it('should not create a route state if navigation is canceled', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + it('can redirect from componentless named outlets', fakeAsync(() => { + const router = TestBed.inject(Router); const fixture = createRoot(router, RootCmp); - router.resetConfig([{ - path: 'main', - component: TeamCmp, - children: [ - {path: 'component1', component: SimpleCmp, canDeactivate: ['alwaysFalse']}, - {path: 'component2', component: SimpleCmp} - ] - }]); - - router.navigateByUrl('/main/component1'); - advance(fixture); + router.resetConfig([ + {path: 'main', outlet: 'aux', component: BlankCmp}, + {path: '', pathMatch: 'full', outlet: 'aux', redirectTo: 'main'}, + ]); - router.navigateByUrl('/main/component2'); + router.navigateByUrl(''); 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'); - }))); + expect(TestBed.inject(Location).path()).toEqual('/(aux:main)'); + })); - it('should not run CanActivate when CanDeactivate returns false', + it('should update Navigation object after redirects are applied', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { const fixture = createRoot(router, RootCmp); + let initialUrl, afterRedirectUrl; - router.resetConfig([{ - path: 'main', - component: TeamCmp, - children: [ - { - path: 'component1', - component: SimpleCmp, - canDeactivate: ['alwaysFalseWithDelayAndLogging'] - }, - { - path: 'component2', - component: SimpleCmp, - canActivate: ['canActivate_alwaysTrueAndLogging'] - }, - ] - }]); + router.resetConfig([ + {path: 'old/team/:id', redirectTo: 'team/:id'}, {path: 'team/:id', component: TeamCmp} + ]); - router.navigateByUrl('/main/component1'); - advance(fixture); - expect(location.path()).toEqual('/main/component1'); + router.events.subscribe(e => { + if (e instanceof NavigationStart) { + const navigation = router.getCurrentNavigation(); + initialUrl = navigation && navigation.finalUrl; + } + if (e instanceof RoutesRecognized) { + const navigation = router.getCurrentNavigation(); + afterRedirectUrl = navigation && navigation.finalUrl; + } + }); - router.navigateByUrl('/main/component2'); + router.navigateByUrl('old/team/22'); 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', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + expect(initialUrl).toBeUndefined(); + expect(router.serializeUrl(afterRedirectUrl as any)).toBe('/team/22'); + }))); + it('should not break the back button when trigger by location change', fakeAsync(() => { + TestBed.configureTestingModule( + {providers: [{provide: LocationStrategy, useClass: HashLocationStrategy}]}); + const router = TestBed.inject(Router); + const location = TestBed.inject(Location); + const fixture = TestBed.createComponent(RootCmp); + advance(fixture); router.resetConfig([ - {path: 'simple', component: SimpleCmp, canDeactivate: ['alwaysFalseAndLogging']}, - {path: 'blank', component: BlankCmp} - + {path: 'initial', component: BlankCmp}, {path: 'old/team/:id', redirectTo: 'team/:id'}, + {path: 'team/:id', component: TeamCmp} ]); - router.navigateByUrl('/simple'); - advance(fixture); + location.go('initial'); + location.historyGo(0); + location.go('old/team/22'); + location.historyGo(0); - router.navigateByUrl('/blank'); + // initial navigation + router.initialNavigation(); advance(fixture); - expect(log).toEqual(['called']); - expect(location.path()).toEqual('/simple'); + expect(location.path()).toEqual('/team/22'); - router.navigateByUrl('/blank'); + location.back(); advance(fixture); - expect(log).toEqual(['called', 'called']); - expect(location.path()).toEqual('/simple'); - }))); + expect(location.path()).toEqual('/initial'); - describe('next state', () => { - let log: string[]; + // location change + location.go('/old/team/33'); + location.historyGo(0); - class ClassWithNextState { - canDeactivate( - component: TeamCmp, currentRoute: ActivatedRouteSnapshot, - currentState: RouterStateSnapshot, nextState: RouterStateSnapshot): boolean { - log.push(currentState.url, nextState.url); - return true; - } - } + advance(fixture); + expect(location.path()).toEqual('/team/33'); - beforeEach(() => { - log = []; - TestBed.configureTestingModule({ - providers: [ - ClassWithNextState, { - provide: 'FunctionWithNextState', - useValue: - (cmp: any, currentRoute: ActivatedRouteSnapshot, - currentState: RouterStateSnapshot, nextState: RouterStateSnapshot) => { - log.push(currentState.url, nextState.url); - return true; - } - } - ] + location.back(); + advance(fixture); + expect(location.path()).toEqual('/initial'); + })); + }); + describe('guards', () => { + describe('CanActivate', () => { + describe('should not activate a route when CanActivate returns false', () => { + beforeEach(() => { + TestBed.configureTestingModule( + {providers: [{provide: 'alwaysFalse', useValue: (a: any, b: any) => false}]}); }); - }); - it('should pass next state as the 4 argument when guard is a class', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - canDeactivate: - [(component: TeamCmp, currentRoute: ActivatedRouteSnapshot, - currentState: RouterStateSnapshot, nextState: RouterStateSnapshot) => - coreInject(ClassWithNextState) - .canDeactivate(component, currentRoute, currentState, nextState)] - }]); + const recordedEvents: Event[] = []; + router.events.forEach(e => recordedEvents.push(e)); - router.navigateByUrl('/team/22'); - advance(fixture); - expect(location.path()).toEqual('/team/22'); + router.resetConfig( + [{path: 'team/:id', component: TeamCmp, canActivate: ['alwaysFalse']}]); - router.navigateByUrl('/team/33'); - advance(fixture); - expect(location.path()).toEqual('/team/33'); - expect(log).toEqual(['/team/22', '/team/33']); - }))); + router.navigateByUrl('/team/22'); + advance(fixture); - it('should pass next state as the 4 argument when guard is a function', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + 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); + }))); + }); - router.resetConfig([ - {path: 'team/:id', component: TeamCmp, canDeactivate: ['FunctionWithNextState']} - ]); + describe( + 'should not activate a route when CanActivate returns false (componentless route)', + () => { + beforeEach(() => { + TestBed.configureTestingModule( + {providers: [{provide: 'alwaysFalse', useValue: (a: any, b: any) => false}]}); + }); - router.navigateByUrl('/team/22'); - advance(fixture); - expect(location.path()).toEqual('/team/22'); + it('works', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/team/33'); - advance(fixture); - expect(location.path()).toEqual('/team/33'); - expect(log).toEqual(['/team/22', '/team/33']); - }))); - }); + router.resetConfig([{ + path: 'parent', + canActivate: ['alwaysFalse'], + children: [{path: 'team/:id', component: TeamCmp}] + }]); - describe('should work when given a class', () => { - class AlwaysTrue { - canDeactivate(): boolean { - return true; - } - } + router.navigateByUrl('parent/team/22'); + advance(fixture); - beforeEach(() => { - TestBed.configureTestingModule({providers: [AlwaysTrue]}); - }); + expect(location.path()).toEqual(''); + }))); + }); - it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + describe('should activate a route when CanActivate returns true', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ + provide: 'alwaysTrue', + useValue: (a: ActivatedRouteSnapshot, s: RouterStateSnapshot) => true + }] + }); + }); - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - canDeactivate: [() => coreInject(AlwaysTrue).canDeactivate()] - }]); + it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/team/22'); - advance(fixture); - expect(location.path()).toEqual('/team/22'); + router.resetConfig( + [{path: 'team/:id', component: TeamCmp, canActivate: ['alwaysTrue']}]); - router.navigateByUrl('/team/33'); - advance(fixture); - expect(location.path()).toEqual('/team/33'); - }))); - }); + router.navigateByUrl('/team/22'); + 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; + } + } - describe('should work when returns an observable', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [{ - provide: 'CanDeactivate', - useValue: (c: TeamCmp, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { - return new Observable((observer) => { - observer.next(false); - }); - } - }] + beforeEach(() => { + TestBed.configureTestingModule({providers: [AlwaysTrue]}); }); - }); - - it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); - router.resetConfig( - [{path: 'team/:id', component: TeamCmp, canDeactivate: ['CanDeactivate']}]); + it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/team/22'); - advance(fixture); - expect(location.path()).toEqual('/team/22'); + router.resetConfig( + [{path: 'team/:id', component: TeamCmp, canActivate: [AlwaysTrue]}]); - router.navigateByUrl('/team/33'); - advance(fixture); - expect(location.path()).toEqual('/team/22'); - }))); - }); - }); + router.navigateByUrl('/team/22'); + advance(fixture); - describe('CanActivateChild', () => { - describe('should be invoked when activating a child', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [{ - provide: 'alwaysFalse', - useValue: (a: any, b: any) => a.paramMap.get('id') === '22', - }] - }); + expect(location.path()).toEqual('/team/22'); + }))); }); - it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + describe('should work when returns an observable', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ + provide: 'CanActivate', + useValue: (a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { + return new Observable((observer) => { + observer.next(false); + }); + } + }] + }); + }); - router.resetConfig([{ - path: '', - canActivateChild: ['alwaysFalse'], - children: [{path: 'team/:id', component: TeamCmp}] - }]); - router.navigateByUrl('/team/22'); - advance(fixture); + it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - expect(location.path()).toEqual('/team/22'); + router.resetConfig( + [{path: 'team/:id', component: TeamCmp, canActivate: ['CanActivate']}]); - router.navigateByUrl('/team/33')!.catch(() => {}); - advance(fixture); + router.navigateByUrl('/team/22'); + advance(fixture); + expect(location.path()).toEqual(''); + }))); + }); - expect(location.path()).toEqual('/team/22'); - }))); - }); + describe('should work when returns a promise', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ + provide: 'CanActivate', + useValue: (a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { + if (a.params['id'] === '22') { + return Promise.resolve(true); + } else { + return Promise.resolve(false); + } + } + }] + }); + }); - it('should find the guard provided in lazy loaded module', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - @Component({selector: 'admin', template: ''}) - class AdminComponent { - } - @Component({selector: 'lazy', template: 'lazy-loaded'}) - class LazyLoadedComponent { - } + it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - @NgModule({ - declarations: [AdminComponent, LazyLoadedComponent], - imports: [RouterModule.forChild([{ - path: '', - component: AdminComponent, - children: [{ - path: '', - canActivateChild: ['alwaysTrue'], - children: [{path: '', component: LazyLoadedComponent}] - }] - }])], - providers: [{provide: 'alwaysTrue', useValue: () => true}], - }) - class LazyLoadedModule { - } + router.resetConfig( + [{path: 'team/:id', component: TeamCmp, canActivate: ['CanActivate']}]); - const fixture = createRoot(router, RootCmp); + router.navigateByUrl('/team/22'); + advance(fixture); + expect(location.path()).toEqual('/team/22'); + + router.navigateByUrl('/team/33'); + advance(fixture); + expect(location.path()).toEqual('/team/22'); + }))); + }); - router.resetConfig([{path: 'admin', loadChildren: () => LazyLoadedModule}]); + describe('should reset the location when cancelling a navigation', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: 'alwaysFalse', + useValue: (a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { + return false; + } + }, + {provide: LocationStrategy, useClass: HashLocationStrategy} + ] + }); + }); - router.navigateByUrl('/admin'); - advance(fixture); + it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - expect(location.path()).toEqual('/admin'); - expect(fixture.nativeElement).toHaveText('lazy-loaded'); - }))); - }); + router.resetConfig([ + {path: 'one', component: SimpleCmp}, + {path: 'two', component: SimpleCmp, canActivate: ['alwaysFalse']} + ]); - describe('CanLoad', () => { - let canLoadRunCount = 0; - beforeEach(() => { - canLoadRunCount = 0; - TestBed.configureTestingModule({ - providers: [ - {provide: 'alwaysFalse', useValue: (a: any) => false}, - { - provide: 'returnUrlTree', - useFactory: (router: Router) => () => { - return router.createUrlTree(['blank']); - }, - deps: [Router], - }, - { + router.navigateByUrl('/one'); + advance(fixture); + expect(location.path()).toEqual('/one'); + + location.go('/two'); + location.historyGo(0); + advance(fixture); + expect(location.path()).toEqual('/one'); + }))); + }); + + describe('should redirect to / when guard returns false', () => { + beforeEach(() => TestBed.configureTestingModule({ + providers: [{ provide: 'returnFalseAndNavigate', - useFactory: (router: Router) => (a: any) => { - router.navigate(['blank']); + useFactory: (router: Router) => () => { + router.navigate(['/']); return false; }, - deps: [Router], - }, - { - provide: 'alwaysTrue', - useValue: () => { - canLoadRunCount++; - return true; + deps: [Router] + }] + })); + + it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + router.resetConfig([ + { + path: '', + component: SimpleCmp, + }, + {path: 'one', component: RouteCmp, canActivate: ['returnFalseAndNavigate']} + ]); + + const fixture = TestBed.createComponent(RootCmp); + router.navigateByUrl('/one'); + advance(fixture); + expect(location.path()).toEqual(''); + expect(fixture.nativeElement).toHaveText('simple'); + }))); + }); + + describe('should redirect when guard returns UrlTree', () => { + beforeEach(() => TestBed.configureTestingModule({ + providers: [ + { + provide: 'returnUrlTree', + useFactory: (router: Router) => () => { + return router.parseUrl('/redirected'); + }, + deps: [Router] + }, + { + provide: 'returnRootUrlTree', + useFactory: (router: Router) => () => { + return router.parseUrl('/'); + }, + deps: [Router] } - }, - ] + ] + })); + + it('works', fakeAsync(inject([Router, Location], (router: Router, location: 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: ['returnUrlTree']}, + {path: 'redirected', component: SimpleCmp} + ]); + + const fixture = TestBed.createComponent(RootCmp); + router.navigateByUrl('/one'); + + 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', + fakeAsync(inject([Router, Location], (router: Router, location: 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: ['returnRootUrlTree']} + ]); + + const fixture = TestBed.createComponent(RootCmp); + router.navigateByUrl('/one'); + + 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', + fakeAsync(() => { + 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: ['returnUrlTree']}, + {path: 'redirected', component: SimpleCmp} + ]); + createRoot(router, RootCmp); + router.navigateByUrl('/one'); + const urlChanges: string[] = []; + location.onUrlChange((change) => { + urlChanges.push(change); + }); + + tick(); + + expect(location.path()).toEqual('/redirected'); + expect(urlChanges).toEqual(['/one', '/redirected']); + location.back(); + tick(); + expect(location.path()).toEqual(''); + })); + + it('should resolve navigateByUrl promise after redirect finishes', fakeAsync(() => { + 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: ['returnUrlTree']}, + {path: 'redirected', component: SimpleCmp} + ]); + const fixture = createRoot(router, RootCmp); + router.navigateByUrl('/one').then(v => { + resolvedPath = location.path(); + }); + + tick(); + expect(resolvedPath).toBe('/redirected'); + })); }); - }); - it('should not load children when CanLoad returns false', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - @Component({selector: 'lazy', template: 'lazy-loaded'}) - class LazyLoadedComponent { - } + describe('runGuardsAndResolvers', () => { + let guardRunCount = 0; + let resolverRunCount = 0; - @NgModule({ - declarations: [LazyLoadedComponent], - imports: [RouterModule.forChild([{path: 'loaded', component: LazyLoadedComponent}])] - }) - class LoadedModule { - } + beforeEach(() => { + guardRunCount = 0; + resolverRunCount = 0; + TestBed.configureTestingModule({ + providers: [ + { + provide: 'guard', + useValue: () => { + guardRunCount++; + return true; + } + }, + {provide: 'resolver', useValue: () => resolverRunCount++} + ] + }); + }); - const fixture = createRoot(router, RootCmp); + function configureRouter(router: Router, runGuardsAndResolvers: RunGuardsAndResolvers): + ComponentFixture { + const fixture = createRoot(router, RootCmpWithTwoOutlets); - router.resetConfig([ - {path: 'lazyFalse', canLoad: ['alwaysFalse'], loadChildren: () => LoadedModule}, - {path: 'lazyTrue', canLoad: ['alwaysTrue'], loadChildren: () => LoadedModule} - ]); + router.resetConfig([ + { + path: 'a', + runGuardsAndResolvers, + component: RouteCmp, + canActivate: ['guard'], + resolve: {data: 'resolver'} + }, + {path: 'b', component: SimpleCmp, outlet: 'right'}, { + path: 'c/:param', + runGuardsAndResolvers, + component: RouteCmp, + canActivate: ['guard'], + resolve: {data: 'resolver'} + }, + { + path: 'd/:param', + component: WrapperCmp, + runGuardsAndResolvers, + children: [ + { + path: 'e/:param', + component: SimpleCmp, + canActivate: ['guard'], + resolve: {data: 'resolver'}, + }, + ] + }, + { + path: 'throwing', + runGuardsAndResolvers, + component: ThrowingCmp, + canActivate: ['guard'], + resolve: {data: 'resolver'} + } + ]); - const recordedEvents: Event[] = []; - router.events.forEach(e => recordedEvents.push(e)); + router.navigateByUrl('/a'); + advance(fixture); + return fixture; + } - // failed navigation - router.navigateByUrl('/lazyFalse/loaded'); - advance(fixture); + it('should rerun guards and resolvers when params change', + fakeAsync(inject([Router], (router: Router) => { + const fixture = configureRouter(router, 'paramsChange'); - expect(location.path()).toEqual(''); + const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; + const recordedData: Data[] = []; + cmp.route.data.subscribe((data) => recordedData.push(data)); - expectEvents(recordedEvents, [ - [NavigationStart, '/lazyFalse/loaded'], - // [GuardsCheckStart, '/lazyFalse/loaded'], - [NavigationCancel, '/lazyFalse/loaded'], - ]); + expect(guardRunCount).toEqual(1); + expect(recordedData).toEqual([{data: 0}]); - expect((recordedEvents[1] as NavigationCancel).code) - .toBe(NavigationCancellationCode.GuardRejected); + router.navigateByUrl('/a;p=1'); + advance(fixture); + expect(guardRunCount).toEqual(2); + expect(recordedData).toEqual([{data: 0}, {data: 1}]); - recordedEvents.splice(0); + router.navigateByUrl('/a;p=2'); + advance(fixture); + expect(guardRunCount).toEqual(3); + expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]); - // successful navigation - router.navigateByUrl('/lazyTrue/loaded'); - advance(fixture); + router.navigateByUrl('/a;p=2?q=1'); + advance(fixture); + expect(guardRunCount).toEqual(3); + expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]); + }))); - expect(location.path()).toEqual('/lazyTrue/loaded'); + it('should rerun guards and resolvers when query params change', + fakeAsync(inject([Router], (router: Router) => { + const fixture = configureRouter(router, 'paramsOrQueryParamsChange'); - 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'], - ]); - }))); + const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; + const recordedData: Data[] = []; + cmp.route.data.subscribe((data) => recordedData.push(data)); - it('should support navigating from within the guard', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + expect(guardRunCount).toEqual(1); + expect(recordedData).toEqual([{data: 0}]); - router.resetConfig([ - { - path: 'lazyFalse', - canLoad: ['returnFalseAndNavigate'], - loadChildren: jasmine.createSpy('lazyFalse') - }, - {path: 'blank', component: BlankCmp} - ]); + router.navigateByUrl('/a;p=1'); + advance(fixture); + expect(guardRunCount).toEqual(2); + expect(recordedData).toEqual([{data: 0}, {data: 1}]); - const recordedEvents: Event[] = []; - router.events.forEach(e => recordedEvents.push(e)); + router.navigateByUrl('/a;p=2'); + advance(fixture); + expect(guardRunCount).toEqual(3); + expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]); + router.navigateByUrl('/a;p=2?q=1'); + advance(fixture); + expect(guardRunCount).toEqual(4); + expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}, {data: 3}]); - router.navigateByUrl('/lazyFalse/loaded'); - advance(fixture); + router.navigateByUrl('/a;p=2(right:b)?q=1'); + advance(fixture); + expect(guardRunCount).toEqual(4); + expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}, {data: 3}]); + }))); - expect(location.path()).toEqual('/blank'); + it('should always rerun guards and resolvers', + fakeAsync(inject([Router], (router: Router) => { + const fixture = configureRouter(router, 'always'); - 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'] - ]); + const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; + const recordedData: Data[] = []; + cmp.route.data.subscribe((data) => recordedData.push(data)); - expect((recordedEvents[1] as NavigationCancel).code) - .toBe(NavigationCancellationCode.SupersededByNewNavigation); - }))); + expect(guardRunCount).toEqual(1); + expect(recordedData).toEqual([{data: 0}]); - it('should support returning UrlTree from within the guard', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + router.navigateByUrl('/a;p=1'); + advance(fixture); + expect(guardRunCount).toEqual(2); + expect(recordedData).toEqual([{data: 0}, {data: 1}]); - router.resetConfig([ - { - path: 'lazyFalse', - canLoad: ['returnUrlTree'], - loadChildren: jasmine.createSpy('lazyFalse') - }, - {path: 'blank', component: BlankCmp} - ]); + router.navigateByUrl('/a;p=2'); + advance(fixture); + expect(guardRunCount).toEqual(3); + expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]); - const recordedEvents: Event[] = []; - router.events.forEach(e => recordedEvents.push(e)); + router.navigateByUrl('/a;p=2?q=1'); + 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'); + advance(fixture); + expect(guardRunCount).toEqual(5); + expect(recordedData).toEqual([ + {data: 0}, {data: 1}, {data: 2}, {data: 3}, {data: 4} + ]); - router.navigateByUrl('/lazyFalse/loaded'); - advance(fixture); + // Issue #39030, always running guards and resolvers should not throw + // when navigating away from a component with a throwing constructor. + expect(() => { + router.navigateByUrl('/throwing').catch(() => {}); + advance(fixture); + router.navigateByUrl('/a;p=1'); + advance(fixture); + }).not.toThrow(); + }))); - expect(location.path()).toEqual('/blank'); + it('should rerun rerun guards and resolvers when path params change', + fakeAsync(inject([Router], (router: Router) => { + const fixture = configureRouter(router, 'pathParamsChange'); - 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'] - ]); + const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; + const recordedData: Data[] = []; + cmp.route.data.subscribe((data) => recordedData.push(data)); - expect((recordedEvents[1] as NavigationCancel).code) - .toBe(NavigationCancellationCode.Redirect); - }))); + // First navigation has already run + expect(guardRunCount).toEqual(1); + expect(recordedData).toEqual([{data: 0}]); - // 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', - fakeAsync(inject([Router], (router: Router) => { - @Component({selector: 'lazy', template: 'lazy-loaded'}) - class LazyLoadedComponent { - } + // Changing any optional params will not result in running guards or resolvers + router.navigateByUrl('/a;p=1'); + advance(fixture); + expect(guardRunCount).toEqual(1); + expect(recordedData).toEqual([{data: 0}]); - @NgModule({ - declarations: [LazyLoadedComponent], - imports: [RouterModule.forChild([{path: 'loaded', component: LazyLoadedComponent}])] - }) - class LazyLoadedModule { - } + router.navigateByUrl('/a;p=2'); + advance(fixture); + expect(guardRunCount).toEqual(1); + expect(recordedData).toEqual([{data: 0}]); - const fixture = createRoot(router, RootCmp); + router.navigateByUrl('/a;p=2?q=1'); + advance(fixture); + expect(guardRunCount).toEqual(1); + expect(recordedData).toEqual([{data: 0}]); - router.resetConfig([ - {path: 'lazy-false', canLoad: ['alwaysFalse'], loadChildren: () => LazyLoadedModule}, - {path: 'lazy-true', canLoad: ['alwaysTrue'], loadChildren: () => LazyLoadedModule}, - ]); + router.navigateByUrl('/a;p=2(right:b)?q=1'); + advance(fixture); + expect(guardRunCount).toEqual(1); + expect(recordedData).toEqual([{data: 0}]); - let navFalseResult = true; - let navTrueResult = false; - router.navigateByUrl('/lazy-false').then(v => { - navFalseResult = v; - }); - advance(fixture); - router.navigateByUrl('/lazy-true').then(v => { - navTrueResult = v; - }); - advance(fixture); + // Change to new route with path param should run guards and resolvers + router.navigateByUrl('/c/paramValue'); + advance(fixture); - expect(navFalseResult).toBe(false); - expect(navTrueResult).toBe(true); - }))); + expect(guardRunCount).toEqual(2); - it('should execute CanLoad only once', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - @Component({selector: 'lazy', template: 'lazy-loaded'}) - class LazyLoadedComponent { - } + // Modifying a path param should run guards and resolvers + router.navigateByUrl('/c/paramValueChanged'); + advance(fixture); + expect(guardRunCount).toEqual(3); - @NgModule({ - declarations: [LazyLoadedComponent], - imports: [RouterModule.forChild([{path: 'loaded', component: LazyLoadedComponent}])] - }) - class LazyLoadedModule { - } + // Adding optional params should not cause guards/resolvers to run + router.navigateByUrl('/c/paramValueChanged;p=1?q=2'); + advance(fixture); + expect(guardRunCount).toEqual(3); + }))); - const fixture = createRoot(router, RootCmp); + it('should rerun when a parent segment changes', + fakeAsync(inject([Router], (router: Router) => { + const fixture = configureRouter(router, 'pathParamsChange'); - router.resetConfig( - [{path: 'lazy', canLoad: ['alwaysTrue'], loadChildren: () => LazyLoadedModule}]); + const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; - router.navigateByUrl('/lazy/loaded'); - advance(fixture); - expect(location.path()).toEqual('/lazy/loaded'); - expect(canLoadRunCount).toEqual(1); + // Land on an initial page + router.navigateByUrl('/d/1;dd=11/e/2;dd=22'); + advance(fixture); - router.navigateByUrl('/'); - advance(fixture); - expect(location.path()).toEqual(''); + expect(guardRunCount).toEqual(2); - router.navigateByUrl('/lazy/loaded'); - advance(fixture); - expect(location.path()).toEqual('/lazy/loaded'); - expect(canLoadRunCount).toEqual(1); - }))); + // Changes cause re-run on the config with the guard + router.navigateByUrl('/d/1;dd=11/e/3;ee=22'); + advance(fixture); - it('cancels guard execution when a new navigation happens', fakeAsync(() => { - @Injectable({providedIn: 'root'}) - class DelayedGuard { - static delayedExecutions = 0; - static canLoadCalls = 0; - canLoad() { - DelayedGuard.canLoadCalls++; - return of(true).pipe(delay(1000), tap(() => { - DelayedGuard.delayedExecutions++; - })); - } - } - const router = TestBed.inject(Router); - router.resetConfig([ - {path: 'a', canLoad: [DelayedGuard], loadChildren: () => [], component: SimpleCmp}, - {path: 'team/:id', component: TeamCmp}, - ]); - const fixture = createRoot(router, RootCmp); + expect(guardRunCount).toEqual(3); - router.navigateByUrl('/a'); - tick(10); - // The delayed guard should have started - expect(DelayedGuard.canLoadCalls).toEqual(1); - router.navigateByUrl('/team/1'); - advance(fixture, 1000); - 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); - })); - }); + // Changes to the parent also cause re-run + router.navigateByUrl('/d/2;dd=11/e/3;ee=22'); + advance(fixture); - describe('should run CanLoad guards concurrently', () => { - function delayObservable(delayMs: number): Observable { - return of(delayMs).pipe(delay(delayMs), mapTo(true)); - } + expect(guardRunCount).toEqual(4); + }))); - let log: string[]; + it('should rerun rerun guards and resolvers when path or query params change', + fakeAsync(inject([Router], (router: Router) => { + const fixture = configureRouter(router, 'pathParamsOrQueryParamsChange'); - beforeEach(() => { - log = []; - TestBed.configureTestingModule({ - providers: [ - { - provide: 'guard1', - useValue: () => { - return delayObservable(5).pipe(tap({next: () => log.push('guard1')})); - } - }, - { - provide: 'guard2', - useValue: () => { - return delayObservable(0).pipe(tap({next: () => log.push('guard2')})); - } - }, - { - provide: 'returnFalse', - useValue: () => { - log.push('returnFalse'); - return false; - } - }, - { - provide: 'returnFalseAndNavigate', - useFactory: (router: Router) => () => { - log.push('returnFalseAndNavigate'); - router.navigateByUrl('/redirected'); - return false; - }, - deps: [Router] - }, - { - provide: 'returnUrlTree', - useFactory: (router: Router) => () => { - return delayObservable(15).pipe( - mapTo(router.parseUrl('/redirected')), - tap({next: () => log.push('returnUrlTree')})); - }, - deps: [Router] - }, - ] - }); - }); + const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; + const recordedData: Data[] = []; + cmp.route.data.subscribe((data) => recordedData.push(data)); - it('should only execute canLoad guards of routes being activated', fakeAsync(() => { - const router = TestBed.inject(Router); + // First navigation has already run + expect(guardRunCount).toEqual(1); + expect(recordedData).toEqual([{data: 0}]); - 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) - }, - ]); + // Changing matrix params will not result in running guards or resolvers + router.navigateByUrl('/a;p=1'); + advance(fixture); + expect(guardRunCount).toEqual(1); + expect(recordedData).toEqual([{data: 0}]); - router.navigateByUrl('/lazy'); - tick(5); - expect(log.length).toEqual(1); - expect(log).toEqual(['guard1']); - })); + router.navigateByUrl('/a;p=2'); + advance(fixture); + expect(guardRunCount).toEqual(1); + expect(recordedData).toEqual([{data: 0}]); - it('should execute canLoad guards', fakeAsync(inject([Router], (router: Router) => { - router.resetConfig([{ - path: 'lazy', - canLoad: ['guard1', 'guard2'], - loadChildren: () => ModuleWithBlankCmpAsRoute - }]); + // Adding query params will re-run guards/resolvers + router.navigateByUrl('/a;p=2?q=1'); + advance(fixture); + expect(guardRunCount).toEqual(2); + expect(recordedData).toEqual([{data: 0}, {data: 1}]); - router.navigateByUrl('/lazy'); - tick(5); + // Changing query params will re-run guards/resolvers + router.navigateByUrl('/a;p=2?q=2'); + advance(fixture); + expect(guardRunCount).toEqual(3); + expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]); + }))); - expect(log.length).toEqual(2); - expect(log).toEqual(['guard2', 'guard1']); - }))); + it('should allow a predicate function to determine when to run guards and resolvers', + fakeAsync(inject([Router], (router: Router) => { + const fixture = configureRouter(router, (from, to) => to.paramMap.get('p') === '2'); - it('should redirect with UrlTree if higher priority guards have resolved', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - router.resetConfig([ - { - path: 'lazy', - canLoad: ['returnUrlTree', 'guard1', 'guard2'], - loadChildren: () => ModuleWithBlankCmpAsRoute - }, - {path: 'redirected', component: SimpleCmp} - ]); + const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance; + const recordedData: Data[] = []; + cmp.route.data.subscribe((data) => recordedData.push(data)); - router.navigateByUrl('/lazy'); - tick(15); + // First navigation has already run + expect(guardRunCount).toEqual(1); + expect(recordedData).toEqual([{data: 0}]); - expect(log.length).toEqual(3); - expect(log).toEqual(['guard2', 'guard1', 'returnUrlTree']); - expect(location.path()).toEqual('/redirected'); - }))); + // Adding `p` param shouldn't cause re-run + router.navigateByUrl('/a;p=1'); + advance(fixture); + expect(guardRunCount).toEqual(1); + expect(recordedData).toEqual([{data: 0}]); - it('should redirect with UrlTree if UrlTree is lower priority', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - router.resetConfig([ - { - path: 'lazy', - canLoad: ['guard1', 'returnUrlTree'], - loadChildren: () => ModuleWithBlankCmpAsRoute - }, - {path: 'redirected', component: SimpleCmp} - ]); + // Re-run should trigger on p=2 + router.navigateByUrl('/a;p=2'); + advance(fixture); + expect(guardRunCount).toEqual(2); + expect(recordedData).toEqual([{data: 0}, {data: 1}]); - router.navigateByUrl('/lazy'); - tick(15); + // Any other changes don't pass the predicate + router.navigateByUrl('/a;p=3?q=1'); + advance(fixture); + expect(guardRunCount).toEqual(2); + expect(recordedData).toEqual([{data: 0}, {data: 1}]); - expect(log.length).toEqual(2); - expect(log).toEqual(['guard1', 'returnUrlTree']); - expect(location.path()).toEqual('/redirected'); - }))); - }); + // Changing query params will re-run guards/resolvers + router.navigateByUrl('/a;p=3?q=2'); + advance(fixture); + expect(guardRunCount).toEqual(2); + expect(recordedData).toEqual([{data: 0}, {data: 1}]); + }))); + }); - describe('order', () => { - class Logger { - logs: string[] = []; - add(thing: string) { - this.logs.push(thing); - } - } + describe('should wait for parent to complete', () => { + let log: string[]; - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - Logger, { - provide: 'canActivateChild_parent', - useFactory: (logger: Logger) => () => (logger.add('canActivateChild_parent'), true), - deps: [Logger] - }, - { - provide: 'canActivate_team', - useFactory: (logger: Logger) => () => (logger.add('canActivate_team'), true), - deps: [Logger] - }, - { - provide: 'canDeactivate_team', - useFactory: (logger: Logger) => () => (logger.add('canDeactivate_team'), true), - deps: [Logger] - }, - { - provide: 'canDeactivate_simple', - useFactory: (logger: Logger) => () => (logger.add('canDeactivate_simple'), true), - deps: [Logger] - } - ] - }); - }); + beforeEach(() => { + log = []; + TestBed.configureTestingModule({ + providers: [ + { + provide: 'parentGuard', + useValue: () => { + return delayPromise(10).then(() => { + log.push('parent'); + return true; + }); + } + }, + { + provide: 'childGuard', + useValue: () => { + return delayPromise(5).then(() => { + log.push('child'); + return true; + }); + } + } + ] + }); + }); + + function delayPromise(delay: number): Promise { + let resolve: (val: boolean) => void; + const promise = new Promise(res => resolve = res); + setTimeout(() => resolve(true), delay); + return promise; + } - it('should call guards in the right order', - fakeAsync(inject( - [Router, Location, Logger], (router: Router, location: Location, logger: Logger) => { + it('works', fakeAsync(inject([Router], (router: Router) => { const fixture = createRoot(router, RootCmp); router.resetConfig([{ - path: '', - canActivateChild: ['canActivateChild_parent'], - children: [{ - path: 'team/:id', - canActivate: ['canActivate_team'], - canDeactivate: ['canDeactivate_team'], - component: TeamCmp - }] + path: 'parent', + canActivate: ['parentGuard'], + children: [ + {path: 'child', component: SimpleCmp, canActivate: ['childGuard']}, + ] }]); + router.navigateByUrl('/parent/child'); + advance(fixture); + tick(15); + expect(log).toEqual(['parent', 'child']); + }))); + }); + }); + + describe('CanDeactivate', () => { + let log: any; + + beforeEach(() => { + log = []; + + TestBed.configureTestingModule({ + providers: [ + { + provide: 'CanDeactivateParent', + useValue: (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { + return a.params['id'] === '22'; + } + }, + { + provide: 'CanDeactivateTeam', + useValue: (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { + return c.route.snapshot.params['id'] === '22'; + } + }, + { + provide: 'CanDeactivateUser', + useValue: (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { + return a.params['name'] === 'victor'; + } + }, + { + provide: 'RecordingDeactivate', + useValue: (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { + log.push({path: a.routeConfig!.path, component: c}); + return true; + } + }, + { + provide: 'alwaysFalse', + useValue: (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { + return false; + } + }, + { + provide: 'alwaysFalseAndLogging', + useValue: (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { + log.push('called'); + return false; + } + }, + { + provide: 'alwaysFalseWithDelayAndLogging', + useValue: () => { + log.push('called'); + let resolve: (result: boolean) => void; + const promise = new Promise(res => resolve = res); + setTimeout(() => resolve(false), 0); + return promise; + } + }, + { + provide: 'canActivate_alwaysTrueAndLogging', + useValue: () => { + log.push('canActivate called'); + return true; + } + }, + ] + }); + }); + + describe('should not deactivate a route when CanDeactivate returns false', () => { + it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); + + router.resetConfig( + [{path: 'team/:id', component: TeamCmp, canDeactivate: ['CanDeactivateTeam']}]); + router.navigateByUrl('/team/22'); advance(fixture); + expect(location.path()).toEqual('/team/22'); - router.navigateByUrl('/team/33'); + let successStatus: boolean = false; + router.navigateByUrl('/team/33')!.then(res => successStatus = res); + advance(fixture); + expect(location.path()).toEqual('/team/33'); + expect(successStatus).toEqual(true); + + let canceledStatus: boolean = false; + router.navigateByUrl('/team/44')!.then(res => canceledStatus = res); + advance(fixture); + expect(location.path()).toEqual('/team/33'); + expect(canceledStatus).toEqual(false); + }))); + + it('works with componentless routes', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = 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'); + advance(fixture); + expect(location.path()).toEqual('/grandparent/parent/child/simple'); + + router.navigateByUrl('/simple'); advance(fixture); - expect(logger.logs).toEqual([ - 'canActivateChild_parent', 'canActivate_team', + const child = fixture.debugElement.children[1].componentInstance; - 'canDeactivate_team', 'canActivateChild_parent', 'canActivate_team' + 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('should call deactivate guards from bottom to top', - fakeAsync(inject( - [Router, Location, Logger], (router: Router, location: Location, logger: Logger) => { + it('works with aux routes', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { const fixture = createRoot(router, RootCmp); router.resetConfig([{ - path: '', - children: [{ - path: 'team/:id', - canDeactivate: ['canDeactivate_team'], - children: - [{path: '', component: SimpleCmp, canDeactivate: ['canDeactivate_simple']}], - component: TeamCmp - }] + path: 'two-outlets', + component: TwoOutletsCmp, + children: [ + {path: 'a', component: BlankCmp}, { + path: 'b', + canDeactivate: ['RecordingDeactivate'], + component: SimpleCmp, + outlet: 'aux' + } + ] }]); - router.navigateByUrl('/team/22'); + router.navigateByUrl('/two-outlets/(a//aux:b)'); advance(fixture); + expect(location.path()).toEqual('/two-outlets/(a//aux:b)'); - router.navigateByUrl('/team/33'); + router.navigate(['two-outlets', {outlets: {aux: null}}]); advance(fixture); - expect(logger.logs).toEqual(['canDeactivate_simple', 'canDeactivate_team']); + expect(log.map((a: any) => a.path)).toEqual(['b']); + expect(location.path()).toEqual('/two-outlets/a'); }))); - }); - describe('canMatch', () => { - @Injectable({providedIn: 'root'}) - class ConfigurableGuard { - result: Promise|Observable|boolean|UrlTree = false; - canMatch() { - return this.result; - } - } + it('works with a nested route', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - it('falls back to second route when canMatch returns false', fakeAsync(() => { - const router = TestBed.inject(Router); - router.resetConfig([ - { - path: 'a', - canMatch: [() => coreInject(ConfigurableGuard).canMatch()], - component: BlankCmp - }, - {path: 'a', component: SimpleCmp}, - ]); - const fixture = createRoot(router, RootCmp); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [ + {path: '', pathMatch: 'full', component: SimpleCmp}, + {path: 'user/:name', component: UserCmp, canDeactivate: ['CanDeactivateUser']} + ] + }]); - router.navigateByUrl('/a'); - advance(fixture); - expect(fixture.nativeElement.innerHTML).toContain('simple'); - })); + router.navigateByUrl('/team/22/user/victor'); + advance(fixture); - it('uses route when canMatch returns true', fakeAsync(() => { - const router = TestBed.inject(Router); - TestBed.inject(ConfigurableGuard).result = Promise.resolve(true); - router.resetConfig([ - { - path: 'a', - canMatch: [() => coreInject(ConfigurableGuard).canMatch()], - component: SimpleCmp - }, - {path: 'a', component: BlankCmp}, - ]); - const fixture = createRoot(router, RootCmp); + // this works because we can deactivate victor + router.navigateByUrl('/team/33'); + advance(fixture); + expect(location.path()).toEqual('/team/33'); + router.navigateByUrl('/team/33/user/fedor'); + advance(fixture); - router.navigateByUrl('/a'); - advance(fixture); - expect(fixture.nativeElement.innerHTML).toContain('simple'); - })); + // this doesn't work cause we cannot deactivate fedor + router.navigateByUrl('/team/44'); + advance(fixture); + expect(location.path()).toEqual('/team/33/user/fedor'); + }))); + }); - it('can return UrlTree from canMatch guard', fakeAsync(() => { - const router = TestBed.inject(Router); - TestBed.inject(ConfigurableGuard).result = - Promise.resolve(router.createUrlTree(['/team/1'])); - router.resetConfig([ - { - path: 'a', - canMatch: [() => coreInject(ConfigurableGuard).canMatch()], - component: SimpleCmp - }, - {path: 'team/:id', component: TeamCmp}, - ]); - const fixture = createRoot(router, RootCmp); + it('should use correct component to deactivate forChild route', + fakeAsync(inject([Router], (router: Router) => { + @Component({selector: 'admin', template: ''}) + class AdminComponent { + } + + @NgModule({ + declarations: [AdminComponent], + imports: [RouterModule.forChild([{ + path: '', + component: AdminComponent, + canDeactivate: ['RecordingDeactivate'], + }])], + }) + class LazyLoadedModule { + } + const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/a'); - advance(fixture); - expect(fixture.nativeElement.innerHTML).toContain('team'); - })); + router.resetConfig([ + { + path: 'a', + component: WrapperCmp, + children: [ + {path: '', pathMatch: 'full', loadChildren: () => LazyLoadedModule}, + ] + }, + {path: 'b', component: SimpleCmp}, + ]); - it('can return UrlTree from CanMatchFn guard', fakeAsync(() => { - const canMatchTeamSection = new InjectionToken('CanMatchTeamSection'); - const canMatchFactory: (router: Router) => CanMatchFn = (router: Router) => () => - router.createUrlTree(['/team/1']); + router.navigateByUrl('/a'); + advance(fixture); + router.navigateByUrl('/b'); + advance(fixture); - TestBed.overrideProvider( - canMatchTeamSection, {useFactory: canMatchFactory, deps: [Router]}); + expect(log[0].component).toBeInstanceOf(AdminComponent); + }))); - const router = TestBed.inject(Router); + it('should not create a route state if navigation is canceled', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - router.resetConfig([ - {path: 'a', canMatch: [canMatchTeamSection], component: SimpleCmp}, - {path: 'team/:id', component: TeamCmp}, + router.resetConfig([{ + path: 'main', + component: TeamCmp, + children: [ + {path: 'component1', component: SimpleCmp, canDeactivate: ['alwaysFalse']}, + {path: 'component2', component: SimpleCmp} + ] + }]); + + router.navigateByUrl('/main/component1'); + advance(fixture); + + router.navigateByUrl('/main/component2'); + 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', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); + + router.resetConfig([{ + path: 'main', + component: TeamCmp, + children: [ + { + path: 'component1', + component: SimpleCmp, + canDeactivate: ['alwaysFalseWithDelayAndLogging'] + }, + { + path: 'component2', + component: SimpleCmp, + canActivate: ['canActivate_alwaysTrueAndLogging'] + }, + ] + }]); + + router.navigateByUrl('/main/component1'); + advance(fixture); + expect(location.path()).toEqual('/main/component1'); + + router.navigateByUrl('/main/component2'); + 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', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); + + router.resetConfig([ + {path: 'simple', component: SimpleCmp, canDeactivate: ['alwaysFalseAndLogging']}, + {path: 'blank', component: BlankCmp} + + ]); + + router.navigateByUrl('/simple'); + advance(fixture); + + router.navigateByUrl('/blank'); + advance(fixture); + expect(log).toEqual(['called']); + expect(location.path()).toEqual('/simple'); + + router.navigateByUrl('/blank'); + 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, { + provide: 'FunctionWithNextState', + useValue: + (cmp: any, currentRoute: ActivatedRouteSnapshot, + currentState: RouterStateSnapshot, nextState: RouterStateSnapshot) => { + log.push(currentState.url, nextState.url); + return true; + } + } + ] + }); + }); + + it('should pass next state as the 4 argument when guard is a class', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); + + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + canDeactivate: + [(component: TeamCmp, currentRoute: ActivatedRouteSnapshot, + currentState: RouterStateSnapshot, nextState: RouterStateSnapshot) => + coreInject(ClassWithNextState) + .canDeactivate(component, currentRoute, currentState, nextState)] + }]); + + router.navigateByUrl('/team/22'); + advance(fixture); + expect(location.path()).toEqual('/team/22'); + + router.navigateByUrl('/team/33'); + 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', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); + + router.resetConfig([ + {path: 'team/:id', component: TeamCmp, canDeactivate: ['FunctionWithNextState']} + ]); + + router.navigateByUrl('/team/22'); + advance(fixture); + expect(location.path()).toEqual('/team/22'); + + router.navigateByUrl('/team/33'); + 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', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); + + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + canDeactivate: [() => coreInject(AlwaysTrue).canDeactivate()] + }]); + + router.navigateByUrl('/team/22'); + advance(fixture); + expect(location.path()).toEqual('/team/22'); + + router.navigateByUrl('/team/33'); + advance(fixture); + expect(location.path()).toEqual('/team/33'); + }))); + }); + + + describe('should work when returns an observable', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ + provide: 'CanDeactivate', + useValue: (c: TeamCmp, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { + return new Observable((observer) => { + observer.next(false); + }); + } + }] + }); + }); + + it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); + + router.resetConfig( + [{path: 'team/:id', component: TeamCmp, canDeactivate: ['CanDeactivate']}]); + + router.navigateByUrl('/team/22'); + advance(fixture); + expect(location.path()).toEqual('/team/22'); + + router.navigateByUrl('/team/33'); + advance(fixture); + expect(location.path()).toEqual('/team/22'); + }))); + }); + }); + + describe('CanActivateChild', () => { + describe('should be invoked when activating a child', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ + provide: 'alwaysFalse', + useValue: (a: any, b: any) => a.paramMap.get('id') === '22', + }] + }); + }); + + it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); + + router.resetConfig([{ + path: '', + canActivateChild: ['alwaysFalse'], + children: [{path: 'team/:id', component: TeamCmp}] + }]); + + router.navigateByUrl('/team/22'); + advance(fixture); + + expect(location.path()).toEqual('/team/22'); + + router.navigateByUrl('/team/33')!.catch(() => {}); + advance(fixture); + + expect(location.path()).toEqual('/team/22'); + }))); + }); + + it('should find the guard provided in lazy loaded module', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + @Component({selector: 'admin', template: ''}) + class AdminComponent { + } + + @Component({selector: 'lazy', template: 'lazy-loaded'}) + class LazyLoadedComponent { + } + + @NgModule({ + declarations: [AdminComponent, LazyLoadedComponent], + imports: [RouterModule.forChild([{ + path: '', + component: AdminComponent, + children: [{ + path: '', + canActivateChild: ['alwaysTrue'], + children: [{path: '', component: LazyLoadedComponent}] + }] + }])], + providers: [{provide: 'alwaysTrue', useValue: () => true}], + }) + class LazyLoadedModule { + } + + const fixture = createRoot(router, RootCmp); + + router.resetConfig([{path: 'admin', loadChildren: () => LazyLoadedModule}]); + + router.navigateByUrl('/admin'); + 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: 'alwaysFalse', useValue: (a: any) => false}, + { + provide: 'returnUrlTree', + useFactory: (router: Router) => () => { + return router.createUrlTree(['blank']); + }, + deps: [Router], + }, + { + provide: 'returnFalseAndNavigate', + useFactory: (router: Router) => (a: any) => { + router.navigate(['blank']); + return false; + }, + deps: [Router], + }, + { + provide: 'alwaysTrue', + useValue: () => { + canLoadRunCount++; + return true; + } + }, + ] + }); + }); + + it('should not load children when CanLoad returns false', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + @Component({selector: 'lazy', template: 'lazy-loaded'}) + class LazyLoadedComponent { + } + + @NgModule({ + declarations: [LazyLoadedComponent], + imports: [RouterModule.forChild([{path: 'loaded', component: LazyLoadedComponent}])] + }) + class LoadedModule { + } + + const fixture = createRoot(router, RootCmp); + + router.resetConfig([ + {path: 'lazyFalse', canLoad: ['alwaysFalse'], loadChildren: () => LoadedModule}, + {path: 'lazyTrue', canLoad: ['alwaysTrue'], loadChildren: () => LoadedModule} + ]); + + const recordedEvents: Event[] = []; + router.events.forEach(e => recordedEvents.push(e)); + + + // failed navigation + router.navigateByUrl('/lazyFalse/loaded'); + 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'); + 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', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); + + router.resetConfig([ + { + path: 'lazyFalse', + canLoad: ['returnFalseAndNavigate'], + loadChildren: jasmine.createSpy('lazyFalse') + }, + {path: 'blank', component: BlankCmp} + ]); + + const recordedEvents: Event[] = []; + router.events.forEach(e => recordedEvents.push(e)); + + + router.navigateByUrl('/lazyFalse/loaded'); + 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', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); + + router.resetConfig([ + { + path: 'lazyFalse', + canLoad: ['returnUrlTree'], + loadChildren: jasmine.createSpy('lazyFalse') + }, + {path: 'blank', component: BlankCmp} + ]); + + const recordedEvents: Event[] = []; + router.events.forEach(e => recordedEvents.push(e)); + + + router.navigateByUrl('/lazyFalse/loaded'); + 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', + fakeAsync(inject([Router], (router: Router) => { + @Component({selector: 'lazy', template: 'lazy-loaded'}) + class LazyLoadedComponent { + } + + @NgModule({ + declarations: [LazyLoadedComponent], + imports: [RouterModule.forChild([{path: 'loaded', component: LazyLoadedComponent}])] + }) + class LazyLoadedModule { + } + + const fixture = createRoot(router, RootCmp); + + router.resetConfig([ + {path: 'lazy-false', canLoad: ['alwaysFalse'], loadChildren: () => LazyLoadedModule}, + {path: 'lazy-true', canLoad: ['alwaysTrue'], loadChildren: () => LazyLoadedModule}, + ]); + + let navFalseResult = true; + let navTrueResult = false; + router.navigateByUrl('/lazy-false').then(v => { + navFalseResult = v; + }); + advance(fixture); + router.navigateByUrl('/lazy-true').then(v => { + navTrueResult = v; + }); + advance(fixture); + + expect(navFalseResult).toBe(false); + expect(navTrueResult).toBe(true); + }))); + + it('should execute CanLoad only once', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + @Component({selector: 'lazy', template: 'lazy-loaded'}) + class LazyLoadedComponent { + } + + @NgModule({ + declarations: [LazyLoadedComponent], + imports: [RouterModule.forChild([{path: 'loaded', component: LazyLoadedComponent}])] + }) + class LazyLoadedModule { + } + + const fixture = createRoot(router, RootCmp); + + router.resetConfig( + [{path: 'lazy', canLoad: ['alwaysTrue'], loadChildren: () => LazyLoadedModule}]); + + router.navigateByUrl('/lazy/loaded'); + advance(fixture); + expect(location.path()).toEqual('/lazy/loaded'); + expect(canLoadRunCount).toEqual(1); + + router.navigateByUrl('/'); + advance(fixture); + expect(location.path()).toEqual(''); + + router.navigateByUrl('/lazy/loaded'); + advance(fixture); + expect(location.path()).toEqual('/lazy/loaded'); + expect(canLoadRunCount).toEqual(1); + }))); + + it('cancels guard execution when a new navigation happens', fakeAsync(() => { + @Injectable({providedIn: 'root'}) + class DelayedGuard { + static delayedExecutions = 0; + static canLoadCalls = 0; + canLoad() { + DelayedGuard.canLoadCalls++; + return of(true).pipe(delay(1000), tap(() => { + DelayedGuard.delayedExecutions++; + })); + } + } + const router = TestBed.inject(Router); + router.resetConfig([ + {path: 'a', canLoad: [DelayedGuard], loadChildren: () => [], component: SimpleCmp}, + {path: 'team/:id', component: TeamCmp}, + ]); + const fixture = createRoot(router, RootCmp); + + router.navigateByUrl('/a'); + tick(10); + // The delayed guard should have started + expect(DelayedGuard.canLoadCalls).toEqual(1); + router.navigateByUrl('/team/1'); + advance(fixture, 1000); + 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 { + return of(delayMs).pipe(delay(delayMs), mapTo(true)); + } + + let log: string[]; + + beforeEach(() => { + log = []; + TestBed.configureTestingModule({ + providers: [ + { + provide: 'guard1', + useValue: () => { + return delayObservable(5).pipe(tap({next: () => log.push('guard1')})); + } + }, + { + provide: 'guard2', + useValue: () => { + return delayObservable(0).pipe(tap({next: () => log.push('guard2')})); + } + }, + { + provide: 'returnFalse', + useValue: () => { + log.push('returnFalse'); + return false; + } + }, + { + provide: 'returnFalseAndNavigate', + useFactory: (router: Router) => () => { + log.push('returnFalseAndNavigate'); + router.navigateByUrl('/redirected'); + return false; + }, + deps: [Router] + }, + { + provide: 'returnUrlTree', + useFactory: (router: Router) => () => { + return delayObservable(15).pipe( + mapTo(router.parseUrl('/redirected')), + tap({next: () => log.push('returnUrlTree')})); + }, + deps: [Router] + }, + ] + }); + }); + + it('should only execute canLoad guards of routes being activated', fakeAsync(() => { + 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) + }, + ]); + + router.navigateByUrl('/lazy'); + tick(5); + expect(log.length).toEqual(1); + expect(log).toEqual(['guard1']); + })); + + it('should execute canLoad guards', fakeAsync(inject([Router], (router: Router) => { + router.resetConfig([{ + path: 'lazy', + canLoad: ['guard1', 'guard2'], + loadChildren: () => ModuleWithBlankCmpAsRoute + }]); + + router.navigateByUrl('/lazy'); + tick(5); + + expect(log.length).toEqual(2); + expect(log).toEqual(['guard2', 'guard1']); + }))); + + it('should redirect with UrlTree if higher priority guards have resolved', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + router.resetConfig([ + { + path: 'lazy', + canLoad: ['returnUrlTree', 'guard1', 'guard2'], + loadChildren: () => ModuleWithBlankCmpAsRoute + }, + {path: 'redirected', component: SimpleCmp} + ]); + + router.navigateByUrl('/lazy'); + tick(15); + + 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', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + router.resetConfig([ + { + path: 'lazy', + canLoad: ['guard1', 'returnUrlTree'], + loadChildren: () => ModuleWithBlankCmpAsRoute + }, + {path: 'redirected', component: SimpleCmp} + ]); + + router.navigateByUrl('/lazy'); + tick(15); + + 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, { + provide: 'canActivateChild_parent', + useFactory: (logger: Logger) => () => (logger.add('canActivateChild_parent'), true), + deps: [Logger] + }, + { + provide: 'canActivate_team', + useFactory: (logger: Logger) => () => (logger.add('canActivate_team'), true), + deps: [Logger] + }, + { + provide: 'canDeactivate_team', + useFactory: (logger: Logger) => () => (logger.add('canDeactivate_team'), true), + deps: [Logger] + }, + { + provide: 'canDeactivate_simple', + useFactory: (logger: Logger) => () => (logger.add('canDeactivate_simple'), true), + deps: [Logger] + } + ] + }); + }); + + it('should call guards in the right order', + fakeAsync(inject( + [Router, Location, Logger], (router: Router, location: Location, logger: Logger) => { + const fixture = createRoot(router, RootCmp); + + router.resetConfig([{ + path: '', + canActivateChild: ['canActivateChild_parent'], + children: [{ + path: 'team/:id', + canActivate: ['canActivate_team'], + canDeactivate: ['canDeactivate_team'], + component: TeamCmp + }] + }]); + + router.navigateByUrl('/team/22'); + advance(fixture); + + router.navigateByUrl('/team/33'); + 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', + fakeAsync(inject( + [Router, Location, Logger], (router: Router, location: Location, logger: Logger) => { + const fixture = createRoot(router, RootCmp); + + router.resetConfig([{ + path: '', + children: [{ + path: 'team/:id', + canDeactivate: ['canDeactivate_team'], + children: [ + {path: '', component: SimpleCmp, canDeactivate: ['canDeactivate_simple']} + ], + component: TeamCmp + }] + }]); + + router.navigateByUrl('/team/22'); + advance(fixture); + + router.navigateByUrl('/team/33'); + advance(fixture); + + expect(logger.logs).toEqual(['canDeactivate_simple', 'canDeactivate_team']); + }))); + }); + + describe('canMatch', () => { + @Injectable({providedIn: 'root'}) + class ConfigurableGuard { + result: Promise|Observable|boolean|UrlTree = false; + canMatch() { + return this.result; + } + } + + it('falls back to second route when canMatch returns false', fakeAsync(() => { + const router = TestBed.inject(Router); + router.resetConfig([ + { + path: 'a', + canMatch: [() => coreInject(ConfigurableGuard).canMatch()], + component: BlankCmp + }, + {path: 'a', component: SimpleCmp}, + ]); + const fixture = createRoot(router, RootCmp); + + router.navigateByUrl('/a'); + advance(fixture); + expect(fixture.nativeElement.innerHTML).toContain('simple'); + })); + + it('uses route when canMatch returns true', fakeAsync(() => { + const router = TestBed.inject(Router); + TestBed.inject(ConfigurableGuard).result = Promise.resolve(true); + router.resetConfig([ + { + path: 'a', + canMatch: [() => coreInject(ConfigurableGuard).canMatch()], + component: SimpleCmp + }, + {path: 'a', component: BlankCmp}, + ]); + const fixture = createRoot(router, RootCmp); + + + router.navigateByUrl('/a'); + advance(fixture); + expect(fixture.nativeElement.innerHTML).toContain('simple'); + })); + + it('can return UrlTree from canMatch guard', fakeAsync(() => { + const router = TestBed.inject(Router); + TestBed.inject(ConfigurableGuard).result = + Promise.resolve(router.createUrlTree(['/team/1'])); + router.resetConfig([ + { + path: 'a', + canMatch: [() => coreInject(ConfigurableGuard).canMatch()], + component: SimpleCmp + }, + {path: 'team/:id', component: TeamCmp}, + ]); + const fixture = createRoot(router, RootCmp); + + + router.navigateByUrl('/a'); + advance(fixture); + expect(fixture.nativeElement.innerHTML).toContain('team'); + })); + + it('can return UrlTree from CanMatchFn guard', fakeAsync(() => { + 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 = createRoot(router, RootCmp); + + + router.navigateByUrl('/a'); + advance(fixture); + expect(fixture.nativeElement.innerHTML).toContain('team'); + })); + + it('runs canMatch guards provided in lazy module', fakeAsync(() => { + const router = TestBed.inject(Router); + @Component({ + selector: 'lazy', + template: 'lazy-loaded-parent []' + }) + class ParentLazyLoadedComponent { + } + + @Component({selector: 'lazy', template: 'lazy-loaded-child'}) + class ChildLazyLoadedComponent { + } + @Injectable() + class LazyCanMatchFalse { + canMatch() { + return false; + } + } + @Component({template: 'restricted'}) + 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 = createRoot(router, RootCmp); + + router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); + router.navigateByUrl('/lazy/loaded/child'); + 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', fakeAsync(() => { + @Injectable({providedIn: 'root'}) + class DelayedGuard { + static delayedExecutions = 0; + canMatch() { + return of(true).pipe(delay(1000), 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 = createRoot(router, RootCmp); + + + router.navigateByUrl('/a'); + 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('should allow guards as functions', fakeAsync(() => { + @Component({ + template: '', + standalone: true, + }) + class BlankCmp { + } + const router = TestBed.inject(Router); + const fixture = 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, + } ]); - const fixture = createRoot(router, RootCmp); + router.navigateByUrl('/'); + advance(fixture); + expect(guards.canMatch).toHaveBeenCalled(); + expect(guards.canLoad).toHaveBeenCalled(); + expect(guards.canActivate).toHaveBeenCalled(); + expect(guards.canActivateChild).toHaveBeenCalled(); - router.navigateByUrl('/a'); + router.navigateByUrl('/other'); advance(fixture); - expect(fixture.nativeElement.innerHTML).toContain('team'); + expect(guards.canDeactivate).toHaveBeenCalled(); })); - it('runs canMatch guards provided in lazy module', fakeAsync(() => { - const router = TestBed.inject(Router); - @Component( - {selector: 'lazy', template: 'lazy-loaded-parent []'}) - class ParentLazyLoadedComponent { - } - - @Component({selector: 'lazy', template: 'lazy-loaded-child'}) - class ChildLazyLoadedComponent { - } - @Injectable() - class LazyCanMatchFalse { - canMatch() { - return false; - } - } - @Component({template: 'restricted'}) - 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}] - } - ])] + it('should allow DI in plain function guards', fakeAsync(() => { + @Component({ + template: '', + standalone: true, }) - class LoadedModule { + class BlankCmp { } - - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); - router.navigateByUrl('/lazy/loaded/child'); - 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', fakeAsync(() => { @Injectable({providedIn: 'root'}) - class DelayedGuard { - static delayedExecutions = 0; - canMatch() { - return of(true).pipe(delay(1000), tap(() => { - DelayedGuard.delayedExecutions++; - })); - } + class State { + value = true; } 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; - }); + const fixture = createRoot(router, RootCmp); + const guards = { + canActivate() { + return coreInject(State).value; + }, + canDeactivate() { + return coreInject(State).value; + }, + canActivateChild() { + return coreInject(State).value; + }, + canMatch() { + return coreInject(State).value; + }, + canLoad() { + return coreInject(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: '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}, + { + 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, + } ]); - const fixture = createRoot(router, RootCmp); - - router.navigateByUrl('/a'); + router.navigateByUrl('/'); advance(fixture); - expect(fixture.nativeElement.innerHTML).toContain('team'); + expect(guards.canMatch).toHaveBeenCalled(); + expect(guards.canLoad).toHaveBeenCalled(); + expect(guards.canActivate).toHaveBeenCalled(); + expect(guards.canActivateChild).toHaveBeenCalled(); - 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); + router.navigateByUrl('/other'); + advance(fixture); + expect(guards.canDeactivate).toHaveBeenCalled(); })); - }); - it('should allow guards as functions', fakeAsync(() => { - @Component({ - template: '', - standalone: true, - }) - class BlankCmp { - } - const router = TestBed.inject(Router); - const fixture = 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, + it('can run functional guards serially', fakeAsync(() => { + function runSerially(guards: CanActivateFn[]|CanActivateChildFn[]): CanActivateFn| + CanActivateChildFn { + return (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { + const injector = coreInject(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()); + }; } - ]); - - router.navigateByUrl('/'); - advance(fixture); - expect(guards.canMatch).toHaveBeenCalled(); - expect(guards.canLoad).toHaveBeenCalled(); - expect(guards.canActivate).toHaveBeenCalled(); - expect(guards.canActivateChild).toHaveBeenCalled(); - - router.navigateByUrl('/other'); - advance(fixture); - expect(guards.canDeactivate).toHaveBeenCalled(); - })); - it('should allow DI in plain function guards', fakeAsync(() => { - @Component({ - template: '', - standalone: true, - }) - class BlankCmp { - } + const guardDone: string[] = []; - @Injectable({providedIn: 'root'}) - class State { - value = true; - } - const router = TestBed.inject(Router); - const fixture = createRoot(router, RootCmp); - const guards = { - canActivate() { - return coreInject(State).value; - }, - canDeactivate() { - return coreInject(State).value; - }, - canActivateChild() { - return coreInject(State).value; - }, - canMatch() { - return coreInject(State).value; - }, - canLoad() { - return coreInject(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', + const guard1: CanActivateFn = () => + of(true).pipe(delay(100), tap(() => guardDone.push('guard1'))); + const guard2: CanActivateFn = () => of(true).pipe(tap(() => guardDone.push('guard2'))); + const guard3: CanActivateFn = () => + of(true).pipe(delay(50), tap(() => guardDone.push('guard3'))); + const guard4: CanActivateFn = () => + of(true).pipe(delay(200), tap(() => guardDone.push('guard4'))); + const router = TestBed.inject(Router); + router.resetConfig([{ + path: '**', component: BlankCmp, - } - ]); - - router.navigateByUrl('/'); - advance(fixture); - expect(guards.canMatch).toHaveBeenCalled(); - expect(guards.canLoad).toHaveBeenCalled(); - expect(guards.canActivate).toHaveBeenCalled(); - expect(guards.canActivateChild).toHaveBeenCalled(); - - router.navigateByUrl('/other'); - advance(fixture); - expect(guards.canDeactivate).toHaveBeenCalled(); - })); - - it('can run functional guards serially', fakeAsync(() => { - function runSerially(guards: CanActivateFn[]|CanActivateChildFn[]): CanActivateFn| - CanActivateChildFn { - return (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { - const injector = coreInject(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[] = []; + canActivate: [runSerially([guard1, guard2, guard3, guard4])] + }]); + router.navigateByUrl(''); + + tick(100); + expect(guardDone).toEqual(['guard1', 'guard2']); + tick(50); + expect(guardDone).toEqual(['guard1', 'guard2', 'guard3']); + tick(200); + expect(guardDone).toEqual(['guard1', 'guard2', 'guard3', 'guard4']); + })); + }); - const guard1: CanActivateFn = () => - of(true).pipe(delay(100), tap(() => guardDone.push('guard1'))); - const guard2: CanActivateFn = () => of(true).pipe(tap(() => guardDone.push('guard2'))); - const guard3: CanActivateFn = () => - of(true).pipe(delay(50), tap(() => guardDone.push('guard3'))); - const guard4: CanActivateFn = () => - of(true).pipe(delay(200), tap(() => guardDone.push('guard4'))); - const router = TestBed.inject(Router); - router.resetConfig([{ - path: '**', - component: BlankCmp, - canActivate: [runSerially([guard1, guard2, guard3, guard4])] - }]); - router.navigateByUrl(''); - - tick(100); - expect(guardDone).toEqual(['guard1', 'guard2']); - tick(50); - expect(guardDone).toEqual(['guard1', 'guard2', 'guard3']); - tick(200); - expect(guardDone).toEqual(['guard1', 'guard2', 'guard3', 'guard4']); - })); - }); + describe('route events', () => { + it('should fire matching (Child)ActivationStart/End events', + fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - describe('route events', () => { - it('should fire matching (Child)ActivationStart/End events', - fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); + router.resetConfig([{path: 'user/:name', component: UserCmp}]); - router.resetConfig([{path: 'user/:name', component: UserCmp}]); + const recordedEvents: Event[] = []; + router.events.forEach(e => recordedEvents.push(e)); - const recordedEvents: Event[] = []; - router.events.forEach(e => recordedEvents.push(e)); + router.navigateByUrl('/user/fedor'); + advance(fixture); - router.navigateByUrl('/user/fedor'); - advance(fixture); + const event3 = recordedEvents[3] as ChildActivationStart; + const event9 = recordedEvents[9] as ChildActivationEnd; - const event3 = recordedEvents[3] as ChildActivationStart; - const event9 = recordedEvents[9] as ChildActivationEnd; + expect(fixture.nativeElement).toHaveText('user fedor'); + expect(event3 instanceof ChildActivationStart).toBe(true); + expect(event3.snapshot).toBe(event9.snapshot.root); + expect(event9 instanceof ChildActivationEnd).toBe(true); + expect(event9.snapshot).toBe(event9.snapshot.root); - expect(fixture.nativeElement).toHaveText('user fedor'); - expect(event3 instanceof ChildActivationStart).toBe(true); - expect(event3.snapshot).toBe(event9.snapshot.root); - expect(event9 instanceof ChildActivationEnd).toBe(true); - expect(event9.snapshot).toBe(event9.snapshot.root); + const event4 = recordedEvents[4] as ActivationStart; + const event8 = recordedEvents[8] as ActivationEnd; - const event4 = recordedEvents[4] as ActivationStart; - const event8 = recordedEvents[8] as ActivationEnd; + expect(event4 instanceof ActivationStart).toBe(true); + expect(event4.snapshot.routeConfig?.path).toBe('user/:name'); + expect(event8 instanceof ActivationEnd).toBe(true); + expect(event8.snapshot.routeConfig?.path).toBe('user/:name'); - expect(event4 instanceof ActivationStart).toBe(true); - expect(event4.snapshot.routeConfig?.path).toBe('user/:name'); - expect(event8 instanceof ActivationEnd).toBe(true); - expect(event8.snapshot.routeConfig?.path).toBe('user/:name'); + expectEvents(recordedEvents, [ + [NavigationStart, '/user/fedor'], [RoutesRecognized, '/user/fedor'], + [GuardsCheckStart, '/user/fedor'], [ChildActivationStart], [ActivationStart], + [GuardsCheckEnd, '/user/fedor'], [ResolveStart, '/user/fedor'], + [ResolveEnd, '/user/fedor'], [ActivationEnd], [ChildActivationEnd], + [NavigationEnd, '/user/fedor'] + ]); + }))); - expectEvents(recordedEvents, [ - [NavigationStart, '/user/fedor'], [RoutesRecognized, '/user/fedor'], - [GuardsCheckStart, '/user/fedor'], [ChildActivationStart], [ActivationStart], - [GuardsCheckEnd, '/user/fedor'], [ResolveStart, '/user/fedor'], - [ResolveEnd, '/user/fedor'], [ActivationEnd], [ChildActivationEnd], - [NavigationEnd, '/user/fedor'] - ]); - }))); + it('should allow redirection in NavigationStart', + fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - it('should allow redirection in NavigationStart', - fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); + router.resetConfig([ + {path: 'blank', component: UserCmp}, + {path: 'user/:name', component: BlankCmp}, + ]); - router.resetConfig([ - {path: 'blank', component: UserCmp}, - {path: 'user/:name', component: BlankCmp}, - ]); + const navigateSpy = spyOn(router, 'navigate').and.callThrough(); + const recordedEvents: Event[] = []; - const navigateSpy = spyOn(router, 'navigate').and.callThrough(); - const recordedEvents: Event[] = []; + const navStart$ = router.events.pipe( + tap(e => recordedEvents.push(e)), + filter((e): e is NavigationStart => e instanceof NavigationStart), first()); - const navStart$ = router.events.pipe( - tap(e => recordedEvents.push(e)), - filter((e): e is NavigationStart => e instanceof NavigationStart), first()); + navStart$.subscribe((e: NavigationStart|NavigationError) => { + router.navigate( + ['/blank'], {queryParams: {state: 'redirected'}, queryParamsHandling: 'merge'}); + advance(fixture); + }); - navStart$.subscribe((e: NavigationStart|NavigationError) => { - router.navigate( - ['/blank'], {queryParams: {state: 'redirected'}, queryParamsHandling: 'merge'}); + router.navigate(['/user/:fedor']); advance(fixture); - }); - - router.navigate(['/user/:fedor']); - advance(fixture); - - expect(navigateSpy.calls.mostRecent().args[1]!.queryParams); - }))); + expect(navigateSpy.calls.mostRecent().args[1]!.queryParams); + }))); - it('should stop emitting events after the router is destroyed', - fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); - router.resetConfig([{path: 'user/:name', component: UserCmp}]); - let events = 0; - const subscription = router.events.subscribe(() => events++); + it('should stop emitting events after the router is destroyed', + fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); + router.resetConfig([{path: 'user/:name', component: UserCmp}]); - router.navigateByUrl('/user/frodo'); - advance(fixture); - expect(events).toBeGreaterThan(0); + let events = 0; + const subscription = router.events.subscribe(() => events++); - const previousCount = events; - router.dispose(); - router.navigateByUrl('/user/bilbo'); - advance(fixture); + router.navigateByUrl('/user/frodo'); + advance(fixture); + expect(events).toBeGreaterThan(0); - expect(events).toBe(previousCount); - subscription.unsubscribe(); - }))); + const previousCount = events; + router.dispose(); + router.navigateByUrl('/user/bilbo'); + advance(fixture); - it('should resolve navigation promise with false after the router is destroyed', - fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); - let result = null as boolean | null; - const callback = (r: boolean) => result = r; - router.resetConfig([{path: 'user/:name', component: UserCmp}]); + expect(events).toBe(previousCount); + subscription.unsubscribe(); + }))); - router.navigateByUrl('/user/frodo').then(callback); - advance(fixture); - expect(result).toBe(true); - result = null as boolean | null; + it('should resolve navigation promise with false after the router is destroyed', + fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); + let result = null as boolean | null; + const callback = (r: boolean) => result = r; + router.resetConfig([{path: 'user/:name', component: UserCmp}]); - router.dispose(); + router.navigateByUrl('/user/frodo').then(callback); + advance(fixture); + expect(result).toBe(true); + result = null as boolean | null; - router.navigateByUrl('/user/bilbo').then(callback); - advance(fixture); - expect(result).toBe(false); - result = null as boolean | null; + router.dispose(); - router.navigate(['/user/bilbo']).then(callback); - advance(fixture); - expect(result).toBe(false); - }))); - }); + router.navigateByUrl('/user/bilbo').then(callback); + advance(fixture); + expect(result).toBe(false); + result = null as boolean | null; - describe('routerLinkActive', () => { - it('should set the class when the link is active (a tag)', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + router.navigate(['/user/bilbo']).then(callback); + advance(fixture); + expect(result).toBe(false); + }))); + }); - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: [{ - path: 'link', - component: DummyLinkCmp, - children: [{path: 'simple', component: SimpleCmp}, {path: '', component: BlankCmp}] - }] - }]); + describe('routerLinkActive', () => { + it('should set the class when the link is active (a tag)', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/team/22/link;exact=true'); - advance(fixture); - advance(fixture); - expect(location.path()).toEqual('/team/22/link;exact=true'); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [{ + path: 'link', + component: DummyLinkCmp, + children: [{path: 'simple', component: SimpleCmp}, {path: '', component: BlankCmp}] + }] + }]); - const nativeLink = fixture.nativeElement.querySelector('a'); - const nativeButton = fixture.nativeElement.querySelector('button'); - expect(nativeLink.className).toEqual('active'); - expect(nativeButton.className).toEqual('active'); + router.navigateByUrl('/team/22/link;exact=true'); + advance(fixture); + advance(fixture); + expect(location.path()).toEqual('/team/22/link;exact=true'); - router.navigateByUrl('/team/22/link/simple'); - advance(fixture); - expect(location.path()).toEqual('/team/22/link/simple'); - expect(nativeLink.className).toEqual(''); - expect(nativeButton.className).toEqual(''); - }))); + const nativeLink = fixture.nativeElement.querySelector('a'); + const nativeButton = fixture.nativeElement.querySelector('button'); + expect(nativeLink.className).toEqual('active'); + expect(nativeButton.className).toEqual('active'); - it('should not set the class until the first navigation succeeds', fakeAsync(() => { - @Component({ - template: - '' - }) - class RootCmpWithLink { - } + router.navigateByUrl('/team/22/link/simple'); + advance(fixture); + expect(location.path()).toEqual('/team/22/link/simple'); + expect(nativeLink.className).toEqual(''); + expect(nativeButton.className).toEqual(''); + }))); - TestBed.configureTestingModule({declarations: [RootCmpWithLink]}); - const router: Router = TestBed.inject(Router); + it('should not set the class until the first navigation succeeds', fakeAsync(() => { + @Component({ + template: + '' + }) + class RootCmpWithLink { + } - const f = TestBed.createComponent(RootCmpWithLink); - advance(f); + TestBed.configureTestingModule({declarations: [RootCmpWithLink]}); + const router: Router = TestBed.inject(Router); - const link = f.nativeElement.querySelector('a'); - expect(link.className).toEqual(''); + const f = TestBed.createComponent(RootCmpWithLink); + advance(f); - router.initialNavigation(); - advance(f); + const link = f.nativeElement.querySelector('a'); + expect(link.className).toEqual(''); - expect(link.className).toEqual('active'); - })); + router.initialNavigation(); + advance(f); + expect(link.className).toEqual('active'); + })); - it('should set the class on a parent element when the link is active', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: [{ - path: 'link', - component: DummyLinkWithParentCmp, - children: [{path: 'simple', component: SimpleCmp}, {path: '', component: BlankCmp}] - }] - }]); + it('should set the class on a parent element when the link is active', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/team/22/link;exact=true'); - advance(fixture); - advance(fixture); - expect(location.path()).toEqual('/team/22/link;exact=true'); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [{ + path: 'link', + component: DummyLinkWithParentCmp, + children: [{path: 'simple', component: SimpleCmp}, {path: '', component: BlankCmp}] + }] + }]); - const native = fixture.nativeElement.querySelector('#link-parent'); - expect(native.className).toEqual('active'); + router.navigateByUrl('/team/22/link;exact=true'); + advance(fixture); + advance(fixture); + expect(location.path()).toEqual('/team/22/link;exact=true'); - router.navigateByUrl('/team/22/link/simple'); - advance(fixture); - expect(location.path()).toEqual('/team/22/link/simple'); - expect(native.className).toEqual(''); - }))); + const native = fixture.nativeElement.querySelector('#link-parent'); + expect(native.className).toEqual('active'); - it('should set the class when the link is active', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + router.navigateByUrl('/team/22/link/simple'); + advance(fixture); + expect(location.path()).toEqual('/team/22/link/simple'); + expect(native.className).toEqual(''); + }))); - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: [{ - path: 'link', - component: DummyLinkCmp, - children: [{path: 'simple', component: SimpleCmp}, {path: '', component: BlankCmp}] - }] - }]); + it('should set the class when the link is active', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/team/22/link'); - advance(fixture); - advance(fixture); - expect(location.path()).toEqual('/team/22/link'); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [{ + path: 'link', + component: DummyLinkCmp, + children: [{path: 'simple', component: SimpleCmp}, {path: '', component: BlankCmp}] + }] + }]); - const native = fixture.nativeElement.querySelector('a'); - expect(native.className).toEqual('active'); + router.navigateByUrl('/team/22/link'); + advance(fixture); + advance(fixture); + expect(location.path()).toEqual('/team/22/link'); - router.navigateByUrl('/team/22/link/simple'); - advance(fixture); - expect(location.path()).toEqual('/team/22/link/simple'); - expect(native.className).toEqual('active'); - }))); + const native = fixture.nativeElement.querySelector('a'); + expect(native.className).toEqual('active'); - it('should expose an isActive property', fakeAsync(() => { - @Component({ - template: ` + router.navigateByUrl('/team/22/link/simple'); + advance(fixture); + expect(location.path()).toEqual('/team/22/link/simple'); + expect(native.className).toEqual('active'); + }))); + + it('should expose an isActive property', fakeAsync(() => { + @Component({ + template: `

{{rla.isActive}}

` - }) - class ComponentWithRouterLink { - } + }) + class ComponentWithRouterLink { + } - TestBed.configureTestingModule({declarations: [ComponentWithRouterLink]}); - const router: Router = TestBed.inject(Router); + TestBed.configureTestingModule({declarations: [ComponentWithRouterLink]}); + const router: Router = TestBed.inject(Router); - router.resetConfig([ - { - path: 'team', - component: TeamCmp, - }, - { - path: 'otherteam', - component: TeamCmp, - } - ]); + router.resetConfig([ + { + path: 'team', + component: TeamCmp, + }, + { + path: 'otherteam', + component: TeamCmp, + } + ]); - const fixture = TestBed.createComponent(ComponentWithRouterLink); - router.navigateByUrl('/team'); - expect(() => advance(fixture)).not.toThrow(); - advance(fixture); + const fixture = TestBed.createComponent(ComponentWithRouterLink); + router.navigateByUrl('/team'); + expect(() => advance(fixture)).not.toThrow(); + advance(fixture); - const paragraph = fixture.nativeElement.querySelector('p'); - expect(paragraph.textContent).toEqual('true'); + const paragraph = fixture.nativeElement.querySelector('p'); + expect(paragraph.textContent).toEqual('true'); - router.navigateByUrl('/otherteam'); - advance(fixture); - advance(fixture); - expect(paragraph.textContent).toEqual('false'); - })); + router.navigateByUrl('/otherteam'); + advance(fixture); + advance(fixture); + expect(paragraph.textContent).toEqual('false'); + })); - it('should not trigger change detection when active state has not changed', fakeAsync(() => { - @Component({ - template: ``, - }) - class LinkComponent { - link = 'notactive'; - } + it('should not trigger change detection when active state has not changed', fakeAsync(() => { + @Component({ + template: ``, + }) + class LinkComponent { + link = 'notactive'; + } - @Component({template: ''}) - class SimpleComponent { - } + @Component({template: ''}) + class SimpleComponent { + } - TestBed.configureTestingModule({ - imports: [ - ...ROUTER_DIRECTIVES, - ], - providers: [ - provideRouter([{path: '', component: SimpleComponent}]), - ], - declarations: [LinkComponent, SimpleComponent] - }); + TestBed.configureTestingModule({ + imports: [ + ...ROUTER_DIRECTIVES, + ], + providers: [ + provideRouter([{path: '', component: SimpleComponent}]), + ], + declarations: [LinkComponent, SimpleComponent] + }); - const fixture = createRoot(TestBed.inject(Router), LinkComponent); - fixture.componentInstance.link = 'stillnotactive'; - fixture.detectChanges(false /** checkNoChanges */); - expect(TestBed.inject(NgZone).hasPendingMicrotasks).toBe(false); - })); + const fixture = createRoot(TestBed.inject(Router), LinkComponent); + fixture.componentInstance.link = 'stillnotactive'; + fixture.detectChanges(false /** checkNoChanges */); + expect(TestBed.inject(NgZone).hasPendingMicrotasks).toBe(false); + })); - it('should emit on isActiveChange output when link is activated or inactivated', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + it('should emit on isActiveChange output when link is activated or inactivated', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: [{ - path: 'link', - component: DummyLinkCmp, - children: [{path: 'simple', component: SimpleCmp}, {path: '', component: BlankCmp}] - }] - }]); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [{ + path: 'link', + component: DummyLinkCmp, + children: [{path: 'simple', component: SimpleCmp}, {path: '', component: BlankCmp}] + }] + }]); - router.navigateByUrl('/team/22/link;exact=true'); - advance(fixture); - advance(fixture); - expect(location.path()).toEqual('/team/22/link;exact=true'); + router.navigateByUrl('/team/22/link;exact=true'); + advance(fixture); + advance(fixture); + expect(location.path()).toEqual('/team/22/link;exact=true'); - const linkComponent = - fixture.debugElement.query(By.directive(DummyLinkCmp)).componentInstance as - DummyLinkCmp; + const linkComponent = + fixture.debugElement.query(By.directive(DummyLinkCmp)).componentInstance as + DummyLinkCmp; - expect(linkComponent.isLinkActivated).toEqual(true); - const nativeLink = fixture.nativeElement.querySelector('a'); - const nativeButton = fixture.nativeElement.querySelector('button'); - expect(nativeLink.className).toEqual('active'); - expect(nativeButton.className).toEqual('active'); + expect(linkComponent.isLinkActivated).toEqual(true); + const nativeLink = fixture.nativeElement.querySelector('a'); + const nativeButton = fixture.nativeElement.querySelector('button'); + expect(nativeLink.className).toEqual('active'); + expect(nativeButton.className).toEqual('active'); - router.navigateByUrl('/team/22/link/simple'); - advance(fixture); - expect(location.path()).toEqual('/team/22/link/simple'); - expect(linkComponent.isLinkActivated).toEqual(false); - expect(nativeLink.className).toEqual(''); - expect(nativeButton.className).toEqual(''); - }))); + router.navigateByUrl('/team/22/link/simple'); + advance(fixture); + expect(location.path()).toEqual('/team/22/link/simple'); + expect(linkComponent.isLinkActivated).toEqual(false); + expect(nativeLink.className).toEqual(''); + expect(nativeButton.className).toEqual(''); + }))); - it('should set a provided aria-current attribute when the link is active (a tag)', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + it('should set a provided aria-current attribute when the link is active (a tag)', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: [{ - path: 'link', - component: DummyLinkCmp, - children: [{path: 'simple', component: SimpleCmp}, {path: '', component: BlankCmp}] - }] - }]); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [{ + path: 'link', + component: DummyLinkCmp, + children: [{path: 'simple', component: SimpleCmp}, {path: '', component: BlankCmp}] + }] + }]); - router.navigateByUrl('/team/22/link;exact=true'); - advance(fixture); - advance(fixture); - expect(location.path()).toEqual('/team/22/link;exact=true'); + router.navigateByUrl('/team/22/link;exact=true'); + advance(fixture); + advance(fixture); + expect(location.path()).toEqual('/team/22/link;exact=true'); - const nativeLink = fixture.nativeElement.querySelector('a'); - const nativeButton = fixture.nativeElement.querySelector('button'); - expect(nativeLink.getAttribute('aria-current')).toEqual('page'); - expect(nativeButton.hasAttribute('aria-current')).toEqual(false); + const nativeLink = fixture.nativeElement.querySelector('a'); + const nativeButton = fixture.nativeElement.querySelector('button'); + expect(nativeLink.getAttribute('aria-current')).toEqual('page'); + expect(nativeButton.hasAttribute('aria-current')).toEqual(false); - router.navigateByUrl('/team/22/link/simple'); - advance(fixture); - expect(location.path()).toEqual('/team/22/link/simple'); - expect(nativeLink.hasAttribute('aria-current')).toEqual(false); - expect(nativeButton.hasAttribute('aria-current')).toEqual(false); - }))); - }); + router.navigateByUrl('/team/22/link/simple'); + advance(fixture); + expect(location.path()).toEqual('/team/22/link/simple'); + expect(nativeLink.hasAttribute('aria-current')).toEqual(false); + expect(nativeButton.hasAttribute('aria-current')).toEqual(false); + }))); + }); - describe('lazy loading', () => { - it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - @Component( - {selector: 'lazy', template: 'lazy-loaded-parent []'}) - class ParentLazyLoadedComponent { - } + describe('lazy loading', () => { + it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + @Component( + {selector: 'lazy', template: 'lazy-loaded-parent []'}) + class ParentLazyLoadedComponent { + } - @Component({selector: 'lazy', template: 'lazy-loaded-child'}) - class ChildLazyLoadedComponent { - } + @Component({selector: 'lazy', template: 'lazy-loaded-child'}) + class ChildLazyLoadedComponent { + } - @NgModule({ - declarations: [ParentLazyLoadedComponent, ChildLazyLoadedComponent], - imports: [RouterModule.forChild([{ - path: 'loaded', - component: ParentLazyLoadedComponent, - children: [{path: 'child', component: ChildLazyLoadedComponent}] - }])] - }) - class LoadedModule { - } + @NgModule({ + declarations: [ParentLazyLoadedComponent, ChildLazyLoadedComponent], + imports: [RouterModule.forChild([{ + path: 'loaded', + component: ParentLazyLoadedComponent, + children: [{path: 'child', component: ChildLazyLoadedComponent}] + }])] + }) + class LoadedModule { + } - const fixture = createRoot(router, RootCmp); + const fixture = createRoot(router, RootCmp); - router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); + router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); - router.navigateByUrl('/lazy/loaded/child'); - advance(fixture); + router.navigateByUrl('/lazy/loaded/child'); + advance(fixture); - expect(location.path()).toEqual('/lazy/loaded/child'); - expect(fixture.nativeElement).toHaveText('lazy-loaded-parent [lazy-loaded-child]'); - }))); + expect(location.path()).toEqual('/lazy/loaded/child'); + expect(fixture.nativeElement).toHaveText('lazy-loaded-parent [lazy-loaded-child]'); + }))); - it('should have 2 injector trees: module and element', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - @Component({ - selector: 'lazy', - template: 'parent[]', - viewProviders: [ - {provide: 'shadow', useValue: 'from parent component'}, - ], - }) - class Parent { - } + it('should have 2 injector trees: module and element', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + @Component({ + selector: 'lazy', + template: 'parent[]', + viewProviders: [ + {provide: 'shadow', useValue: 'from parent component'}, + ], + }) + class Parent { + } - @Component({selector: 'lazy', template: 'child'}) - class Child { - } + @Component({selector: 'lazy', template: 'child'}) + class Child { + } - @NgModule({ - declarations: [Parent], - imports: [RouterModule.forChild([{ - path: 'parent', - component: Parent, - children: [ - {path: 'child', loadChildren: () => ChildModule}, - ] - }])], - providers: [ - {provide: 'moduleName', useValue: 'parent'}, - {provide: 'fromParent', useValue: 'from parent'}, - ], - }) - class ParentModule { - } + @NgModule({ + declarations: [Parent], + imports: [RouterModule.forChild([{ + path: 'parent', + component: Parent, + children: [ + {path: 'child', loadChildren: () => ChildModule}, + ] + }])], + providers: [ + {provide: 'moduleName', useValue: 'parent'}, + {provide: 'fromParent', useValue: 'from parent'}, + ], + }) + class ParentModule { + } - @NgModule({ - declarations: [Child], - imports: [RouterModule.forChild([{path: '', component: Child}])], - providers: [ - {provide: 'moduleName', useValue: 'child'}, - {provide: 'fromChild', useValue: 'from child'}, - {provide: 'shadow', useValue: 'from child module'}, - ], - }) - class ChildModule { - } + @NgModule({ + declarations: [Child], + imports: [RouterModule.forChild([{path: '', component: Child}])], + providers: [ + {provide: 'moduleName', useValue: 'child'}, + {provide: 'fromChild', useValue: 'from child'}, + {provide: 'shadow', useValue: 'from child module'}, + ], + }) + class ChildModule { + } - const fixture = createRoot(router, RootCmp); - router.resetConfig([{path: 'lazy', loadChildren: () => ParentModule}]); - router.navigateByUrl('/lazy/parent/child'); - advance(fixture); - expect(location.path()).toEqual('/lazy/parent/child'); - expect(fixture.nativeElement).toHaveText('parent[child]'); - - const pInj = fixture.debugElement.query(By.directive(Parent)).injector!; - const cInj = fixture.debugElement.query(By.directive(Child)).injector!; - - expect(pInj.get('moduleName')).toEqual('parent'); - expect(pInj.get('fromParent')).toEqual('from parent'); - expect(pInj.get(Parent)).toBeInstanceOf(Parent); - expect(pInj.get('fromChild', null)).toEqual(null); - expect(pInj.get(Child, null)).toEqual(null); - - expect(cInj.get('moduleName')).toEqual('child'); - expect(cInj.get('fromParent')).toEqual('from parent'); - expect(cInj.get('fromChild')).toEqual('from child'); - expect(cInj.get(Parent)).toBeInstanceOf(Parent); - expect(cInj.get(Child)).toBeInstanceOf(Child); - // The child module can not shadow the parent component - expect(cInj.get('shadow')).toEqual('from parent component'); - - const pmInj = pInj.get(NgModuleRef).injector; - const cmInj = cInj.get(NgModuleRef).injector; - - expect(pmInj.get('moduleName')).toEqual('parent'); - expect(cmInj.get('moduleName')).toEqual('child'); - - expect(pmInj.get(Parent, '-')).toEqual('-'); - expect(cmInj.get(Parent, '-')).toEqual('-'); - expect(pmInj.get(Child, '-')).toEqual('-'); - expect(cmInj.get(Child, '-')).toEqual('-'); - }))); + const fixture = createRoot(router, RootCmp); + router.resetConfig([{path: 'lazy', loadChildren: () => ParentModule}]); + router.navigateByUrl('/lazy/parent/child'); + advance(fixture); + expect(location.path()).toEqual('/lazy/parent/child'); + expect(fixture.nativeElement).toHaveText('parent[child]'); + + const pInj = fixture.debugElement.query(By.directive(Parent)).injector!; + const cInj = fixture.debugElement.query(By.directive(Child)).injector!; + + expect(pInj.get('moduleName')).toEqual('parent'); + expect(pInj.get('fromParent')).toEqual('from parent'); + expect(pInj.get(Parent)).toBeInstanceOf(Parent); + expect(pInj.get('fromChild', null)).toEqual(null); + expect(pInj.get(Child, null)).toEqual(null); + + expect(cInj.get('moduleName')).toEqual('child'); + expect(cInj.get('fromParent')).toEqual('from parent'); + expect(cInj.get('fromChild')).toEqual('from child'); + expect(cInj.get(Parent)).toBeInstanceOf(Parent); + expect(cInj.get(Child)).toBeInstanceOf(Child); + // The child module can not shadow the parent component + expect(cInj.get('shadow')).toEqual('from parent component'); + + const pmInj = pInj.get(NgModuleRef).injector; + const cmInj = cInj.get(NgModuleRef).injector; + + expect(pmInj.get('moduleName')).toEqual('parent'); + expect(cmInj.get('moduleName')).toEqual('child'); + + expect(pmInj.get(Parent, '-')).toEqual('-'); + expect(cmInj.get(Parent, '-')).toEqual('-'); + expect(pmInj.get(Child, '-')).toEqual('-'); + expect(cmInj.get(Child, '-')).toEqual('-'); + }))); - // https://github.com/angular/angular/issues/12889 - it('should create a single instance of lazy-loaded modules', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - @Component( - {selector: 'lazy', template: 'lazy-loaded-parent []'}) - class ParentLazyLoadedComponent { - } + // https://github.com/angular/angular/issues/12889 + it('should create a single instance of lazy-loaded modules', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + @Component( + {selector: 'lazy', template: 'lazy-loaded-parent []'}) + class ParentLazyLoadedComponent { + } - @Component({selector: 'lazy', template: 'lazy-loaded-child'}) - class ChildLazyLoadedComponent { - } + @Component({selector: 'lazy', template: 'lazy-loaded-child'}) + class ChildLazyLoadedComponent { + } - @NgModule({ - declarations: [ParentLazyLoadedComponent, ChildLazyLoadedComponent], - imports: [RouterModule.forChild([{ - path: 'loaded', - component: ParentLazyLoadedComponent, - children: [{path: 'child', component: ChildLazyLoadedComponent}] - }])] - }) - class LoadedModule { - static instances = 0; - constructor() { - LoadedModule.instances++; + @NgModule({ + declarations: [ParentLazyLoadedComponent, ChildLazyLoadedComponent], + imports: [RouterModule.forChild([{ + path: 'loaded', + component: ParentLazyLoadedComponent, + children: [{path: 'child', component: ChildLazyLoadedComponent}] + }])] + }) + class LoadedModule { + static instances = 0; + constructor() { + LoadedModule.instances++; + } } - } - const fixture = createRoot(router, RootCmp); - router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); - router.navigateByUrl('/lazy/loaded/child'); - advance(fixture); - expect(fixture.nativeElement).toHaveText('lazy-loaded-parent [lazy-loaded-child]'); - expect(LoadedModule.instances).toEqual(1); - }))); + const fixture = createRoot(router, RootCmp); + router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); + router.navigateByUrl('/lazy/loaded/child'); + advance(fixture); + expect(fixture.nativeElement).toHaveText('lazy-loaded-parent [lazy-loaded-child]'); + expect(LoadedModule.instances).toEqual(1); + }))); - // https://github.com/angular/angular/issues/13870 - it('should create a single instance of guards for lazy-loaded modules', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - @Injectable() - class Service { - } + // https://github.com/angular/angular/issues/13870 + it('should create a single instance of guards for lazy-loaded modules', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + @Injectable() + class Service { + } - @Injectable() - class Resolver { - constructor(public service: Service) {} - resolve() { - return this.service; + @Injectable() + class Resolver { + constructor(public service: Service) {} + resolve() { + return this.service; + } } - } - @Component({selector: 'lazy', template: 'lazy'}) - class LazyLoadedComponent { - resolvedService: Service; - constructor(public injectedService: Service, route: ActivatedRoute) { - this.resolvedService = route.snapshot.data['service']; + @Component({selector: 'lazy', template: 'lazy'}) + class LazyLoadedComponent { + resolvedService: Service; + constructor(public injectedService: Service, route: ActivatedRoute) { + this.resolvedService = route.snapshot.data['service']; + } } - } - @NgModule({ - declarations: [LazyLoadedComponent], - providers: [Service, Resolver], - imports: [ - RouterModule.forChild([{ - path: 'loaded', - component: LazyLoadedComponent, - resolve: {'service': () => coreInject(Resolver).resolve()}, - }]), - ] - }) - class LoadedModule { - } + @NgModule({ + declarations: [LazyLoadedComponent], + providers: [Service, Resolver], + imports: [ + RouterModule.forChild([{ + path: 'loaded', + component: LazyLoadedComponent, + resolve: {'service': () => coreInject(Resolver).resolve()}, + }]), + ] + }) + class LoadedModule { + } - const fixture = createRoot(router, RootCmp); - router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); - router.navigateByUrl('/lazy/loaded'); - advance(fixture); + const fixture = createRoot(router, RootCmp); + router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); + router.navigateByUrl('/lazy/loaded'); + advance(fixture); - expect(fixture.nativeElement).toHaveText('lazy'); - const lzc = - fixture.debugElement.query(By.directive(LazyLoadedComponent)).componentInstance; - expect(lzc.injectedService).toBe(lzc.resolvedService); - }))); + expect(fixture.nativeElement).toHaveText('lazy'); + const lzc = + fixture.debugElement.query(By.directive(LazyLoadedComponent)).componentInstance; + expect(lzc.injectedService).toBe(lzc.resolvedService); + }))); - it('should emit RouteConfigLoadStart and RouteConfigLoadEnd event when route is lazy loaded', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - @Component({ - selector: 'lazy', - template: 'lazy-loaded-parent []', - }) - class ParentLazyLoadedComponent { - } + it('should emit RouteConfigLoadStart and RouteConfigLoadEnd event when route is lazy loaded', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + @Component({ + selector: 'lazy', + template: 'lazy-loaded-parent []', + }) + class ParentLazyLoadedComponent { + } + + @Component({selector: 'lazy', template: 'lazy-loaded-child'}) + class ChildLazyLoadedComponent { + } + + @NgModule({ + declarations: [ParentLazyLoadedComponent, ChildLazyLoadedComponent], + imports: [RouterModule.forChild([{ + path: 'loaded', + component: ParentLazyLoadedComponent, + children: [{path: 'child', component: ChildLazyLoadedComponent}], + }])] + }) + class LoadedModule { + } - @Component({selector: 'lazy', template: 'lazy-loaded-child'}) - class ChildLazyLoadedComponent { - } + const events: Array = []; - @NgModule({ - declarations: [ParentLazyLoadedComponent, ChildLazyLoadedComponent], - imports: [RouterModule.forChild([{ - path: 'loaded', - component: ParentLazyLoadedComponent, - children: [{path: 'child', component: ChildLazyLoadedComponent}], - }])] - }) - class LoadedModule { - } + router.events.subscribe(e => { + if (e instanceof RouteConfigLoadStart || e instanceof RouteConfigLoadEnd) { + events.push(e); + } + }); - const events: Array = []; + const fixture = createRoot(router, RootCmp); + router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); - router.events.subscribe(e => { - if (e instanceof RouteConfigLoadStart || e instanceof RouteConfigLoadEnd) { - events.push(e); + router.navigateByUrl('/lazy/loaded/child'); + advance(fixture); + + expect(events.length).toEqual(2); + expect(events[0].toString()).toEqual('RouteConfigLoadStart(path: lazy)'); + expect(events[1].toString()).toEqual('RouteConfigLoadEnd(path: lazy)'); + }))); + + it('throws an error when forRoot() is used in a lazy context', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + @Component({selector: 'lazy', template: 'should not show'}) + class LazyLoadedComponent { } - }); - const fixture = createRoot(router, RootCmp); - router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); + @NgModule({ + declarations: [LazyLoadedComponent], + imports: [RouterModule.forRoot([{path: 'loaded', component: LazyLoadedComponent}])] + }) + class LoadedModule { + } - router.navigateByUrl('/lazy/loaded/child'); - advance(fixture); + const fixture = createRoot(router, RootCmp); - expect(events.length).toEqual(2); - expect(events[0].toString()).toEqual('RouteConfigLoadStart(path: lazy)'); - expect(events[1].toString()).toEqual('RouteConfigLoadEnd(path: lazy)'); - }))); + router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); - it('throws an error when forRoot() is used in a lazy context', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - @Component({selector: 'lazy', template: 'should not show'}) - class LazyLoadedComponent { - } + let recordedError: any = null; + router.navigateByUrl('/lazy/loaded')!.catch(err => recordedError = err); + advance(fixture); + expect(recordedError.message).toContain(`NG04007`); + }))); - @NgModule({ - declarations: [LazyLoadedComponent], - imports: [RouterModule.forRoot([{path: 'loaded', component: LazyLoadedComponent}])] - }) - class LoadedModule { - } + it('should combine routes from multiple modules into a single configuration', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + @Component({selector: 'lazy', template: 'lazy-loaded-2'}) + class LazyComponent2 { + } - const fixture = createRoot(router, RootCmp); + @NgModule({ + declarations: [LazyComponent2], + imports: [RouterModule.forChild([{path: 'loaded', component: LazyComponent2}])] + }) + class SiblingOfLoadedModule { + } - router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); + @Component({selector: 'lazy', template: 'lazy-loaded-1'}) + class LazyComponent1 { + } - let recordedError: any = null; - router.navigateByUrl('/lazy/loaded')!.catch(err => recordedError = err); - advance(fixture); - expect(recordedError.message).toContain(`NG04007`); - }))); + @NgModule({ + declarations: [LazyComponent1], + imports: [ + RouterModule.forChild([{path: 'loaded', component: LazyComponent1}]), + SiblingOfLoadedModule + ] + }) + class LoadedModule { + } - it('should combine routes from multiple modules into a single configuration', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - @Component({selector: 'lazy', template: 'lazy-loaded-2'}) - class LazyComponent2 { - } + const fixture = createRoot(router, RootCmp); - @NgModule({ - declarations: [LazyComponent2], - imports: [RouterModule.forChild([{path: 'loaded', component: LazyComponent2}])] - }) - class SiblingOfLoadedModule { - } + router.resetConfig([ + {path: 'lazy1', loadChildren: () => LoadedModule}, + {path: 'lazy2', loadChildren: () => SiblingOfLoadedModule} + ]); - @Component({selector: 'lazy', template: 'lazy-loaded-1'}) - class LazyComponent1 { - } + router.navigateByUrl('/lazy1/loaded'); + advance(fixture); + expect(location.path()).toEqual('/lazy1/loaded'); - @NgModule({ - declarations: [LazyComponent1], - imports: [ - RouterModule.forChild([{path: 'loaded', component: LazyComponent1}]), - SiblingOfLoadedModule - ] - }) - class LoadedModule { - } + router.navigateByUrl('/lazy2/loaded'); + advance(fixture); + expect(location.path()).toEqual('/lazy2/loaded'); + }))); - const fixture = createRoot(router, RootCmp); + it('should allow lazy loaded module in named outlet', + fakeAsync(inject([Router], (router: Router) => { + @Component({selector: 'lazy', template: 'lazy-loaded'}) + class LazyComponent { + } - router.resetConfig([ - {path: 'lazy1', loadChildren: () => LoadedModule}, - {path: 'lazy2', loadChildren: () => SiblingOfLoadedModule} - ]); + @NgModule({ + declarations: [LazyComponent], + imports: [RouterModule.forChild([{path: '', component: LazyComponent}])] + }) + class LazyLoadedModule { + } - router.navigateByUrl('/lazy1/loaded'); - advance(fixture); - expect(location.path()).toEqual('/lazy1/loaded'); + const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/lazy2/loaded'); - advance(fixture); - expect(location.path()).toEqual('/lazy2/loaded'); - }))); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [ + {path: 'user/:name', component: UserCmp}, + {path: 'lazy', loadChildren: () => LazyLoadedModule, outlet: 'right'}, + ] + }]); - it('should allow lazy loaded module in named outlet', - fakeAsync(inject([Router], (router: Router) => { - @Component({selector: 'lazy', template: 'lazy-loaded'}) - class LazyComponent { - } - @NgModule({ - declarations: [LazyComponent], - imports: [RouterModule.forChild([{path: '', component: LazyComponent}])] - }) - class LazyLoadedModule { - } + router.navigateByUrl('/team/22/user/john'); + advance(fixture); - const fixture = createRoot(router, RootCmp); + expect(fixture.nativeElement).toHaveText('team 22 [ user john, right: ]'); - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: [ - {path: 'user/:name', component: UserCmp}, - {path: 'lazy', loadChildren: () => LazyLoadedModule, outlet: 'right'}, - ] - }]); + router.navigateByUrl('/team/22/(user/john//right:lazy)'); + advance(fixture); + expect(fixture.nativeElement).toHaveText('team 22 [ user john, right: lazy-loaded ]'); + }))); - router.navigateByUrl('/team/22/user/john'); - advance(fixture); + it('should allow componentless named outlet to render children', + fakeAsync(inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); - expect(fixture.nativeElement).toHaveText('team 22 [ user john, right: ]'); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [ + {path: 'user/:name', component: UserCmp}, + {path: 'simple', outlet: 'right', children: [{path: '', component: SimpleCmp}]}, + ] + }]); - router.navigateByUrl('/team/22/(user/john//right:lazy)'); - advance(fixture); - expect(fixture.nativeElement).toHaveText('team 22 [ user john, right: lazy-loaded ]'); - }))); + router.navigateByUrl('/team/22/user/john'); + advance(fixture); - it('should allow componentless named outlet to render children', - fakeAsync(inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); + expect(fixture.nativeElement).toHaveText('team 22 [ user john, right: ]'); - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: [ - {path: 'user/:name', component: UserCmp}, - {path: 'simple', outlet: 'right', children: [{path: '', component: SimpleCmp}]}, - ] - }]); + router.navigateByUrl('/team/22/(user/john//right:simple)'); + advance(fixture); + expect(fixture.nativeElement).toHaveText('team 22 [ user john, right: simple ]'); + }))); - router.navigateByUrl('/team/22/user/john'); - advance(fixture); + it('should render loadComponent named outlet with children', fakeAsync(() => { + const router = TestBed.inject(Router); + const fixture = createRoot(router, RootCmp); - expect(fixture.nativeElement).toHaveText('team 22 [ user john, right: ]'); + @Component({ + standalone: true, + imports: [RouterModule], + template: '[right outlet component: ]', + }) + class RightComponent { + constructor(readonly route: ActivatedRoute) {} + } - router.navigateByUrl('/team/22/(user/john//right:simple)'); - advance(fixture); + const loadSpy = jasmine.createSpy(); + loadSpy.and.returnValue(RightComponent); - expect(fixture.nativeElement).toHaveText('team 22 [ user john, right: simple ]'); - }))); + router.resetConfig([ + { + path: 'team/:id', + component: TeamCmp, + children: [ + {path: 'user/:name', component: UserCmp}, + { + path: 'simple', + loadComponent: loadSpy, + outlet: 'right', + children: [{path: '', component: SimpleCmp}] + }, + ] + }, + {path: '', component: SimpleCmp} + ]); - it('should render loadComponent named outlet with children', fakeAsync(() => { - const router = TestBed.inject(Router); - const fixture = createRoot(router, RootCmp); + router.navigateByUrl('/team/22/(user/john//right:simple)'); + advance(fixture); - @Component({ - standalone: true, - imports: [RouterModule], - template: '[right outlet component: ]', - }) - class RightComponent { - constructor(readonly route: ActivatedRoute) {} - } + expect(fixture.nativeElement) + .toHaveText('team 22 [ user john, right: [right outlet component: simple] ]'); + const rightCmp: RightComponent = + fixture.debugElement.query(By.directive(RightComponent)).componentInstance; + // Ensure we don't accidentally add `EmptyOutletComponent` via `standardizeConfig` + expect(rightCmp.route.routeConfig?.component).not.toBeDefined(); - const loadSpy = jasmine.createSpy(); - loadSpy.and.returnValue(RightComponent); + // Ensure we can navigate away and come back + router.navigateByUrl('/'); + advance(fixture); + router.navigateByUrl('/team/22/(user/john//right:simple)'); + advance(fixture); + expect(fixture.nativeElement) + .toHaveText('team 22 [ user john, right: [right outlet component: simple] ]'); + expect(loadSpy.calls.count()).toEqual(1); + })); - router.resetConfig([ - { - path: 'team/:id', - component: TeamCmp, - children: [ - {path: 'user/:name', component: UserCmp}, - { - path: 'simple', - loadComponent: loadSpy, - outlet: 'right', - children: [{path: '', component: SimpleCmp}] - }, - ] - }, - {path: '', component: SimpleCmp} - ]); + describe('should use the injector of the lazily-loaded configuration', () => { + class LazyLoadedServiceDefinedInModule {} - router.navigateByUrl('/team/22/(user/john//right:simple)'); - advance(fixture); + @Component({ + selector: 'eager-parent', + template: 'eager-parent ', + }) + class EagerParentComponent { + } - expect(fixture.nativeElement) - .toHaveText('team 22 [ user john, right: [right outlet component: simple] ]'); - const rightCmp: RightComponent = - fixture.debugElement.query(By.directive(RightComponent)).componentInstance; - // Ensure we don't accidentally add `EmptyOutletComponent` via `standardizeConfig` - expect(rightCmp.route.routeConfig?.component).not.toBeDefined(); + @Component({ + selector: 'lazy-parent', + template: 'lazy-parent ', + }) + class LazyParentComponent { + } - // Ensure we can navigate away and come back - router.navigateByUrl('/'); - advance(fixture); - router.navigateByUrl('/team/22/(user/john//right:simple)'); - advance(fixture); - expect(fixture.nativeElement) - .toHaveText('team 22 [ user john, right: [right outlet component: simple] ]'); - expect(loadSpy.calls.count()).toEqual(1); - })); + @Component({ + selector: 'lazy-child', + template: 'lazy-child', + }) + class LazyChildComponent { + constructor( + lazy: LazyParentComponent, // should be able to inject lazy/direct parent + lazyService: + LazyLoadedServiceDefinedInModule, // should be able to inject lazy service + eager: EagerParentComponent // should use the injector of the location to create a + // parent + ) {} + } - describe('should use the injector of the lazily-loaded configuration', () => { - class LazyLoadedServiceDefinedInModule {} + @NgModule({ + declarations: [LazyParentComponent, LazyChildComponent], + imports: [RouterModule.forChild([{ + path: '', + children: [{ + path: 'lazy-parent', + component: LazyParentComponent, + children: [{path: 'lazy-child', component: LazyChildComponent}] + }] + }])], + providers: [LazyLoadedServiceDefinedInModule] + }) + class LoadedModule { + } - @Component({ - selector: 'eager-parent', - template: 'eager-parent ', - }) - class EagerParentComponent { - } + @NgModule({declarations: [EagerParentComponent], imports: [RouterModule.forRoot([])]}) + class TestModule { + } - @Component({ - selector: 'lazy-parent', - template: 'lazy-parent ', - }) - class LazyParentComponent { - } + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestModule], + }); + }); - @Component({ - selector: 'lazy-child', - template: 'lazy-child', - }) - class LazyChildComponent { - constructor( - lazy: LazyParentComponent, // should be able to inject lazy/direct parent - lazyService: LazyLoadedServiceDefinedInModule, // should be able to inject lazy service - eager: EagerParentComponent // should use the injector of the location to create a - // parent - ) {} - } + it('should use the injector of the lazily-loaded configuration', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - @NgModule({ - declarations: [LazyParentComponent, LazyChildComponent], - imports: [RouterModule.forChild([{ - path: '', - children: [{ - path: 'lazy-parent', - component: LazyParentComponent, - children: [{path: 'lazy-child', component: LazyChildComponent}] - }] - }])], - providers: [LazyLoadedServiceDefinedInModule] - }) - class LoadedModule { - } + router.resetConfig([{ + path: 'eager-parent', + component: EagerParentComponent, + children: [{path: 'lazy', loadChildren: () => LoadedModule}] + }]); - @NgModule({declarations: [EagerParentComponent], imports: [RouterModule.forRoot([])]}) - class TestModule { - } + router.navigateByUrl('/eager-parent/lazy/lazy-parent/lazy-child'); + advance(fixture); - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [TestModule], - }); + expect(location.path()).toEqual('/eager-parent/lazy/lazy-parent/lazy-child'); + expect(fixture.nativeElement).toHaveText('eager-parent lazy-parent lazy-child'); + }))); }); - it('should use the injector of the lazily-loaded configuration', + it('works when given a callback', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + @Component({selector: 'lazy', template: 'lazy-loaded'}) + class LazyLoadedComponent { + } + + @NgModule({ + declarations: [LazyLoadedComponent], + imports: [RouterModule.forChild([{path: 'loaded', component: LazyLoadedComponent}])], + }) + class LoadedModule { + } + const fixture = createRoot(router, RootCmp); - router.resetConfig([{ - path: 'eager-parent', - component: EagerParentComponent, - children: [{path: 'lazy', loadChildren: () => LoadedModule}] - }]); + router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); - router.navigateByUrl('/eager-parent/lazy/lazy-parent/lazy-child'); + router.navigateByUrl('/lazy/loaded'); advance(fixture); - expect(location.path()).toEqual('/eager-parent/lazy/lazy-parent/lazy-child'); - expect(fixture.nativeElement).toHaveText('eager-parent lazy-parent lazy-child'); + expect(location.path()).toEqual('/lazy/loaded'); + expect(fixture.nativeElement).toHaveText('lazy-loaded'); }))); - }); - it('works when given a callback', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - @Component({selector: 'lazy', template: 'lazy-loaded'}) - class LazyLoadedComponent { - } - - @NgModule({ - declarations: [LazyLoadedComponent], - imports: [RouterModule.forChild([{path: 'loaded', component: LazyLoadedComponent}])], - }) - class LoadedModule { - } + it('error emit an error when cannot load a config', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - const fixture = createRoot(router, RootCmp); + router.resetConfig([{ + path: 'lazy', + loadChildren: () => { + throw new Error('invalid'); + } + }]); - router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); + const recordedEvents: Event[] = []; + router.events.forEach(e => recordedEvents.push(e)); - router.navigateByUrl('/lazy/loaded'); - advance(fixture); + router.navigateByUrl('/lazy/loaded')!.catch(s => {}); + advance(fixture); - expect(location.path()).toEqual('/lazy/loaded'); - expect(fixture.nativeElement).toHaveText('lazy-loaded'); - }))); + expect(location.path()).toEqual(''); - it('error emit an error when cannot load a config', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + expectEvents(recordedEvents, [ + [NavigationStart, '/lazy/loaded'], + [RouteConfigLoadStart], + [NavigationError, '/lazy/loaded'], + ]); + }))); - router.resetConfig([{ - path: 'lazy', - loadChildren: () => { - throw new Error('invalid'); + it('should emit an error when the lazily-loaded config is not valid', fakeAsync(() => { + const router = TestBed.inject(Router); + @NgModule({imports: [RouterModule.forChild([{path: 'loaded'}])]}) + class LoadedModule { } - }]); - - const recordedEvents: Event[] = []; - router.events.forEach(e => recordedEvents.push(e)); - - router.navigateByUrl('/lazy/loaded')!.catch(s => {}); - advance(fixture); - - expect(location.path()).toEqual(''); - expectEvents(recordedEvents, [ - [NavigationStart, '/lazy/loaded'], - [RouteConfigLoadStart], - [NavigationError, '/lazy/loaded'], - ]); - }))); - - it('should emit an error when the lazily-loaded config is not valid', fakeAsync(() => { - const router = TestBed.inject(Router); - @NgModule({imports: [RouterModule.forChild([{path: 'loaded'}])]}) - class LoadedModule { - } - - const fixture = createRoot(router, RootCmp); - router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); + const fixture = createRoot(router, RootCmp); + router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]); - let recordedError: any = null; - router.navigateByUrl('/lazy/loaded').catch(err => recordedError = err); - advance(fixture); + let recordedError: any = null; + router.navigateByUrl('/lazy/loaded').catch(err => recordedError = err); + advance(fixture); - expect(recordedError.message) - .toContain( - `Invalid configuration of route 'lazy/loaded'. One of the following must be provided: component, loadComponent, redirectTo, children or loadChildren`); - })); + expect(recordedError.message) + .toContain( + `Invalid configuration of route 'lazy/loaded'. One of the following must be provided: component, loadComponent, redirectTo, children or loadChildren`); + })); - it('should work with complex redirect rules', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - @Component({selector: 'lazy', template: 'lazy-loaded'}) - class LazyLoadedComponent { - } + it('should work with complex redirect rules', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + @Component({selector: 'lazy', template: 'lazy-loaded'}) + class LazyLoadedComponent { + } - @NgModule({ - declarations: [LazyLoadedComponent], - imports: [RouterModule.forChild([{path: 'loaded', component: LazyLoadedComponent}])], - }) - class LoadedModule { - } + @NgModule({ + declarations: [LazyLoadedComponent], + imports: [RouterModule.forChild([{path: 'loaded', component: LazyLoadedComponent}])], + }) + class LoadedModule { + } - const fixture = createRoot(router, RootCmp); + const fixture = createRoot(router, RootCmp); - router.resetConfig( - [{path: 'lazy', loadChildren: () => LoadedModule}, {path: '**', redirectTo: 'lazy'}]); + router.resetConfig([ + {path: 'lazy', loadChildren: () => LoadedModule}, {path: '**', redirectTo: 'lazy'} + ]); - router.navigateByUrl('/lazy/loaded'); - advance(fixture); + router.navigateByUrl('/lazy/loaded'); + advance(fixture); - expect(location.path()).toEqual('/lazy/loaded'); - }))); + expect(location.path()).toEqual('/lazy/loaded'); + }))); - it('should work with wildcard route', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - @Component({selector: 'lazy', template: 'lazy-loaded'}) - class LazyLoadedComponent { - } + it('should work with wildcard route', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + @Component({selector: 'lazy', template: 'lazy-loaded'}) + class LazyLoadedComponent { + } - @NgModule({ - declarations: [LazyLoadedComponent], - imports: [RouterModule.forChild([{path: '', component: LazyLoadedComponent}])], - }) - class LazyLoadedModule { - } + @NgModule({ + declarations: [LazyLoadedComponent], + imports: [RouterModule.forChild([{path: '', component: LazyLoadedComponent}])], + }) + class LazyLoadedModule { + } - const fixture = createRoot(router, RootCmp); + const fixture = createRoot(router, RootCmp); - router.resetConfig([{path: '**', loadChildren: () => LazyLoadedModule}]); + router.resetConfig([{path: '**', loadChildren: () => LazyLoadedModule}]); - router.navigateByUrl('/lazy'); - advance(fixture); + router.navigateByUrl('/lazy'); + advance(fixture); - expect(location.path()).toEqual('/lazy'); - expect(fixture.nativeElement).toHaveText('lazy-loaded'); - }))); + expect(location.path()).toEqual('/lazy'); + expect(fixture.nativeElement).toHaveText('lazy-loaded'); + }))); - describe('preloading', () => { - let log: string[] = []; - @Component({selector: 'lazy', template: 'should not show'}) - class LazyLoadedComponent { - } + describe('preloading', () => { + let log: string[] = []; + @Component({selector: 'lazy', template: 'should not show'}) + class LazyLoadedComponent { + } - @NgModule({ - declarations: [LazyLoadedComponent], - imports: [RouterModule.forChild([{path: 'LoadedModule2', component: LazyLoadedComponent}])] - }) - class LoadedModule2 { - } + @NgModule({ + declarations: [LazyLoadedComponent], + imports: + [RouterModule.forChild([{path: 'LoadedModule2', component: LazyLoadedComponent}])] + }) + class LoadedModule2 { + } - @NgModule({ - imports: - [RouterModule.forChild([{path: 'LoadedModule1', loadChildren: () => LoadedModule2}])] - }) - class LoadedModule1 { - } + @NgModule({ + imports: + [RouterModule.forChild([{path: 'LoadedModule1', loadChildren: () => LoadedModule2}])] + }) + class LoadedModule1 { + } - @NgModule({}) - class EmptyModule { - } + @NgModule({}) + class EmptyModule { + } - beforeEach(() => { - log.length = 0; - TestBed.configureTestingModule({ - providers: [ - {provide: PreloadingStrategy, useExisting: PreloadAllModules}, { - provide: 'loggingReturnsTrue', - useValue: () => { - log.push('loggingReturnsTrue'); - return true; + beforeEach(() => { + log.length = 0; + TestBed.configureTestingModule({ + providers: [ + {provide: PreloadingStrategy, useExisting: PreloadAllModules}, { + provide: 'loggingReturnsTrue', + useValue: () => { + log.push('loggingReturnsTrue'); + return true; + } } - } - ] + ] + }); + const preloader = TestBed.inject(RouterPreloader); + preloader.setUpPreloading(); }); - const preloader = TestBed.inject(RouterPreloader); - preloader.setUpPreloading(); - }); - - it('should work', fakeAsync(() => { - const router = TestBed.inject(Router); - const fixture = createRoot(router, RootCmp); - router.resetConfig([ - {path: 'blank', component: BlankCmp}, {path: 'lazy', loadChildren: () => LoadedModule1} - ]); + it('should work', fakeAsync(() => { + const router = TestBed.inject(Router); + const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/blank'); - advance(fixture); + router.resetConfig([ + {path: 'blank', component: BlankCmp}, + {path: 'lazy', loadChildren: () => LoadedModule1} + ]); - const config = router.config; - const firstRoutes = getLoadedRoutes(config[1])!; + router.navigateByUrl('/blank'); + advance(fixture); - expect(firstRoutes).toBeDefined(); - expect(firstRoutes[0].path).toEqual('LoadedModule1'); + const config = router.config; + const firstRoutes = getLoadedRoutes(config[1])!; - const secondRoutes = getLoadedRoutes(firstRoutes[0])!; - expect(secondRoutes).toBeDefined(); - expect(secondRoutes[0].path).toEqual('LoadedModule2'); - })); + expect(firstRoutes).toBeDefined(); + expect(firstRoutes[0].path).toEqual('LoadedModule1'); - it('should not preload when canLoad is present and does not execute guard', fakeAsync(() => { - const router = TestBed.inject(Router); - const fixture = createRoot(router, RootCmp); + const secondRoutes = getLoadedRoutes(firstRoutes[0])!; + expect(secondRoutes).toBeDefined(); + expect(secondRoutes[0].path).toEqual('LoadedModule2'); + })); - router.resetConfig([ - {path: 'blank', component: BlankCmp}, - {path: 'lazy', loadChildren: () => LoadedModule1, canLoad: ['loggingReturnsTrue']} - ]); + it('should not preload when canLoad is present and does not execute guard', + fakeAsync(() => { + const router = TestBed.inject(Router); + const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/blank'); - advance(fixture); + router.resetConfig([ + {path: 'blank', component: BlankCmp}, + {path: 'lazy', loadChildren: () => LoadedModule1, canLoad: ['loggingReturnsTrue']} + ]); - const config = router.config; - const firstRoutes = getLoadedRoutes(config[1])!; + router.navigateByUrl('/blank'); + advance(fixture); - expect(firstRoutes).toBeUndefined(); - expect(log.length).toBe(0); - })); + const config = router.config; + const firstRoutes = getLoadedRoutes(config[1])!; - it('should allow navigation to modules with no routes', fakeAsync(() => { - const router = TestBed.inject(Router); - const fixture = createRoot(router, RootCmp); + expect(firstRoutes).toBeUndefined(); + expect(log.length).toBe(0); + })); - router.resetConfig([{path: 'lazy', loadChildren: () => EmptyModule}]); + it('should allow navigation to modules with no routes', fakeAsync(() => { + const router = TestBed.inject(Router); + const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/lazy'); - advance(fixture); - })); - }); + router.resetConfig([{path: 'lazy', loadChildren: () => EmptyModule}]); - describe('custom url handling strategies', () => { - class CustomUrlHandlingStrategy implements UrlHandlingStrategy { - shouldProcessUrl(url: UrlTree): boolean { - return url.toString().startsWith('/include') || url.toString() === '/'; - } + router.navigateByUrl('/lazy'); + advance(fixture); + })); + }); - extract(url: UrlTree): UrlTree { - const oldRoot = url.root; - const children: Record = {}; - if (oldRoot.children[PRIMARY_OUTLET]) { - children[PRIMARY_OUTLET] = oldRoot.children[PRIMARY_OUTLET]; + describe('custom url handling strategies', () => { + class CustomUrlHandlingStrategy implements UrlHandlingStrategy { + shouldProcessUrl(url: UrlTree): boolean { + return url.toString().startsWith('/include') || url.toString() === '/'; } - const root = new UrlSegmentGroup(oldRoot.segments, children); - return new UrlTree(root, url.queryParams, url.fragment); - } - merge(newUrlPart: UrlTree, wholeUrl: UrlTree): UrlTree { - const oldRoot = newUrlPart.root; - - const children: Record = {}; - if (oldRoot.children[PRIMARY_OUTLET]) { - children[PRIMARY_OUTLET] = oldRoot.children[PRIMARY_OUTLET]; + extract(url: UrlTree): UrlTree { + const oldRoot = url.root; + const children: Record = {}; + if (oldRoot.children[PRIMARY_OUTLET]) { + children[PRIMARY_OUTLET] = oldRoot.children[PRIMARY_OUTLET]; + } + const root = new UrlSegmentGroup(oldRoot.segments, children); + return new UrlTree(root, url.queryParams, url.fragment); } - Object.entries(wholeUrl.root.children).forEach(([k, v]: [string, any]) => { - if (k !== PRIMARY_OUTLET) { - children[k] = v; + merge(newUrlPart: UrlTree, wholeUrl: UrlTree): UrlTree { + const oldRoot = newUrlPart.root; + + const children: Record = {}; + if (oldRoot.children[PRIMARY_OUTLET]) { + children[PRIMARY_OUTLET] = oldRoot.children[PRIMARY_OUTLET]; } - v.parent = this; - }); - const root = new UrlSegmentGroup(oldRoot.segments, children); - return new UrlTree(root, newUrlPart.queryParams, newUrlPart.fragment); + + Object.entries(wholeUrl.root.children).forEach(([k, v]: [string, any]) => { + if (k !== PRIMARY_OUTLET) { + children[k] = v; + } + v.parent = this; + }); + const root = new UrlSegmentGroup(oldRoot.segments, children); + return new UrlTree(root, newUrlPart.queryParams, newUrlPart.fragment); + } } - } - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - {provide: UrlHandlingStrategy, useClass: CustomUrlHandlingStrategy}, - {provide: LocationStrategy, useClass: HashLocationStrategy} - ] + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + {provide: UrlHandlingStrategy, useClass: CustomUrlHandlingStrategy}, + {provide: LocationStrategy, useClass: HashLocationStrategy} + ] + }); }); - }); - it('should work', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + it('should work', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - router.resetConfig([{ - path: 'include', - component: TeamCmp, - children: - [{path: 'user/:name', component: UserCmp}, {path: 'simple', component: SimpleCmp}] - }]); + router.resetConfig([{ + path: 'include', + component: TeamCmp, + children: [ + {path: 'user/:name', component: UserCmp}, {path: 'simple', component: SimpleCmp} + ] + }]); - const events: Event[] = []; - router.events.subscribe(e => e instanceof RouterEvent && events.push(e)); + const events: Event[] = []; + router.events.subscribe(e => e instanceof RouterEvent && events.push(e)); - // supported URL - router.navigateByUrl('/include/user/kate'); - advance(fixture); + // supported URL + router.navigateByUrl('/include/user/kate'); + advance(fixture); - expect(location.path()).toEqual('/include/user/kate'); - expectEvents(events, [ - [NavigationStart, '/include/user/kate'], [RoutesRecognized, '/include/user/kate'], - [GuardsCheckStart, '/include/user/kate'], [GuardsCheckEnd, '/include/user/kate'], - [ResolveStart, '/include/user/kate'], [ResolveEnd, '/include/user/kate'], - [NavigationEnd, '/include/user/kate'] - ]); - expect(fixture.nativeElement).toHaveText('team [ user kate, right: ]'); - events.splice(0); + expect(location.path()).toEqual('/include/user/kate'); + expectEvents(events, [ + [NavigationStart, '/include/user/kate'], [RoutesRecognized, '/include/user/kate'], + [GuardsCheckStart, '/include/user/kate'], [GuardsCheckEnd, '/include/user/kate'], + [ResolveStart, '/include/user/kate'], [ResolveEnd, '/include/user/kate'], + [NavigationEnd, '/include/user/kate'] + ]); + expect(fixture.nativeElement).toHaveText('team [ user kate, right: ]'); + events.splice(0); - // unsupported URL - router.navigateByUrl('/exclude/one'); - advance(fixture); + // unsupported URL + router.navigateByUrl('/exclude/one'); + advance(fixture); - expect(location.path()).toEqual('/exclude/one'); - expect(Object.keys(router.routerState.root.children).length).toEqual(0); - expect(fixture.nativeElement).toHaveText(''); - expectEvents(events, [ - [NavigationStart, '/exclude/one'], [GuardsCheckStart, '/exclude/one'], - [GuardsCheckEnd, '/exclude/one'], [NavigationEnd, '/exclude/one'] - ]); - events.splice(0); + expect(location.path()).toEqual('/exclude/one'); + expect(Object.keys(router.routerState.root.children).length).toEqual(0); + expect(fixture.nativeElement).toHaveText(''); + expectEvents(events, [ + [NavigationStart, '/exclude/one'], [GuardsCheckStart, '/exclude/one'], + [GuardsCheckEnd, '/exclude/one'], [NavigationEnd, '/exclude/one'] + ]); + events.splice(0); - // another unsupported URL - location.go('/exclude/two'); - location.historyGo(0); - advance(fixture); + // another unsupported URL + location.go('/exclude/two'); + location.historyGo(0); + advance(fixture); - expect(location.path()).toEqual('/exclude/two'); - expectEvents(events, [[NavigationSkipped, '/exclude/two']]); - events.splice(0); + expect(location.path()).toEqual('/exclude/two'); + expectEvents(events, [[NavigationSkipped, '/exclude/two']]); + events.splice(0); - // back to a supported URL - location.go('/include/simple'); - location.historyGo(0); - advance(fixture); + // back to a supported URL + location.go('/include/simple'); + location.historyGo(0); + advance(fixture); - expect(location.path()).toEqual('/include/simple'); - expect(fixture.nativeElement).toHaveText('team [ simple, right: ]'); + expect(location.path()).toEqual('/include/simple'); + expect(fixture.nativeElement).toHaveText('team [ simple, right: ]'); - expectEvents(events, [ - [NavigationStart, '/include/simple'], [RoutesRecognized, '/include/simple'], - [GuardsCheckStart, '/include/simple'], [GuardsCheckEnd, '/include/simple'], - [ResolveStart, '/include/simple'], [ResolveEnd, '/include/simple'], - [NavigationEnd, '/include/simple'] - ]); - }))); + expectEvents(events, [ + [NavigationStart, '/include/simple'], [RoutesRecognized, '/include/simple'], + [GuardsCheckStart, '/include/simple'], [GuardsCheckEnd, '/include/simple'], + [ResolveStart, '/include/simple'], [ResolveEnd, '/include/simple'], + [NavigationEnd, '/include/simple'] + ]); + }))); - it('should handle the case when the router takes only the primary url', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + it('should handle the case when the router takes only the primary url', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - router.resetConfig([{ - path: 'include', - component: TeamCmp, - children: - [{path: 'user/:name', component: UserCmp}, {path: 'simple', component: SimpleCmp}] - }]); + router.resetConfig([{ + path: 'include', + component: TeamCmp, + children: [ + {path: 'user/:name', component: UserCmp}, {path: 'simple', component: SimpleCmp} + ] + }]); - const events: Event[] = []; - router.events.subscribe(e => e instanceof RouterEvent && events.push(e)); + const events: Event[] = []; + router.events.subscribe(e => e instanceof RouterEvent && events.push(e)); - location.go('/include/user/kate(aux:excluded)'); - location.historyGo(0); - advance(fixture); + location.go('/include/user/kate(aux:excluded)'); + location.historyGo(0); + advance(fixture); - expect(location.path()).toEqual('/include/user/kate(aux:excluded)'); - expectEvents(events, [ - [NavigationStart, '/include/user/kate'], [RoutesRecognized, '/include/user/kate'], - [GuardsCheckStart, '/include/user/kate'], [GuardsCheckEnd, '/include/user/kate'], - [ResolveStart, '/include/user/kate'], [ResolveEnd, '/include/user/kate'], - [NavigationEnd, '/include/user/kate'] - ]); - events.splice(0); + expect(location.path()).toEqual('/include/user/kate(aux:excluded)'); + expectEvents(events, [ + [NavigationStart, '/include/user/kate'], [RoutesRecognized, '/include/user/kate'], + [GuardsCheckStart, '/include/user/kate'], [GuardsCheckEnd, '/include/user/kate'], + [ResolveStart, '/include/user/kate'], [ResolveEnd, '/include/user/kate'], + [NavigationEnd, '/include/user/kate'] + ]); + events.splice(0); - location.go('/include/user/kate(aux:excluded2)'); - location.historyGo(0); - advance(fixture); - expectEvents(events, [[NavigationSkipped, '/include/user/kate(aux:excluded2)']]); - events.splice(0); + location.go('/include/user/kate(aux:excluded2)'); + location.historyGo(0); + advance(fixture); + expectEvents(events, [[NavigationSkipped, '/include/user/kate(aux:excluded2)']]); + events.splice(0); - router.navigateByUrl('/include/simple'); - advance(fixture); + router.navigateByUrl('/include/simple'); + advance(fixture); - expect(location.path()).toEqual('/include/simple(aux:excluded2)'); - expectEvents(events, [ - [NavigationStart, '/include/simple'], [RoutesRecognized, '/include/simple'], - [GuardsCheckStart, '/include/simple'], [GuardsCheckEnd, '/include/simple'], - [ResolveStart, '/include/simple'], [ResolveEnd, '/include/simple'], - [NavigationEnd, '/include/simple'] - ]); - }))); + expect(location.path()).toEqual('/include/simple(aux:excluded2)'); + expectEvents(events, [ + [NavigationStart, '/include/simple'], [RoutesRecognized, '/include/simple'], + [GuardsCheckStart, '/include/simple'], [GuardsCheckEnd, '/include/simple'], + [ResolveStart, '/include/simple'], [ResolveEnd, '/include/simple'], + [NavigationEnd, '/include/simple'] + ]); + }))); - it('should not remove parts of the URL that are not handled by the router when "eager"', - fakeAsync(() => { - TestBed.configureTestingModule( - {providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))]}); - const router = TestBed.inject(Router); - const location = TestBed.inject(Location); - const fixture = createRoot(router, RootCmp); + it('should not remove parts of the URL that are not handled by the router when "eager"', + fakeAsync(() => { + TestBed.configureTestingModule( + {providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))]}); + const router = TestBed.inject(Router); + const location = TestBed.inject(Location); + const fixture = createRoot(router, RootCmp); - router.resetConfig([{ - path: 'include', - component: TeamCmp, - children: [{path: 'user/:name', component: UserCmp}] - }]); + router.resetConfig([{ + path: 'include', + component: TeamCmp, + children: [{path: 'user/:name', component: UserCmp}] + }]); - location.go('/include/user/kate(aux:excluded)'); - location.historyGo(0); - advance(fixture); + location.go('/include/user/kate(aux:excluded)'); + location.historyGo(0); + advance(fixture); - expect(location.path()).toEqual('/include/user/kate(aux:excluded)'); - })); - }); + expect(location.path()).toEqual('/include/user/kate(aux:excluded)'); + })); + }); - it('can use `relativeTo` `route.parent` in `routerLink` to close secondary outlet', - fakeAsync(() => { - // Given - @Component({template: ''}) - class ChildRootCmp { - } + it('can use `relativeTo` `route.parent` in `routerLink` to close secondary outlet', + fakeAsync(() => { + // Given + @Component({template: ''}) + class ChildRootCmp { + } - @Component({ - selector: 'link-cmp', - template: - `link + @Component({ + selector: 'link-cmp', + template: + `link ` - }) - class RelativeLinkCmp { - @ViewChildren(RouterLink) links!: QueryList; + }) + class RelativeLinkCmp { + @ViewChildren(RouterLink) links!: QueryList; - constructor(readonly route: ActivatedRoute) {} - } - @NgModule({ - declarations: [RelativeLinkCmp, ChildRootCmp], - imports: [RouterModule.forChild([{ - path: 'childRoot', - component: ChildRootCmp, - children: [ - {path: 'popup', outlet: 'secondary', component: RelativeLinkCmp}, - ] - }])] - }) - class LazyLoadedModule { - } - const router = TestBed.inject(Router); - router.resetConfig([{path: 'root', loadChildren: () => LazyLoadedModule}]); + constructor(readonly route: ActivatedRoute) {} + } + @NgModule({ + declarations: [RelativeLinkCmp, ChildRootCmp], + imports: [RouterModule.forChild([{ + path: 'childRoot', + component: ChildRootCmp, + children: [ + {path: 'popup', outlet: 'secondary', component: RelativeLinkCmp}, + ] + }])] + }) + class LazyLoadedModule { + } + const router = TestBed.inject(Router); + router.resetConfig([{path: 'root', loadChildren: () => LazyLoadedModule}]); - // When - router.navigateByUrl('/root/childRoot/(secondary:popup)'); - const fixture = createRoot(router, RootCmp); - advance(fixture); + // When + router.navigateByUrl('/root/childRoot/(secondary:popup)'); + const fixture = createRoot(router, RootCmp); + advance(fixture); - // Then - const relativeLinkCmp = - fixture.debugElement.query(By.directive(RelativeLinkCmp)).componentInstance; - expect(relativeLinkCmp.links.first.urlTree.toString()).toEqual('/root/childRoot'); - expect(relativeLinkCmp.links.last.urlTree.toString()).toEqual('/root/childRoot'); - })); + // Then + const relativeLinkCmp = + fixture.debugElement.query(By.directive(RelativeLinkCmp)).componentInstance; + expect(relativeLinkCmp.links.first.urlTree.toString()).toEqual('/root/childRoot'); + expect(relativeLinkCmp.links.last.urlTree.toString()).toEqual('/root/childRoot'); + })); - it('should ignore empty path for relative links', - fakeAsync(inject([Router], (router: Router) => { - @Component({selector: 'link-cmp', template: `link`}) - class RelativeLinkCmp { - } + it('should ignore empty path for relative links', + fakeAsync(inject([Router], (router: Router) => { + @Component({selector: 'link-cmp', template: `link`}) + class RelativeLinkCmp { + } - @NgModule({ - declarations: [RelativeLinkCmp], - imports: [RouterModule.forChild([ - {path: 'foo/bar', children: [{path: '', component: RelativeLinkCmp}]}, - ])] - }) - class LazyLoadedModule { - } + @NgModule({ + declarations: [RelativeLinkCmp], + imports: [RouterModule.forChild([ + {path: 'foo/bar', children: [{path: '', component: RelativeLinkCmp}]}, + ])] + }) + class LazyLoadedModule { + } - const fixture = createRoot(router, RootCmp); + const fixture = createRoot(router, RootCmp); - router.resetConfig([{path: 'lazy', loadChildren: () => LazyLoadedModule}]); + router.resetConfig([{path: 'lazy', loadChildren: () => LazyLoadedModule}]); - router.navigateByUrl('/lazy/foo/bar'); - advance(fixture); + router.navigateByUrl('/lazy/foo/bar'); + advance(fixture); - const link = fixture.nativeElement.querySelector('a'); - expect(link.getAttribute('href')).toEqual('/lazy/foo/simple'); - }))); - }); + const link = fixture.nativeElement.querySelector('a'); + expect(link.getAttribute('href')).toEqual('/lazy/foo/simple'); + }))); + }); - describe('Custom Route Reuse Strategy', () => { - class AttachDetachReuseStrategy implements RouteReuseStrategy { - stored: {[k: string]: DetachedRouteHandle} = {}; - pathsToDetach = ['a']; + describe('Custom Route Reuse Strategy', () => { + class AttachDetachReuseStrategy implements RouteReuseStrategy { + stored: {[k: string]: DetachedRouteHandle} = {}; + pathsToDetach = ['a']; - shouldDetach(route: ActivatedRouteSnapshot): boolean { - return typeof route.routeConfig!.path !== 'undefined' && - this.pathsToDetach.includes(route.routeConfig!.path); - } + shouldDetach(route: ActivatedRouteSnapshot): boolean { + return typeof route.routeConfig!.path !== 'undefined' && + this.pathsToDetach.includes(route.routeConfig!.path); + } - store(route: ActivatedRouteSnapshot, detachedTree: DetachedRouteHandle): void { - this.stored[route.routeConfig!.path!] = detachedTree; - } + store(route: ActivatedRouteSnapshot, detachedTree: DetachedRouteHandle): void { + this.stored[route.routeConfig!.path!] = detachedTree; + } - shouldAttach(route: ActivatedRouteSnapshot): boolean { - return !!this.stored[route.routeConfig!.path!]; - } + shouldAttach(route: ActivatedRouteSnapshot): boolean { + return !!this.stored[route.routeConfig!.path!]; + } - retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle { - return this.stored[route.routeConfig!.path!]; - } + retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle { + return this.stored[route.routeConfig!.path!]; + } - shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean { - return future.routeConfig === curr.routeConfig; + shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean { + return future.routeConfig === curr.routeConfig; + } } - } - class ShortLifecycle implements RouteReuseStrategy { - shouldDetach(route: ActivatedRouteSnapshot): boolean { - return false; - } - store(route: ActivatedRouteSnapshot, detachedTree: DetachedRouteHandle): void {} - shouldAttach(route: ActivatedRouteSnapshot): boolean { - return false; - } - retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle|null { - return null; - } - shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean { - if (future.routeConfig !== curr.routeConfig) { + class ShortLifecycle implements RouteReuseStrategy { + shouldDetach(route: ActivatedRouteSnapshot): boolean { return false; } - - if (Object.keys(future.params).length !== Object.keys(curr.params).length) { + store(route: ActivatedRouteSnapshot, detachedTree: DetachedRouteHandle): void {} + shouldAttach(route: ActivatedRouteSnapshot): boolean { return false; } + retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle|null { + return null; + } + shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean { + if (future.routeConfig !== curr.routeConfig) { + return false; + } + + if (Object.keys(future.params).length !== Object.keys(curr.params).length) { + return false; + } - return Object.keys(future.params).every(k => future.params[k] === curr.params[k]); + return Object.keys(future.params).every(k => future.params[k] === curr.params[k]); + } } - } - it('should be injectable', () => { - TestBed.configureTestingModule({ - providers: [ - {provide: RouteReuseStrategy, useClass: AttachDetachReuseStrategy}, - provideRouter([]), - ] - }); + it('should be injectable', () => { + TestBed.configureTestingModule({ + providers: [ + {provide: RouteReuseStrategy, useClass: AttachDetachReuseStrategy}, + provideRouter([]), + ] + }); - const router = TestBed.inject(Router); + const router = TestBed.inject(Router); - expect(router.routeReuseStrategy).toBeInstanceOf(AttachDetachReuseStrategy); - }); + expect(router.routeReuseStrategy).toBeInstanceOf(AttachDetachReuseStrategy); + }); - it('should emit an event when an outlet gets attached/detached', fakeAsync(() => { - @Component({ - selector: 'container', - template: - `` - }) - class Container { - attachedComponents: unknown[] = []; - detachedComponents: unknown[] = []; + it('should emit an event when an outlet gets attached/detached', fakeAsync(() => { + @Component({ + selector: 'container', + template: + `` + }) + class Container { + attachedComponents: unknown[] = []; + detachedComponents: unknown[] = []; - recordAttached(component: unknown): void { - this.attachedComponents.push(component); - } + recordAttached(component: unknown): void { + this.attachedComponents.push(component); + } - recordDetached(component: unknown): void { - this.detachedComponents.push(component); + recordDetached(component: unknown): void { + this.detachedComponents.push(component); + } } - } - TestBed.configureTestingModule({ - declarations: [Container], - providers: [{provide: RouteReuseStrategy, useClass: AttachDetachReuseStrategy}] - }); + TestBed.configureTestingModule({ + declarations: [Container], + providers: [{provide: RouteReuseStrategy, useClass: AttachDetachReuseStrategy}] + }); - const router = TestBed.inject(Router); - const fixture = createRoot(router, Container); - const cmp = fixture.componentInstance; + const router = TestBed.inject(Router); + const fixture = createRoot(router, Container); + const cmp = fixture.componentInstance; - router.resetConfig([{path: 'a', component: BlankCmp}, {path: 'b', component: SimpleCmp}]); + router.resetConfig( + [{path: 'a', component: BlankCmp}, {path: 'b', component: SimpleCmp}]); - cmp.attachedComponents = []; - cmp.detachedComponents = []; + cmp.attachedComponents = []; + cmp.detachedComponents = []; - router.navigateByUrl('/a'); - advance(fixture); - expect(cmp.attachedComponents.length).toEqual(0); - expect(cmp.detachedComponents.length).toEqual(0); + router.navigateByUrl('/a'); + advance(fixture); + expect(cmp.attachedComponents.length).toEqual(0); + expect(cmp.detachedComponents.length).toEqual(0); - router.navigateByUrl('/b'); - advance(fixture); - expect(cmp.attachedComponents.length).toEqual(0); - expect(cmp.detachedComponents.length).toEqual(1); - expect(cmp.detachedComponents[0] instanceof BlankCmp).toBe(true); + router.navigateByUrl('/b'); + advance(fixture); + expect(cmp.attachedComponents.length).toEqual(0); + expect(cmp.detachedComponents.length).toEqual(1); + expect(cmp.detachedComponents[0] instanceof BlankCmp).toBe(true); - // the route will be reused by the `RouteReuseStrategy` - router.navigateByUrl('/a'); - advance(fixture); - expect(cmp.attachedComponents.length).toEqual(1); - expect(cmp.attachedComponents[0] instanceof BlankCmp).toBe(true); - expect(cmp.detachedComponents.length).toEqual(1); - expect(cmp.detachedComponents[0] instanceof BlankCmp).toBe(true); - })); + // the route will be reused by the `RouteReuseStrategy` + router.navigateByUrl('/a'); + advance(fixture); + expect(cmp.attachedComponents.length).toEqual(1); + expect(cmp.attachedComponents[0] instanceof BlankCmp).toBe(true); + expect(cmp.detachedComponents.length).toEqual(1); + expect(cmp.detachedComponents[0] instanceof BlankCmp).toBe(true); + })); - it('should support attaching & detaching fragments', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); + it('should support attaching & detaching fragments', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); - router.routeReuseStrategy = new AttachDetachReuseStrategy(); - (router.routeReuseStrategy as AttachDetachReuseStrategy).pathsToDetach = ['a', 'b']; - spyOn(router.routeReuseStrategy, 'retrieve').and.callThrough(); + router.routeReuseStrategy = new AttachDetachReuseStrategy(); + (router.routeReuseStrategy as AttachDetachReuseStrategy).pathsToDetach = ['a', 'b']; + spyOn(router.routeReuseStrategy, 'retrieve').and.callThrough(); - router.resetConfig([ - { - path: 'a', - component: TeamCmp, - children: [{path: 'b', component: SimpleCmp}], - }, - {path: 'c', component: UserCmp}, - ]); + router.resetConfig([ + { + path: 'a', + component: TeamCmp, + children: [{path: 'b', component: SimpleCmp}], + }, + {path: 'c', component: UserCmp}, + ]); - router.navigateByUrl('/a/b'); - advance(fixture); - const teamCmp = fixture.debugElement.children[1].componentInstance; - const simpleCmp = fixture.debugElement.children[1].children[1].componentInstance; - expect(location.path()).toEqual('/a/b'); - expect(teamCmp).toBeDefined(); - expect(simpleCmp).toBeDefined(); - expect(router.routeReuseStrategy.retrieve).not.toHaveBeenCalled(); - - router.navigateByUrl('/c'); - advance(fixture); - expect(location.path()).toEqual('/c'); - expect(fixture.debugElement.children[1].componentInstance).toBeInstanceOf(UserCmp); - // We have still not encountered a route that should be reattached - expect(router.routeReuseStrategy.retrieve).not.toHaveBeenCalled(); + router.navigateByUrl('/a/b'); + advance(fixture); + const teamCmp = fixture.debugElement.children[1].componentInstance; + const simpleCmp = fixture.debugElement.children[1].children[1].componentInstance; + expect(location.path()).toEqual('/a/b'); + expect(teamCmp).toBeDefined(); + expect(simpleCmp).toBeDefined(); + expect(router.routeReuseStrategy.retrieve).not.toHaveBeenCalled(); - router.navigateByUrl('/a;p=1/b;p=2'); - advance(fixture); - // We retrieve both the stored route snapshots - expect(router.routeReuseStrategy.retrieve).toHaveBeenCalledTimes(4); - const teamCmp2 = fixture.debugElement.children[1].componentInstance; - const simpleCmp2 = fixture.debugElement.children[1].children[1].componentInstance; - expect(location.path()).toEqual('/a;p=1/b;p=2'); - expect(teamCmp2).toBe(teamCmp); - expect(simpleCmp2).toBe(simpleCmp); - - expect(teamCmp.route).toBe(router.routerState.root.firstChild); - expect(teamCmp.route.snapshot).toBe(router.routerState.snapshot.root.firstChild); - expect(teamCmp.route.snapshot.params).toEqual({p: '1'}); - expect(teamCmp.route.firstChild.snapshot.params).toEqual({p: '2'}); - expect(teamCmp.recordedParams).toEqual([{}, {p: '1'}]); - }))); + router.navigateByUrl('/c'); + advance(fixture); + expect(location.path()).toEqual('/c'); + expect(fixture.debugElement.children[1].componentInstance).toBeInstanceOf(UserCmp); + // We have still not encountered a route that should be reattached + expect(router.routeReuseStrategy.retrieve).not.toHaveBeenCalled(); - it('should support shorter lifecycles', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { - const fixture = createRoot(router, RootCmp); - router.routeReuseStrategy = new ShortLifecycle(); + router.navigateByUrl('/a;p=1/b;p=2'); + advance(fixture); + // We retrieve both the stored route snapshots + expect(router.routeReuseStrategy.retrieve).toHaveBeenCalledTimes(4); + const teamCmp2 = fixture.debugElement.children[1].componentInstance; + const simpleCmp2 = fixture.debugElement.children[1].children[1].componentInstance; + expect(location.path()).toEqual('/a;p=1/b;p=2'); + expect(teamCmp2).toBe(teamCmp); + expect(simpleCmp2).toBe(simpleCmp); + + expect(teamCmp.route).toBe(router.routerState.root.firstChild); + expect(teamCmp.route.snapshot).toBe(router.routerState.snapshot.root.firstChild); + expect(teamCmp.route.snapshot.params).toEqual({p: '1'}); + expect(teamCmp.route.firstChild.snapshot.params).toEqual({p: '2'}); + expect(teamCmp.recordedParams).toEqual([{}, {p: '1'}]); + }))); - router.resetConfig([{path: 'a', component: SimpleCmp}]); + it('should support shorter lifecycles', + fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + const fixture = createRoot(router, RootCmp); + router.routeReuseStrategy = new ShortLifecycle(); - router.navigateByUrl('/a'); - advance(fixture); - const simpleCmp1 = fixture.debugElement.children[1].componentInstance; - expect(location.path()).toEqual('/a'); + router.resetConfig([{path: 'a', component: SimpleCmp}]); - router.navigateByUrl('/a;p=1'); - advance(fixture); - expect(location.path()).toEqual('/a;p=1'); - const simpleCmp2 = fixture.debugElement.children[1].componentInstance; - expect(simpleCmp1).not.toBe(simpleCmp2); - }))); + router.navigateByUrl('/a'); + advance(fixture); + const simpleCmp1 = fixture.debugElement.children[1].componentInstance; + expect(location.path()).toEqual('/a'); - it('should not mount the component of the previously reused route when the outlet was not instantiated at the time of route activation', - fakeAsync(() => { - @Component({ - selector: 'root-cmp', - template: - '
' - }) - class RootCmpWithCondOutlet implements OnDestroy { - private subscription: Subscription; - public isToolpanelShowing: boolean = false; - - constructor(router: Router) { - this.subscription = - router.events.pipe(filter(event => event instanceof NavigationEnd)) - .subscribe( - () => this.isToolpanelShowing = - !!router.parseUrl(router.url).root.children['toolpanel']); - } + router.navigateByUrl('/a;p=1'); + advance(fixture); + expect(location.path()).toEqual('/a;p=1'); + const simpleCmp2 = fixture.debugElement.children[1].componentInstance; + expect(simpleCmp1).not.toBe(simpleCmp2); + }))); + + it('should not mount the component of the previously reused route when the outlet was not instantiated at the time of route activation', + fakeAsync(() => { + @Component({ + selector: 'root-cmp', + template: + '
' + }) + class RootCmpWithCondOutlet implements OnDestroy { + private subscription: Subscription; + public isToolpanelShowing: boolean = false; + + constructor(router: Router) { + this.subscription = + router.events.pipe(filter(event => event instanceof NavigationEnd)) + .subscribe( + () => this.isToolpanelShowing = + !!router.parseUrl(router.url).root.children['toolpanel']); + } - public ngOnDestroy(): void { - this.subscription.unsubscribe(); + public ngOnDestroy(): void { + this.subscription.unsubscribe(); + } } - } - @Component({selector: 'tool-1-cmp', template: 'Tool 1 showing'}) - class Tool1Component { - } + @Component({selector: 'tool-1-cmp', template: 'Tool 1 showing'}) + class Tool1Component { + } - @Component({selector: 'tool-2-cmp', template: 'Tool 2 showing'}) - class Tool2Component { - } + @Component({selector: 'tool-2-cmp', template: 'Tool 2 showing'}) + class Tool2Component { + } - @NgModule({ - declarations: [RootCmpWithCondOutlet, Tool1Component, Tool2Component], - imports: [CommonModule, ...ROUTER_DIRECTIVES], - providers: [ - provideRouter([ - {path: 'a', outlet: 'toolpanel', component: Tool1Component}, - {path: 'b', outlet: 'toolpanel', component: Tool2Component}, - ]), - ] - }) - class TestModule { - } + @NgModule({ + declarations: [RootCmpWithCondOutlet, Tool1Component, Tool2Component], + imports: [CommonModule, ...ROUTER_DIRECTIVES], + providers: [ + provideRouter([ + {path: 'a', outlet: 'toolpanel', component: Tool1Component}, + {path: 'b', outlet: 'toolpanel', component: Tool2Component}, + ]), + ] + }) + class TestModule { + } - TestBed.configureTestingModule({imports: [TestModule]}); + TestBed.configureTestingModule({imports: [TestModule]}); - const router: Router = TestBed.inject(Router); - router.routeReuseStrategy = new AttachDetachReuseStrategy(); + const router: Router = TestBed.inject(Router); + router.routeReuseStrategy = new AttachDetachReuseStrategy(); - const fixture = createRoot(router, RootCmpWithCondOutlet); + const fixture = createRoot(router, RootCmpWithCondOutlet); - // Activate 'tool-1' - router.navigate([{outlets: {toolpanel: 'a'}}]); - advance(fixture); - expect(fixture).toContainComponent(Tool1Component, '(a)'); + // Activate 'tool-1' + router.navigate([{outlets: {toolpanel: 'a'}}]); + advance(fixture); + expect(fixture).toContainComponent(Tool1Component, '(a)'); - // Deactivate 'tool-1' - router.navigate([{outlets: {toolpanel: null}}]); - advance(fixture); - expect(fixture).not.toContainComponent(Tool1Component, '(b)'); + // Deactivate 'tool-1' + router.navigate([{outlets: {toolpanel: null}}]); + advance(fixture); + expect(fixture).not.toContainComponent(Tool1Component, '(b)'); - // Activate 'tool-1' - router.navigate([{outlets: {toolpanel: 'a'}}]); - advance(fixture); - expect(fixture).toContainComponent(Tool1Component, '(c)'); + // Activate 'tool-1' + router.navigate([{outlets: {toolpanel: 'a'}}]); + advance(fixture); + expect(fixture).toContainComponent(Tool1Component, '(c)'); - // Deactivate 'tool-1' - router.navigate([{outlets: {toolpanel: null}}]); - advance(fixture); - expect(fixture).not.toContainComponent(Tool1Component, '(d)'); + // Deactivate 'tool-1' + router.navigate([{outlets: {toolpanel: null}}]); + advance(fixture); + expect(fixture).not.toContainComponent(Tool1Component, '(d)'); - // Activate 'tool-2' - router.navigate([{outlets: {toolpanel: 'b'}}]); - advance(fixture); - expect(fixture).toContainComponent(Tool2Component, '(e)'); - })); + // Activate 'tool-2' + router.navigate([{outlets: {toolpanel: 'b'}}]); + advance(fixture); + expect(fixture).toContainComponent(Tool2Component, '(e)'); + })); - it('should not remount a destroyed component', fakeAsync(() => { - @Component({ - selector: 'root-cmp', - template: '
' - }) - class RootCmpWithCondOutlet { - public showRouterOutlet: boolean = true; - } + it('should not remount a destroyed component', fakeAsync(() => { + @Component({ + selector: 'root-cmp', + template: '
' + }) + class RootCmpWithCondOutlet { + public showRouterOutlet: boolean = true; + } - @NgModule({ - declarations: [RootCmpWithCondOutlet], - imports: [ - CommonModule, - ...ROUTER_DIRECTIVES, - ], - providers: [ - {provide: RouteReuseStrategy, useClass: AttachDetachReuseStrategy}, - provideRouter([ - {path: 'a', component: SimpleCmp}, - {path: 'b', component: BlankCmp}, - ]), - ] - }) - class TestModule { - } - TestBed.configureTestingModule({imports: [TestModule]}); + @NgModule({ + declarations: [RootCmpWithCondOutlet], + imports: [ + CommonModule, + ...ROUTER_DIRECTIVES, + ], + providers: [ + {provide: RouteReuseStrategy, useClass: AttachDetachReuseStrategy}, + provideRouter([ + {path: 'a', component: SimpleCmp}, + {path: 'b', component: BlankCmp}, + ]), + ] + }) + class TestModule { + } + TestBed.configureTestingModule({imports: [TestModule]}); - const router: Router = TestBed.inject(Router); - const fixture = createRoot(router, RootCmpWithCondOutlet); + const router: Router = TestBed.inject(Router); + const fixture = createRoot(router, RootCmpWithCondOutlet); - // Activate 'a' - router.navigate(['a']); - advance(fixture); - expect(fixture.debugElement.query(By.directive(SimpleCmp))).toBeTruthy(); + // Activate 'a' + router.navigate(['a']); + advance(fixture); + expect(fixture.debugElement.query(By.directive(SimpleCmp))).toBeTruthy(); - // Deactivate 'a' and detach the route - router.navigate(['b']); - advance(fixture); - expect(fixture.debugElement.query(By.directive(SimpleCmp))).toBeNull(); + // Deactivate 'a' and detach the route + router.navigate(['b']); + advance(fixture); + expect(fixture.debugElement.query(By.directive(SimpleCmp))).toBeNull(); - // Activate 'a' again, the route should be re-attached - router.navigate(['a']); - advance(fixture); - expect(fixture.debugElement.query(By.directive(SimpleCmp))).toBeTruthy(); + // Activate 'a' again, the route should be re-attached + router.navigate(['a']); + advance(fixture); + expect(fixture.debugElement.query(By.directive(SimpleCmp))).toBeTruthy(); - // Hide the router-outlet, SimpleCmp should be destroyed - fixture.componentInstance.showRouterOutlet = false; - advance(fixture); - expect(fixture.debugElement.query(By.directive(SimpleCmp))).toBeNull(); + // Hide the router-outlet, SimpleCmp should be destroyed + fixture.componentInstance.showRouterOutlet = false; + advance(fixture); + expect(fixture.debugElement.query(By.directive(SimpleCmp))).toBeNull(); - // Show the router-outlet, SimpleCmp should be re-created - fixture.componentInstance.showRouterOutlet = true; - advance(fixture); - expect(fixture.debugElement.query(By.directive(SimpleCmp))).toBeTruthy(); - })); + // Show the router-outlet, SimpleCmp should be re-created + fixture.componentInstance.showRouterOutlet = true; + advance(fixture); + expect(fixture.debugElement.query(By.directive(SimpleCmp))).toBeTruthy(); + })); - it('should allow to attach parent route with fresh child route', fakeAsync(() => { - const CREATED_COMPS = new InjectionToken('CREATED_COMPS'); + it('should allow to attach parent route with fresh child route', fakeAsync(() => { + const CREATED_COMPS = new InjectionToken('CREATED_COMPS'); - @Component({selector: 'root', template: ``}) - class Root { - } + @Component({selector: 'root', template: ``}) + class Root { + } - @Component({selector: 'parent', template: ``}) - class Parent { - constructor(@Inject(CREATED_COMPS) createdComps: string[]) { - createdComps.push('parent'); + @Component({selector: 'parent', template: ``}) + class Parent { + constructor(@Inject(CREATED_COMPS) createdComps: string[]) { + createdComps.push('parent'); + } } - } - @Component({selector: 'child', template: `child`}) - class Child { - constructor(@Inject(CREATED_COMPS) createdComps: string[]) { - createdComps.push('child'); + @Component({selector: 'child', template: `child`}) + class Child { + constructor(@Inject(CREATED_COMPS) createdComps: string[]) { + createdComps.push('child'); + } } - } - @NgModule({ - declarations: [Root, Parent, Child], - imports: [ - CommonModule, - ...ROUTER_DIRECTIVES, - ], - providers: [ - {provide: RouteReuseStrategy, useClass: AttachDetachReuseStrategy}, - {provide: CREATED_COMPS, useValue: []}, - provideRouter([ - {path: 'a', component: Parent, children: [{path: 'b', component: Child}]}, - {path: 'c', component: SimpleCmp} - ]), - ] - }) - class TestModule { - } - TestBed.configureTestingModule({imports: [TestModule]}); + @NgModule({ + declarations: [Root, Parent, Child], + imports: [ + CommonModule, + ...ROUTER_DIRECTIVES, + ], + providers: [ + {provide: RouteReuseStrategy, useClass: AttachDetachReuseStrategy}, + {provide: CREATED_COMPS, useValue: []}, + provideRouter([ + {path: 'a', component: Parent, children: [{path: 'b', component: Child}]}, + {path: 'c', component: SimpleCmp} + ]), + ] + }) + class TestModule { + } + TestBed.configureTestingModule({imports: [TestModule]}); - const router = TestBed.inject(Router); - const fixture = createRoot(router, Root); - const createdComps = TestBed.inject(CREATED_COMPS); + const router = TestBed.inject(Router); + const fixture = createRoot(router, Root); + const createdComps = TestBed.inject(CREATED_COMPS); - expect(createdComps).toEqual([]); + expect(createdComps).toEqual([]); - router.navigateByUrl('/a/b'); - advance(fixture); - expect(createdComps).toEqual(['parent', 'child']); + router.navigateByUrl('/a/b'); + advance(fixture); + expect(createdComps).toEqual(['parent', 'child']); - router.navigateByUrl('/c'); - advance(fixture); - expect(createdComps).toEqual(['parent', 'child']); + router.navigateByUrl('/c'); + advance(fixture); + expect(createdComps).toEqual(['parent', 'child']); - // 'a' parent route will be reused by the `RouteReuseStrategy`, child 'b' should be - // recreated - router.navigateByUrl('/a/b'); - advance(fixture); - expect(createdComps).toEqual(['parent', 'child', 'child']); - })); + // 'a' parent route will be reused by the `RouteReuseStrategy`, child 'b' should be + // recreated + router.navigateByUrl('/a/b'); + advance(fixture); + expect(createdComps).toEqual(['parent', 'child', 'child']); + })); - it('should not try to detach the outlet of a route that does not get to attach a component', - fakeAsync(() => { - @Component({selector: 'root', template: ``}) - class Root { - } + it('should not try to detach the outlet of a route that does not get to attach a component', + fakeAsync(() => { + @Component({selector: 'root', template: ``}) + class Root { + } - @Component({selector: 'component-a', template: 'Component A'}) - class ComponentA { - } + @Component({selector: 'component-a', template: 'Component A'}) + class ComponentA { + } - @Component({selector: 'component-b', template: 'Component B'}) - class ComponentB { - } + @Component({selector: 'component-b', template: 'Component B'}) + class ComponentB { + } - @NgModule({ - declarations: [ComponentA], - imports: [RouterModule.forChild([{path: '', component: ComponentA}])], - }) - class LoadedModule { - } + @NgModule({ + declarations: [ComponentA], + imports: [RouterModule.forChild([{path: '', component: ComponentA}])], + }) + class LoadedModule { + } - @NgModule({ - declarations: [Root, ComponentB], - imports: [ROUTER_DIRECTIVES], - providers: [ - {provide: RouteReuseStrategy, useClass: AttachDetachReuseStrategy}, - provideRouter([ - {path: 'a', loadChildren: () => LoadedModule}, {path: 'b', component: ComponentB} - ]), - ] - }) - class TestModule { - } + @NgModule({ + declarations: [Root, ComponentB], + imports: [ROUTER_DIRECTIVES], + providers: [ + {provide: RouteReuseStrategy, useClass: AttachDetachReuseStrategy}, + provideRouter([ + {path: 'a', loadChildren: () => LoadedModule}, {path: 'b', component: ComponentB} + ]), + ] + }) + class TestModule { + } - TestBed.configureTestingModule({imports: [TestModule]}); + TestBed.configureTestingModule({imports: [TestModule]}); - const router = TestBed.inject(Router); - const strategy = TestBed.inject(RouteReuseStrategy); - const fixture = createRoot(router, Root); + const router = TestBed.inject(Router); + const strategy = TestBed.inject(RouteReuseStrategy); + const fixture = createRoot(router, Root); - spyOn(strategy, 'shouldDetach').and.callThrough(); + spyOn(strategy, 'shouldDetach').and.callThrough(); - router.navigateByUrl('/a'); - advance(fixture); + router.navigateByUrl('/a'); + advance(fixture); - // Deactivate 'a' - // 'shouldDetach' should not be called for the componentless route - router.navigateByUrl('/b'); - advance(fixture); - expect(strategy.shouldDetach).toHaveBeenCalledTimes(1); - })); + // Deactivate 'a' + // 'shouldDetach' should not be called for the componentless route + router.navigateByUrl('/b'); + advance(fixture); + expect(strategy.shouldDetach).toHaveBeenCalledTimes(1); + })); + }); }); -}); +} function expectEvents(events: Event[], pairs: any[]) { expect(events.length).toEqual(pairs.length); diff --git a/packages/tsconfig.json b/packages/tsconfig.json index ccdee15f31de8..e64978daf6b21 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -32,7 +32,7 @@ "lib": ["es2020", "dom", "dom.iterable"], "skipDefaultLibCheck": true, "skipLibCheck": true, - "types": ["angular", "dom-navigation"] + "types": ["angular"] }, "bazelOptions": { "suppressTsconfigOverrideWarnings": true