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/,
+ );
+ }),
+ ));
+}