From 0b6084e1bf007c4e66f2d74baf1c158b8adc1fc3 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Mon, 10 Mar 2025 16:30:54 -0700 Subject: [PATCH] refactor(router): split remainder of describes in integration test file (#60313) to more easily navigate and identify different suites of integration tests, this commit splits them into several different files PR Close #60313 --- .../duplicate_in_flight_navigations.spec.ts | 187 +++ .../eager_url_update_strategy.spec.ts | 244 +++ .../test/integration/integration.spec.ts | 1493 +---------------- .../test/integration/navigation.spec.ts | 718 ++++++++ .../integration/navigation_errors.spec.ts | 449 +++++ 5 files changed, 1608 insertions(+), 1483 deletions(-) create mode 100644 packages/router/test/integration/duplicate_in_flight_navigations.spec.ts create mode 100644 packages/router/test/integration/eager_url_update_strategy.spec.ts create mode 100644 packages/router/test/integration/navigation.spec.ts create mode 100644 packages/router/test/integration/navigation_errors.spec.ts diff --git a/packages/router/test/integration/duplicate_in_flight_navigations.spec.ts b/packages/router/test/integration/duplicate_in_flight_navigations.spec.ts new file mode 100644 index 00000000000..068b442d3b3 --- /dev/null +++ b/packages/router/test/integration/duplicate_in_flight_navigations.spec.ts @@ -0,0 +1,187 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Injectable, inject as coreInject} from '@angular/core'; +import {Location} from '@angular/common'; +import {TestBed, fakeAsync, tick} from '@angular/core/testing'; +import { + Router, + ActivatedRouteSnapshot, + RouterStateSnapshot, + provideRouter, + withRouterConfig, + NavigationStart, + GuardsCheckEnd, +} from '@angular/router/src'; +import {createRoot, SimpleCmp, advance, RootCmp, BlankCmp} from './integration_helpers'; + +export function duplicateInFlightNavigationsIntegrationSuite() { + 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); + + expect(location.path()).toEqual('/simple'); + })); + + it('should skip duplicate location events', fakeAsync(() => { + const router = TestBed.inject(Router); + const location = TestBed.inject(Location); + const fixture = createRoot(router, RootCmp); + + router.resetConfig([ + { + path: 'blocked', + component: BlankCmp, + canActivate: [() => coreInject(RedirectingGuard).canActivate()], + }, + {path: 'simple', component: SimpleCmp}, + ]); + router.navigateByUrl('/simple'); + advance(fixture); + + location.go('/blocked'); + location.historyGo(0); + + advance(fixture); + expect(fixture.nativeElement.innerHTML).toContain('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); + }); + + await router.navigateByUrl('/blocked'); + await fixture.whenStable(); + + 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']); + }); + + 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'); + })); + + 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(); + } + }); + } + }); + + router.navigateByUrl('one'); + tick(1000); + })); + }); +} diff --git a/packages/router/test/integration/eager_url_update_strategy.spec.ts b/packages/router/test/integration/eager_url_update_strategy.spec.ts new file mode 100644 index 00000000000..78642629c5b --- /dev/null +++ b/packages/router/test/integration/eager_url_update_strategy.spec.ts @@ -0,0 +1,244 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Injectable, inject as coreInject} from '@angular/core'; +import {Location} from '@angular/common'; +import {TestBed, fakeAsync, tick, inject} from '@angular/core/testing'; +import { + DefaultUrlSerializer, + provideRouter, + withRouterConfig, + Router, + GuardsCheckStart, + NavigationStart, + RoutesRecognized, + Navigation, +} from '../../src'; +import {of} from 'rxjs'; +import {delay, mapTo} from 'rxjs/operators'; +import { + advance, + TeamCmp, + RootCmp, + BlankCmp, + SimpleCmp, + AbsoluteSimpleLinkCmp, + createRoot, +} from './integration_helpers'; +import {expect} from '@angular/platform-browser/testing/src/matchers'; + +export function eagerUrlUpdateStrategyIntegrationSuite() { + describe('"eager" urlUpdateStrategy', () => { + @Injectable() + class AuthGuard { + canActivateResult = true; + + canActivate() { + return this.canActivateResult; + } + } + @Injectable() + class DelayedGuard { + canActivate() { + return of('').pipe(delay(1000), mapTo(true)); + } + } + + 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, + 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: ]'); + }), + )); + + it('should set `state`', fakeAsync( + inject([Router, Location], (router: Router, location: Location) => { + 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()!; + } + }); + + 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'); + })); + }); +} diff --git a/packages/router/test/integration/integration.spec.ts b/packages/router/test/integration/integration.spec.ts index f0f38162c38..1b136641b84 100644 --- a/packages/router/test/integration/integration.spec.ts +++ b/packages/router/test/integration/integration.spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {HashLocationStrategy, Location, LocationStrategy} from '@angular/common'; +import {Location} from '@angular/common'; import {ɵprovideFakePlatformNavigation} from '@angular/common/testing'; import { ChangeDetectionStrategy, @@ -16,37 +16,28 @@ import { NgModule, ɵConsole as Console, } from '@angular/core'; -import {ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing'; +import {fakeAsync, inject, TestBed, tick} from '@angular/core/testing'; import {expect} from '@angular/platform-browser/testing/src/matchers'; import { - ActivatedRoute, - ActivatedRouteSnapshot, ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, - DefaultUrlSerializer, Event, GuardsCheckEnd, GuardsCheckStart, - Navigation, NavigationCancel, NavigationCancellationCode, NavigationEnd, NavigationError, NavigationStart, - Params, ResolveEnd, ResolveStart, Router, - RouterLink, RouterModule, - RouterStateSnapshot, RoutesRecognized, } from '@angular/router'; import {RouterTestingHarness} from '@angular/router/testing'; -import {of} from 'rxjs'; -import {delay, mapTo} from 'rxjs/operators'; import {RedirectCommand} from '../../src/models'; import { @@ -55,24 +46,17 @@ import { withRouterConfig, } from '../../src/provide_router'; import { - AbsoluteLinkCmp, - AbsoluteSimpleLinkCmp, advance, BlankCmp, CollectParamsCmp, ComponentRecordingRoutePathAndUrl, ConditionalThrowingCmp, createRoot, - DivLinkWithState, EmptyQueryParamsCmp, expectEvents, - LinkWithQueryParamsAndFragment, - LinkWithState, onlyNavigationStartAndEnd, OutletInNgIf, QueryParamsAndFragmentCmp, - RelativeLinkCmp, - RelativeLinkInIfCmp, RootCmp, RootCmpWithNamedOutlet, RootCmpWithOnInit, @@ -80,8 +64,6 @@ import { RouteCmp, ROUTER_DIRECTIVES, SimpleCmp, - StringLinkButtonCmp, - StringLinkCmp, TeamCmp, TestModule, ThrowingCmp, @@ -96,6 +78,10 @@ import {routerLinkActiveIntegrationSuite} from './router_link_active.spec'; import {routerEventsIntegrationSuite} from './router_events.spec'; import {redirectsIntegrationSuite} from './redirects.spec'; import {routerLinkIntegrationSpec} from './router_links.spec'; +import {navigationIntegrationTestSuite} from './navigation.spec'; +import {eagerUrlUpdateStrategyIntegrationSuite} from './eager_url_update_strategy.spec'; +import {duplicateInFlightNavigationsIntegrationSuite} from './duplicate_in_flight_navigations.spec'; +import {navigationErrorsIntegrationSuite} from './navigation_errors.spec'; for (const browserAPI of ['navigation', 'history'] as const) { describe(`${browserAPI}-based routing`, () => { @@ -136,690 +122,6 @@ for (const browserAPI of ['navigation', 'history'] as const) { }), )); - 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 () => { - 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, []); - }); - - 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({ - 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}]}], - }, - ]); - - const fixture = createRoot(router, RootCmp); - - router.navigateByUrl('/foo/bar'); - advance(fixture); - - const link = fixture.nativeElement.querySelector('a'); - expect(link.getAttribute('href')).toEqual('/foo/simple'); - }), - )); - - 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}, - ]); - - 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); - }), - )); - - 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}, - ]); - - const fixture = createRoot(router, RootCmp); - let navigation: Navigation = null!; - router.events.subscribe((e) => { - if (e instanceof NavigationStart) { - navigation = router.getCurrentNavigation()!; - } - }); - - router.navigateByUrl('/simple', {state: {foo: 'bar'}}); - 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'}); - }), - )); - - 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}, - ]); - - const fixture = createRoot(router, RootCmp); - let navigation: Navigation = null!; - router.events.subscribe((e) => { - if (e instanceof NavigationStart) { - navigation = router.getCurrentNavigation()!; - } - }); - - let state: Record = {foo: 'bar'}; - router.navigateByUrl('/simple', {state}); - tick(); - location.back(); - tick(); - location.forward(); - tick(); - - expect(navigation.extras.state).toBeDefined(); - expect(navigation.extras.state).toEqual(state); - - // Manually set state rather than using navigate() - state = {bar: 'foo'}; - location.replaceState(location.path(), '', state); - location.back(); - tick(); - location.forward(); - tick(); - - expect(navigation.extras.state).toBeDefined(); - expect(navigation.extras.state).toEqual(state); - }), - )); - - 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}, - ]); - - createRoot(router, RootCmp); - - router.navigateByUrl('/first'); - tick(); - router.navigateByUrl('/second'); - tick(); - expect(router.url).toEqual('/second'); - - location.historyGo(-1); - tick(); - expect(router.url).toEqual('/first'); - - location.historyGo(1); - tick(); - expect(router.url).toEqual('/second'); - - location.historyGo(-100); - tick(); - expect(router.url).toEqual('/second'); - - location.historyGo(100); - tick(); - expect(router.url).toEqual('/second'); - - location.historyGo(0); - tick(); - expect(router.url).toEqual('/second'); - - location.historyGo(); - tick(); - expect(router.url).toEqual('/second'); - }), - )); - - 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}, - ]); - - const fixture = createRoot(router, RootCmp); - let navigation: Navigation = null!; - router.events.subscribe((e) => { - if (e instanceof NavigationStart) { - navigation = router.getCurrentNavigation()!; - } - }); - - location.replaceState('', '', 42); - router.navigateByUrl('/simple'); - tick(); - location.back(); - advance(fixture); - - // Angular does not support restoring state to the primitive. - expect(navigation.extras.state).toEqual(undefined); - expect(location.getState()).toEqual({navigationId: 3}); - }), - )); - - 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}, - ]); - - createRoot(router, RootCmp); - - const replaceSpy = spyOn(location, 'replaceState'); - router.navigateByUrl('/a', {replaceUrl: true}); - router.navigateByUrl('/b', {replaceUrl: true}); - tick(); - - expect(replaceSpy.calls.count()).toEqual(1); - }), - )); - - 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}, - ]); - - const fixture = createRoot(router, RootCmp); - - 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'); - }), - )); - }); - - describe('should execute navigations serially', () => { - let log: Array = []; - - beforeEach(() => { - log = []; - - 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; - }, - }, - ], - }); - }); - - describe('route activation', () => { - @Component({ - template: '', - standalone: false, - }) - class Parent { - constructor(route: ActivatedRoute) { - route.params.subscribe((s: Params) => { - log.push(s); - }); - } - } - - @Component({ - template: ` - - - - `, - standalone: false, - }) - class NamedOutletHost { - logDeactivate(route: string) { - log.push(route + ' deactivate'); - } - } - - @Component({ - template: 'child1', - standalone: false, - }) - class Child1 { - constructor() { - log.push('child1 constructor'); - } - ngOnDestroy() { - log.push('child1 destroy'); - } - } - - @Component({ - template: 'child2', - standalone: false, - }) - class Child2 { - constructor() { - log.push('child2 constructor'); - } - ngOnDestroy() { - log.push('child2 destroy'); - } - } - - @Component({ - template: 'child3', - standalone: false, - }) - class Child3 { - constructor() { - log.push('child3 constructor'); - } - ngOnDestroy() { - log.push('child3 destroy'); - } - } - - @NgModule({ - declarations: [Parent, NamedOutletHost, Child1, Child2, Child3], - imports: [RouterModule.forRoot([])], - }) - class 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); - - router.resetConfig([ - { - path: 'parent/:id', - component: Parent, - children: [ - {path: 'child1', component: Child1}, - {path: 'child2', component: Child2}, - ], - }, - ]); - - router.navigateByUrl('/parent/1/child1'); - advance(fixture); - - router.navigateByUrl('/parent/2/child2'); - advance(fixture); - - expect(location.path()).toEqual('/parent/2/child2'); - expect(log).toEqual([ - {id: '1'}, - 'child1 constructor', - 'child1 destroy', - {id: '2'}, - 'child2 constructor', - ]); - })); - - it('should deactivate outlet children with componentless parent', fakeAsync(() => { - TestBed.configureTestingModule({imports: [TestModule]}); - const router = TestBed.inject(Router); - const fixture = createRoot(router, RootCmp); - - 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'}, - ], - }, - ], - }, - { - path: 'other', - component: Parent, - }, - ]); - - router.navigateByUrl('/named-outlets/home/primary'); - advance(fixture); - expect(log).toEqual([ - 'child3 constructor', // primary outlet always first - 'child1 constructor', - 'child2 constructor', - ]); - log.length = 0; - - 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('/other'); - advance(fixture); - expect(log).toEqual([ - 'child1 destroy', - 'first deactivate', - 'child2 destroy', - 'second deactivate', - // route param subscription from 'Parent' component - {}, - ]); - })); - - 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'}, - ], - }, - ], - }, - ]); - - const fixture = createRoot(router, RootCmp); - - router.navigateByUrl('/(first:one)'); - advance(fixture); - expect(log).toEqual(['child1 constructor']); - - log.length = 0; - router.navigateByUrl('/(first:two)'); - advance(fixture); - expect(log).toEqual(['child1 destroy', 'first deactivate', 'child2 constructor']); - })); - }); - - it('should not wait for prior navigations to start a new navigation', fakeAsync( - inject([Router, Location], (router: Router) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([ - {path: 'a', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']}, - {path: 'b', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']}, - ]); - - router.navigateByUrl('/a'); - tick(100); - fixture.detectChanges(); - - router.navigateByUrl('/b'); - tick(100); // 200 - fixture.detectChanges(); - - expect(log).toEqual([ - 'trueRightAway', - 'trueIn2Seconds-start', - 'trueRightAway', - 'trueIn2Seconds-start', - ]); - - tick(2000); // 2200 - fixture.detectChanges(); - - expect(log).toEqual([ - 'trueRightAway', - 'trueIn2Seconds-start', - 'trueRightAway', - 'trueIn2Seconds-start', - 'trueIn2Seconds-end', - 'trueIn2Seconds-end', - ]); - }), - )); - }); - it('Should work inside ChangeDetectionStrategy.OnPush components', fakeAsync(() => { @Component({ selector: 'root-cmp', @@ -1048,221 +350,6 @@ for (const browserAPI of ['navigation', 'history'] as const) { }), )); - describe('"eager" urlUpdateStrategy', () => { - @Injectable() - class AuthGuard { - canActivateResult = true; - - canActivate() { - return this.canActivateResult; - } - } - @Injectable() - class DelayedGuard { - canActivate() { - return of('').pipe(delay(1000), mapTo(true)); - } - } - - 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: ]'); - }), - )); - - it('should set `state`', fakeAsync( - inject([Router, Location], (router: Router, location: Location) => { - 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()!; - } - }); - - 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); @@ -1387,170 +474,6 @@ for (const browserAPI of ['navigation', 'history'] as const) { }), )); - 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); - - expect(location.path()).toEqual('/simple'); - })); - - it('should skip duplicate location events', fakeAsync(() => { - const router = TestBed.inject(Router); - const location = TestBed.inject(Location); - const fixture = createRoot(router, RootCmp); - - router.resetConfig([ - { - path: 'blocked', - component: BlankCmp, - canActivate: [() => coreInject(RedirectingGuard).canActivate()], - }, - {path: 'simple', component: SimpleCmp}, - ]); - router.navigateByUrl('/simple'); - advance(fixture); - - location.go('/blocked'); - location.historyGo(0); - - advance(fixture); - expect(fixture.nativeElement.innerHTML).toContain('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); - }); - - await router.navigateByUrl('/blocked'); - await fixture.whenStable(); - - 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']); - }); - - 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'); - })); - - 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(); - } - }); - } - }); - - router.navigateByUrl('one'); - tick(1000); - })); - }); - it('should support secondary routes', fakeAsync( inject([Router], (router: Router) => { const fixture = createRoot(router, RootCmp); @@ -1712,18 +635,6 @@ for (const browserAPI of ['navigation', 'history'] as const) { }), )); - it('should throw an error when one of the commands is null/undefined', fakeAsync( - inject([Router], (router: Router) => { - createRoot(router, RootCmp); - - router.resetConfig([{path: 'query', component: EmptyQueryParamsCmp}]); - - 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); @@ -1855,394 +766,6 @@ for (const browserAPI of ['navigation', 'history'] as const) { }), )); - 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, - }, - ], - withRouterConfig({resolveNavigationPromiseOnError: true}), - withNavigationErrorHandler(() => (coreInject(Handler).handlerCalled = true)), - ), - ], - }); - const router = TestBed.inject(Router); - await router.navigateByUrl('/throw'); - expect(TestBed.inject(Handler).handlerCalled).toBeTrue(); - }); - - it('can redirect from error handler with RouterModule.forRoot', async () => { - TestBed.configureTestingModule({ - imports: [ - RouterModule.forRoot( - [ - { - path: 'throw', - canMatch: [ - () => { - throw new Error(''); - }, - ], - component: BlankCmp, - }, - {path: 'error', component: BlankCmp}, - ], - { - resolveNavigationPromiseOnError: true, - errorHandler: () => new RedirectCommand(coreInject(Router).parseUrl('/error')), - }, - ), - ], - }); - const router = TestBed.inject(Router); - let emitNavigationError = false; - let emitNavigationCancelWithRedirect = false; - router.events.subscribe((e) => { - if (e instanceof NavigationError) { - emitNavigationError = true; - } - if (e instanceof NavigationCancel && e.code === NavigationCancellationCode.Redirect) { - emitNavigationCancelWithRedirect = true; - } - }); - await router.navigateByUrl('/throw'); - expect(router.url).toEqual('/error'); - expect(emitNavigationError).toBe(false); - expect(emitNavigationCancelWithRedirect).toBe(true); - }); - - it('can redirect from error handler', async () => { - TestBed.configureTestingModule({ - providers: [ - provideRouter( - [ - { - path: 'throw', - canMatch: [ - () => { - throw new Error(''); - }, - ], - component: BlankCmp, - }, - {path: 'error', component: BlankCmp}, - ], - withRouterConfig({resolveNavigationPromiseOnError: true}), - withNavigationErrorHandler( - () => new RedirectCommand(coreInject(Router).parseUrl('/error')), - ), - ), - ], - }); - const router = TestBed.inject(Router); - let emitNavigationError = false; - let emitNavigationCancelWithRedirect = false; - router.events.subscribe((e) => { - if (e instanceof NavigationError) { - emitNavigationError = true; - } - if (e instanceof NavigationCancel && e.code === NavigationCancellationCode.Redirect) { - emitNavigationCancelWithRedirect = true; - } - }); - await router.navigateByUrl('/throw'); - expect(router.url).toEqual('/error'); - expect(emitNavigationError).toBe(false); - expect(emitNavigationCancelWithRedirect).toBe(true); - }); - - it('should not break navigation if an error happens in NavigationErrorHandler', async () => { - TestBed.configureTestingModule({ - providers: [ - provideRouter( - [ - { - path: 'throw', - canMatch: [ - () => { - throw new Error(''); - }, - ], - component: BlankCmp, - }, - {path: '**', component: BlankCmp}, - ], - withRouterConfig({resolveNavigationPromiseOnError: true}), - withNavigationErrorHandler(() => { - throw new Error('e'); - }), - ), - ], - }); - const router = TestBed.inject(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: 'simple', component: SimpleCmp}, - {path: 'throwing', component: ThrowingCmp}, - ]); - - router.navigateByUrl('/simple'); - advance(fixture); - - 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('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); - - 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(); - } - }); - - location.go('/throwing'); - location.historyGo(0); - advance(fixture); - - expect(routerUrlBeforeEmittingError).toEqual('/simple'); - expect(locationUrlBeforeEmittingError).toEqual('/simple'); - })); - - 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(''); - }), - )); - - it('should not swallow errors', fakeAsync( - inject([Router], (router: Router) => { - const fixture = createRoot(router, RootCmp); - - router.resetConfig([{path: 'simple', component: SimpleCmp}]); - - router.navigateByUrl('/invalid'); - expect(() => advance(fixture)).toThrow(); - - router.navigateByUrl('/invalid2'); - expect(() => advance(fixture)).toThrow(); - }), - )); - - 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); @@ -2402,6 +925,10 @@ for (const browserAPI of ['navigation', 'history'] as const) { }), )); + navigationErrorsIntegrationSuite(); + eagerUrlUpdateStrategyIntegrationSuite(); + duplicateInFlightNavigationsIntegrationSuite(); + navigationIntegrationTestSuite(); routeDataIntegrationSuite(); routerLinkIntegrationSpec(); redirectsIntegrationSuite(); diff --git a/packages/router/test/integration/navigation.spec.ts b/packages/router/test/integration/navigation.spec.ts new file mode 100644 index 00000000000..3d09ac64f54 --- /dev/null +++ b/packages/router/test/integration/navigation.spec.ts @@ -0,0 +1,718 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Component, inject as coreInject, NgModule} from '@angular/core'; +import {Location} from '@angular/common'; +import {fakeAsync, TestBed, tick, inject} from '@angular/core/testing'; +import { + provideRouter, + Navigation, + withRouterConfig, + Router, + NavigationStart, + NavigationEnd, + RouterLink, + ActivatedRoute, + Params, + RouterModule, +} from '../../src'; +import { + RootCmp, + SimpleCmp, + onlyNavigationStartAndEnd, + expectEvents, + RelativeLinkCmp, + createRoot, + advance, +} from './integration_helpers'; + +export function navigationIntegrationTestSuite() { + describe('navigation', () => { + 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 () => { + 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, []); + }); + + 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({ + 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}]}], + }, + ]); + + const fixture = createRoot(router, RootCmp); + + router.navigateByUrl('/foo/bar'); + advance(fixture); + + const link = fixture.nativeElement.querySelector('a'); + expect(link.getAttribute('href')).toEqual('/foo/simple'); + }), + )); + + 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}, + ]); + + 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); + }), + )); + + 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}, + ]); + + const fixture = createRoot(router, RootCmp); + let navigation: Navigation = null!; + router.events.subscribe((e) => { + if (e instanceof NavigationStart) { + navigation = router.getCurrentNavigation()!; + } + }); + + router.navigateByUrl('/simple', {state: {foo: 'bar'}}); + 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'}); + }), + )); + + 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}, + ]); + + const fixture = createRoot(router, RootCmp); + let navigation: Navigation = null!; + router.events.subscribe((e) => { + if (e instanceof NavigationStart) { + navigation = router.getCurrentNavigation()!; + } + }); + + let state: Record = {foo: 'bar'}; + router.navigateByUrl('/simple', {state}); + tick(); + location.back(); + tick(); + location.forward(); + tick(); + + expect(navigation.extras.state).toBeDefined(); + expect(navigation.extras.state).toEqual(state); + + // Manually set state rather than using navigate() + state = {bar: 'foo'}; + location.replaceState(location.path(), '', state); + location.back(); + tick(); + location.forward(); + tick(); + + expect(navigation.extras.state).toBeDefined(); + expect(navigation.extras.state).toEqual(state); + }), + )); + + 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}, + ]); + + createRoot(router, RootCmp); + + router.navigateByUrl('/first'); + tick(); + router.navigateByUrl('/second'); + tick(); + expect(router.url).toEqual('/second'); + + location.historyGo(-1); + tick(); + expect(router.url).toEqual('/first'); + + location.historyGo(1); + tick(); + expect(router.url).toEqual('/second'); + + location.historyGo(-100); + tick(); + expect(router.url).toEqual('/second'); + + location.historyGo(100); + tick(); + expect(router.url).toEqual('/second'); + + location.historyGo(0); + tick(); + expect(router.url).toEqual('/second'); + + location.historyGo(); + tick(); + expect(router.url).toEqual('/second'); + }), + )); + + 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}, + ]); + + const fixture = createRoot(router, RootCmp); + let navigation: Navigation = null!; + router.events.subscribe((e) => { + if (e instanceof NavigationStart) { + navigation = router.getCurrentNavigation()!; + } + }); + + location.replaceState('', '', 42); + router.navigateByUrl('/simple'); + tick(); + location.back(); + advance(fixture); + + // Angular does not support restoring state to the primitive. + expect(navigation.extras.state).toEqual(undefined); + expect(location.getState()).toEqual({navigationId: 3}); + }), + )); + + 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}, + ]); + + createRoot(router, RootCmp); + + const replaceSpy = spyOn(location, 'replaceState'); + router.navigateByUrl('/a', {replaceUrl: true}); + router.navigateByUrl('/b', {replaceUrl: true}); + tick(); + + expect(replaceSpy.calls.count()).toEqual(1); + }), + )); + + 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}, + ]); + + const fixture = createRoot(router, RootCmp); + + 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'); + }), + )); + }); + + describe('should execute navigations serially', () => { + let log: Array = []; + + beforeEach(() => { + log = []; + + 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; + }, + }, + ], + }); + }); + + describe('route activation', () => { + @Component({ + template: '', + standalone: false, + }) + class Parent { + constructor(route: ActivatedRoute) { + route.params.subscribe((s: Params) => { + log.push(s); + }); + } + } + + @Component({ + template: ` + + + + `, + standalone: false, + }) + class NamedOutletHost { + logDeactivate(route: string) { + log.push(route + ' deactivate'); + } + } + + @Component({ + template: 'child1', + standalone: false, + }) + class Child1 { + constructor() { + log.push('child1 constructor'); + } + ngOnDestroy() { + log.push('child1 destroy'); + } + } + + @Component({ + template: 'child2', + standalone: false, + }) + class Child2 { + constructor() { + log.push('child2 constructor'); + } + ngOnDestroy() { + log.push('child2 destroy'); + } + } + + @Component({ + template: 'child3', + standalone: false, + }) + class Child3 { + constructor() { + log.push('child3 constructor'); + } + ngOnDestroy() { + log.push('child3 destroy'); + } + } + + @NgModule({ + declarations: [Parent, NamedOutletHost, Child1, Child2, Child3], + imports: [RouterModule.forRoot([])], + }) + class 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); + + router.resetConfig([ + { + path: 'parent/:id', + component: Parent, + children: [ + {path: 'child1', component: Child1}, + {path: 'child2', component: Child2}, + ], + }, + ]); + + router.navigateByUrl('/parent/1/child1'); + advance(fixture); + + router.navigateByUrl('/parent/2/child2'); + advance(fixture); + + expect(location.path()).toEqual('/parent/2/child2'); + expect(log).toEqual([ + {id: '1'}, + 'child1 constructor', + 'child1 destroy', + {id: '2'}, + 'child2 constructor', + ]); + })); + + it('should deactivate outlet children with componentless parent', fakeAsync(() => { + TestBed.configureTestingModule({imports: [TestModule]}); + const router = TestBed.inject(Router); + const fixture = createRoot(router, RootCmp); + + 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'}, + ], + }, + ], + }, + { + path: 'other', + component: Parent, + }, + ]); + + router.navigateByUrl('/named-outlets/home/primary'); + advance(fixture); + expect(log).toEqual([ + 'child3 constructor', // primary outlet always first + 'child1 constructor', + 'child2 constructor', + ]); + log.length = 0; + + 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('/other'); + advance(fixture); + expect(log).toEqual([ + 'child1 destroy', + 'first deactivate', + 'child2 destroy', + 'second deactivate', + // route param subscription from 'Parent' component + {}, + ]); + })); + + 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'}, + ], + }, + ], + }, + ]); + + const fixture = createRoot(router, RootCmp); + + router.navigateByUrl('/(first:one)'); + advance(fixture); + expect(log).toEqual(['child1 constructor']); + + log.length = 0; + router.navigateByUrl('/(first:two)'); + advance(fixture); + expect(log).toEqual(['child1 destroy', 'first deactivate', 'child2 constructor']); + })); + }); + + it('should not wait for prior navigations to start a new navigation', fakeAsync( + inject([Router, Location], (router: Router) => { + const fixture = createRoot(router, RootCmp); + + router.resetConfig([ + {path: 'a', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']}, + {path: 'b', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']}, + ]); + + router.navigateByUrl('/a'); + tick(100); + fixture.detectChanges(); + + router.navigateByUrl('/b'); + tick(100); // 200 + fixture.detectChanges(); + + expect(log).toEqual([ + 'trueRightAway', + 'trueIn2Seconds-start', + 'trueRightAway', + 'trueIn2Seconds-start', + ]); + + tick(2000); // 2200 + fixture.detectChanges(); + + expect(log).toEqual([ + 'trueRightAway', + 'trueIn2Seconds-start', + 'trueRightAway', + 'trueIn2Seconds-start', + 'trueIn2Seconds-end', + 'trueIn2Seconds-end', + ]); + }), + )); + }); +} diff --git a/packages/router/test/integration/navigation_errors.spec.ts b/packages/router/test/integration/navigation_errors.spec.ts new file mode 100644 index 00000000000..5abc4917668 --- /dev/null +++ b/packages/router/test/integration/navigation_errors.spec.ts @@ -0,0 +1,449 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ +import {inject as coreInject, Injectable} from '@angular/core'; +import {expect} from '@angular/platform-browser/testing/src/matchers'; +import {Location} from '@angular/common'; +import {fakeAsync, TestBed, tick, inject} from '@angular/core/testing'; +import { + Router, + NavigationStart, + NavigationError, + RoutesRecognized, + GuardsCheckStart, + Event, + ChildActivationStart, + ActivationStart, + GuardsCheckEnd, + ResolveStart, + ResolveEnd, + ActivationEnd, + ChildActivationEnd, + NavigationEnd, + provideRouter, + withRouterConfig, + withNavigationErrorHandler, + RouterModule, + RedirectCommand, + NavigationCancel, + NavigationCancellationCode, +} from '../../src'; +import {RouterTestingHarness} from '@angular/router/testing'; +import { + createRoot, + RootCmp, + BlankCmp, + UserCmp, + advance, + expectEvents, + SimpleCmp, + ThrowingCmp, + ConditionalThrowingCmp, + EmptyQueryParamsCmp, +} from './integration_helpers'; + +export function navigationErrorsIntegrationSuite() { + 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, + }, + ], + withRouterConfig({resolveNavigationPromiseOnError: true}), + withNavigationErrorHandler(() => (coreInject(Handler).handlerCalled = true)), + ), + ], + }); + const router = TestBed.inject(Router); + await router.navigateByUrl('/throw'); + expect(TestBed.inject(Handler).handlerCalled).toBeTrue(); + }); + + it('can redirect from error handler with RouterModule.forRoot', async () => { + TestBed.configureTestingModule({ + imports: [ + RouterModule.forRoot( + [ + { + path: 'throw', + canMatch: [ + () => { + throw new Error(''); + }, + ], + component: BlankCmp, + }, + {path: 'error', component: BlankCmp}, + ], + { + resolveNavigationPromiseOnError: true, + errorHandler: () => new RedirectCommand(coreInject(Router).parseUrl('/error')), + }, + ), + ], + }); + const router = TestBed.inject(Router); + let emitNavigationError = false; + let emitNavigationCancelWithRedirect = false; + router.events.subscribe((e) => { + if (e instanceof NavigationError) { + emitNavigationError = true; + } + if (e instanceof NavigationCancel && e.code === NavigationCancellationCode.Redirect) { + emitNavigationCancelWithRedirect = true; + } + }); + await router.navigateByUrl('/throw'); + expect(router.url).toEqual('/error'); + expect(emitNavigationError).toBe(false); + expect(emitNavigationCancelWithRedirect).toBe(true); + }); + + it('can redirect from error handler', async () => { + TestBed.configureTestingModule({ + providers: [ + provideRouter( + [ + { + path: 'throw', + canMatch: [ + () => { + throw new Error(''); + }, + ], + component: BlankCmp, + }, + {path: 'error', component: BlankCmp}, + ], + withRouterConfig({resolveNavigationPromiseOnError: true}), + withNavigationErrorHandler( + () => new RedirectCommand(coreInject(Router).parseUrl('/error')), + ), + ), + ], + }); + const router = TestBed.inject(Router); + let emitNavigationError = false; + let emitNavigationCancelWithRedirect = false; + router.events.subscribe((e) => { + if (e instanceof NavigationError) { + emitNavigationError = true; + } + if (e instanceof NavigationCancel && e.code === NavigationCancellationCode.Redirect) { + emitNavigationCancelWithRedirect = true; + } + }); + await router.navigateByUrl('/throw'); + expect(router.url).toEqual('/error'); + expect(emitNavigationError).toBe(false); + expect(emitNavigationCancelWithRedirect).toBe(true); + }); + + it('should not break navigation if an error happens in NavigationErrorHandler', async () => { + TestBed.configureTestingModule({ + providers: [ + provideRouter( + [ + { + path: 'throw', + canMatch: [ + () => { + throw new Error(''); + }, + ], + component: BlankCmp, + }, + {path: '**', component: BlankCmp}, + ], + withRouterConfig({resolveNavigationPromiseOnError: true}), + withNavigationErrorHandler(() => { + throw new Error('e'); + }), + ), + ], + }); + const router = TestBed.inject(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: 'simple', component: SimpleCmp}, + {path: 'throwing', component: ThrowingCmp}, + ]); + + router.navigateByUrl('/simple'); + advance(fixture); + + 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('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); + + 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(); + } + }); + + location.go('/throwing'); + location.historyGo(0); + advance(fixture); + + expect(routerUrlBeforeEmittingError).toEqual('/simple'); + expect(locationUrlBeforeEmittingError).toEqual('/simple'); + })); + + 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(''); + }), + )); + + it('should not swallow errors', fakeAsync( + inject([Router], (router: Router) => { + const fixture = createRoot(router, RootCmp); + + router.resetConfig([{path: 'simple', component: SimpleCmp}]); + + router.navigateByUrl('/invalid'); + expect(() => advance(fixture)).toThrow(); + + router.navigateByUrl('/invalid2'); + expect(() => advance(fixture)).toThrow(); + }), + )); + + 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 throw an error when one of the commands is null/undefined', fakeAsync( + inject([Router], (router: Router) => { + createRoot(router, RootCmp); + + router.resetConfig([{path: 'query', component: EmptyQueryParamsCmp}]); + + expect(() => router.navigate([undefined, 'query'])).toThrowError( + /The requested path contains undefined segment at index 0/, + ); + }), + )); +}