Files
angular__angular/packages/router/test/computed_state_restoration.spec.ts
Andrew Scott 50d7916278 feat(router): Add router configuration to resolve navigation promise on error (#48910)
With the deprecation of the configurable errorHandler in the Router, there is a missing
use-case to prevent the navigation promise from rejecting on an error. This rejection
results in unhandled promise rejections. This commit allows developers to instruct
the router to instead resolve the navigation promise with 'false', which matches
the behavior of other failed navigations.

Resolving the Promise would be the ideal default behavior. It is rare
that any code handles the navigation Promise at all and even more rare
that the Promise rejection is caught. Updating the default value for
this option should be considered for an upcoming major version.

fixes #48902

PR Close #48910
2023-12-04 21:49:35 -08:00

565 lines
21 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {CommonModule, Location} from '@angular/common';
import {provideLocationMocks, SpyLocation} from '@angular/common/testing';
import {Component, Injectable, NgModule, Type} from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {expect} from '@angular/platform-browser/testing/src/matchers';
import {Router, RouterModule, RouterOutlet, UrlTree, withRouterConfig} from '@angular/router';
import {EMPTY, of} from 'rxjs';
import {provideRouter} from '../src/provide_router';
import {isUrlTree} from '../src/url_tree';
describe('`restoredState#ɵrouterPageId`', () => {
@Injectable({providedIn: 'root'})
class MyCanDeactivateGuard {
allow: boolean = true;
canDeactivate(): boolean {
return this.allow;
}
}
@Injectable({providedIn: 'root'})
class ThrowingCanActivateGuard {
throw = false;
constructor(private router: Router) {}
canActivate(): boolean {
if (this.throw) {
throw new Error('error in guard');
}
return true;
}
}
@Injectable({providedIn: 'root'})
class MyCanActivateGuard {
allow: boolean = true;
redirectTo: string|null|UrlTree = null;
constructor(private router: Router) {}
canActivate(): boolean|UrlTree {
if (typeof this.redirectTo === 'string') {
this.router.navigateByUrl(this.redirectTo);
} else if (isUrlTree(this.redirectTo)) {
return this.redirectTo;
}
return this.allow;
}
}
@Injectable({providedIn: 'root'})
class MyResolve {
myresolve = of(2);
resolve() {
return this.myresolve;
}
}
let fixture: ComponentFixture<unknown>;
function createNavigationHistory(urlUpdateStrategy: 'eager'|'deferred' = 'deferred') {
TestBed.configureTestingModule({
imports: [TestModule],
providers: [
{provide: 'alwaysFalse', useValue: (a: any) => false},
{provide: Location, useClass: SpyLocation},
provideRouter(
[
{
path: 'first',
component: SimpleCmp,
canDeactivate: [MyCanDeactivateGuard],
canActivate: [MyCanActivateGuard, ThrowingCanActivateGuard],
resolve: {x: MyResolve}
},
{
path: 'second',
component: SimpleCmp,
canDeactivate: [MyCanDeactivateGuard],
canActivate: [MyCanActivateGuard, ThrowingCanActivateGuard],
resolve: {x: MyResolve}
},
{
path: 'third',
component: SimpleCmp,
canDeactivate: [MyCanDeactivateGuard],
canActivate: [MyCanActivateGuard, ThrowingCanActivateGuard],
resolve: {x: MyResolve}
},
{
path: 'unguarded',
component: SimpleCmp,
},
{
path: 'throwing',
component: ThrowingCmp,
},
{
path: 'loaded',
loadChildren: () => of(ModuleWithSimpleCmpAsRoute),
canLoad: ['alwaysFalse']
}
],
withRouterConfig({
urlUpdateStrategy,
canceledNavigationResolution: 'computed',
resolveNavigationPromiseOnError: true,
})),
]
});
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
fixture = createRoot(router, RootCmp);
router.initialNavigation();
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 0}));
router.navigateByUrl('/first');
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1}));
router.navigateByUrl('/second');
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
router.navigateByUrl('/third');
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
location.back();
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
}
describe('deferred url updates', () => {
beforeEach(fakeAsync(() => {
createNavigationHistory();
}));
it('should work when CanActivate returns false', fakeAsync(() => {
const location = TestBed.inject(Location);
const router = TestBed.inject(Router);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
TestBed.inject(MyCanActivateGuard).allow = false;
location.back();
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
TestBed.inject(MyCanActivateGuard).allow = true;
location.back();
advance(fixture);
expect(location.path()).toEqual('/first');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1}));
TestBed.inject(MyCanActivateGuard).allow = false;
location.forward();
advance(fixture);
expect(location.path()).toEqual('/first');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1}));
router.navigateByUrl('/second');
advance(fixture);
expect(location.path()).toEqual('/first');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1}));
}));
it('should work when CanDeactivate returns false', fakeAsync(() => {
const location = TestBed.inject(Location);
const router = TestBed.inject(Router);
TestBed.inject(MyCanDeactivateGuard).allow = false;
location.back();
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
location.forward();
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
router.navigateByUrl('third');
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
TestBed.inject(MyCanDeactivateGuard).allow = true;
location.forward();
advance(fixture);
expect(location.path()).toEqual('/third');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
}));
it('should work when using `NavigationExtras.skipLocationChange`', fakeAsync(() => {
const location = TestBed.inject(Location);
const router = TestBed.inject(Router);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
router.navigateByUrl('/first', {skipLocationChange: true});
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
router.navigateByUrl('/third');
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
location.back();
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
}));
it('should work when using `NavigationExtras.replaceUrl`', fakeAsync(() => {
const location = TestBed.inject(Location);
const router = TestBed.inject(Router);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
router.navigateByUrl('/first', {replaceUrl: true});
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
expect(location.path()).toEqual('/first');
}));
it('should work when CanLoad returns false', fakeAsync(() => {
const location = TestBed.inject(Location);
const router = TestBed.inject(Router);
router.navigateByUrl('/loaded');
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
}));
it('should work when resolve empty', fakeAsync(() => {
const location = TestBed.inject(Location);
const router = TestBed.inject(Router);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
TestBed.inject(MyResolve).myresolve = EMPTY;
location.back();
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
expect(location.path()).toEqual('/second');
TestBed.inject(MyResolve).myresolve = of(2);
location.back();
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1}));
expect(location.path()).toEqual('/first');
TestBed.inject(MyResolve).myresolve = EMPTY;
// We should cancel the navigation to `/third` when myresolve is empty
router.navigateByUrl('/third');
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1}));
expect(location.path()).toEqual('/first');
location.historyGo(2);
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1}));
expect(location.path()).toEqual('/first');
TestBed.inject(MyResolve).myresolve = of(2);
location.historyGo(2);
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
expect(location.path()).toEqual('/third');
TestBed.inject(MyResolve).myresolve = EMPTY;
location.historyGo(-2);
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
expect(location.path()).toEqual('/third');
}));
it('should work when an error occurred during navigation', fakeAsync(() => {
const location = TestBed.inject(Location);
const router = TestBed.inject(Router);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
router.navigateByUrl('/invalid').catch(() => null);
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
location.back();
advance(fixture);
expect(location.path()).toEqual('/first');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1}));
}));
it('should work when CanActivate redirects', fakeAsync(() => {
const location = TestBed.inject(Location);
TestBed.inject(MyCanActivateGuard).redirectTo = '/unguarded';
location.back();
advance(fixture);
expect(location.path()).toEqual('/unguarded');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
TestBed.inject(MyCanActivateGuard).redirectTo = null;
location.back();
advance(fixture);
expect(location.path()).toEqual('/first');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1}));
}));
it('restores history correctly when component throws error in constructor and replaceUrl=true',
fakeAsync(() => {
const location = TestBed.inject(Location);
const router = TestBed.inject(Router);
router.navigateByUrl('/throwing', {replaceUrl: true}).catch(() => null);
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
location.back();
advance(fixture);
expect(location.path()).toEqual('/first');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1}));
}));
it('restores history correctly when component throws error in constructor and skipLocationChange=true',
fakeAsync(() => {
const location = TestBed.inject(Location);
const router = TestBed.inject(Router);
router.navigateByUrl('/throwing', {skipLocationChange: true}).catch(() => null);
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
location.back();
advance(fixture);
expect(location.path()).toEqual('/first');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1}));
}));
});
describe('eager url updates', () => {
beforeEach(fakeAsync(() => {
createNavigationHistory('eager');
}));
it('should work', fakeAsync(() => {
const location = TestBed.inject(Location) as SpyLocation;
const router = TestBed.inject(Router);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
TestBed.inject(MyCanActivateGuard).allow = false;
router.navigateByUrl('/first');
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
location.back();
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
}));
it('should work when CanActivate redirects', fakeAsync(() => {
const location = TestBed.inject(Location);
const router = TestBed.inject(Router);
TestBed.inject(MyCanActivateGuard).redirectTo = '/unguarded';
router.navigateByUrl('/third');
advance(fixture);
expect(location.path()).toEqual('/unguarded');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 4}));
TestBed.inject(MyCanActivateGuard).redirectTo = null;
location.back();
advance(fixture);
expect(location.path()).toEqual('/third');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
}));
it('should work when CanActivate redirects with UrlTree', fakeAsync(() => {
// Note that this test is different from the above case because we are able to specifically
// handle the `UrlTree` case as a proper redirect and set `replaceUrl: true` on the
// follow-up navigation.
const location = TestBed.inject(Location);
const router = TestBed.inject(Router);
let allowNavigation = true;
router.resetConfig([
{path: 'initial', children: []},
{path: 'redirectFrom', redirectTo: 'redirectTo'},
{path: 'redirectTo', children: [], canActivate: [() => allowNavigation]},
]);
// already at '2' from the `beforeEach` navigations
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
router.navigateByUrl('/initial');
advance(fixture);
expect(location.path()).toEqual('/initial');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
TestBed.inject(MyCanActivateGuard).redirectTo = null;
router.navigateByUrl('redirectTo');
advance(fixture);
expect(location.path()).toEqual('/redirectTo');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 4}));
// Navigate to different URL but get redirected to same URL should result in same page id
router.navigateByUrl('redirectFrom');
advance(fixture);
expect(location.path()).toEqual('/redirectTo');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 4}));
// Back and forward should have page IDs 1 apart
location.back();
advance(fixture);
expect(location.path()).toEqual('/initial');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
location.forward();
advance(fixture);
expect(location.path()).toEqual('/redirectTo');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 4}));
// Rejected navigation after redirect to same URL should have the same page ID
allowNavigation = false;
router.navigateByUrl('redirectFrom');
advance(fixture);
expect(location.path()).toEqual('/redirectTo');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 4}));
}));
it('redirectTo with same url, and guard reject', fakeAsync(() => {
const location = TestBed.inject(Location);
const router = TestBed.inject(Router);
TestBed.inject(MyCanActivateGuard).redirectTo = router.createUrlTree(['unguarded']);
router.navigateByUrl('/third');
advance(fixture);
expect(location.path()).toEqual('/unguarded');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
TestBed.inject(MyCanActivateGuard).redirectTo = null;
location.back();
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
}));
});
for (const urlUpdateStrategy of ['deferred', 'eager'] as const) {
it(`restores history correctly when an error is thrown in guard with urlUpdateStrategy ${
urlUpdateStrategy}`,
fakeAsync(() => {
createNavigationHistory(urlUpdateStrategy);
const location = TestBed.inject(Location);
TestBed.inject(ThrowingCanActivateGuard).throw = true;
location.back();
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
TestBed.inject(ThrowingCanActivateGuard).throw = false;
location.back();
advance(fixture);
expect(location.path()).toEqual('/first');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1}));
}));
it(`restores history correctly when component throws error in constructor with urlUpdateStrategy ${
urlUpdateStrategy}`,
fakeAsync(() => {
createNavigationHistory(urlUpdateStrategy);
const location = TestBed.inject(Location);
const router = TestBed.inject(Router);
router.navigateByUrl('/throwing').catch(() => null);
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
location.back();
advance(fixture);
expect(location.path()).toEqual('/first');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1}));
}));
}
});
function createRoot<T>(router: Router, type: Type<T>): ComponentFixture<T> {
const f = TestBed.createComponent(type);
advance(f);
router.initialNavigation();
advance(f);
return f;
}
@Component({selector: 'simple-cmp', template: `simple`})
class SimpleCmp {
}
@NgModule(
{imports: [RouterModule.forChild([{path: '', component: SimpleCmp}])]},
)
class ModuleWithSimpleCmpAsRoute {
}
@Component({selector: 'root-cmp', template: `<router-outlet></router-outlet>`})
class RootCmp {
}
@Component({selector: 'throwing-cmp', template: ''})
class ThrowingCmp {
constructor() {
throw new Error('Throwing Cmp');
}
}
function advance(fixture: ComponentFixture<unknown>, millis?: number): void {
tick(millis);
fixture.detectChanges();
}
@NgModule({
imports: [
RouterOutlet,
CommonModule,
],
providers: [
provideLocationMocks(),
],
exports: [SimpleCmp, RootCmp, ThrowingCmp],
declarations: [SimpleCmp, RootCmp, ThrowingCmp]
})
class TestModule {
}