refactor(router): compute correct history restoration when navigation is cancelled (#38884)

We can’t determine whether the user actually meant the `back` or
the `forward` using the popstate event (triggered by a browser
back/forward)
so we instead need to store information on the state and compute the
distance the user is traveling withing the browser history.
So by using the `History#go` method,
we can bring the user back to the page where he is supposed to be after
performing the action.

implementation for #13586

PR Close #38884
This commit is contained in:
Ahmed Ayed 2020-09-21 18:14:52 -04:00 committed by Alex Rickabaugh
parent 52e098730f
commit efb440eb2f
3 changed files with 556 additions and 212 deletions

View File

@ -38,10 +38,10 @@
"cli-hello-world-lazy": {
"master": {
"uncompressed": {
"runtime-es2015": 2850,
"main-es2015": 295741,
"polyfills-es2015": 36975,
"src_app_lazy_lazy_module_ts-es2015": 825
"runtime-es2015": 2854,
"main-es2015": 296436,
"polyfills-es2015": 37064,
"src_app_lazy_lazy_module_ts-es2015": 822
}
}
},

View File

@ -233,7 +233,12 @@ function defaultMalformedUriErrorHandler(
}
export type RestoredState = {
[k: string]: any; navigationId: number;
[k: string]: any,
// TODO(#27607): Remove `navigationId` and `ɵrouterPageId` and move to `ng` or `ɵ` namespace.
navigationId: number,
// The `ɵ` prefix is there to reduce the chance of colliding with any existing user properties on
// the history state.
ɵrouterPageId?: number,
};
/**
@ -303,6 +308,7 @@ export interface Navigation {
export type NavigationTransition = {
id: number,
targetPageId: number,
currentUrlTree: UrlTree,
currentRawUrl: UrlTree,
extractedUrl: UrlTree,
@ -409,6 +415,12 @@ export class Router {
*/
private lastLocationChangeInfo: LocationChangeInfo|null = null;
private navigationId: number = 0;
/**
* The id of the currently active page in the router.
* Updated to the transition's target id on a successful navigation.
*/
private currentPageId: number = 0;
private configLoader: RouterConfigLoader;
private ngModule: NgModuleRef<any>;
private console: Console;
@ -509,6 +521,25 @@ export class Router {
*/
relativeLinkResolution: 'legacy'|'corrected' = 'corrected';
/**
* Configures how the Router attempts to restore state when a navigation is cancelled.
*
* 'replace' - Always uses `location.replaceState` to set the browser state to the state of the
* router before the navigation started.
*
* 'computed' - Will always return to the same state that corresponds to the actual Angular route
* when the navigation gets cancelled right after triggering a `popstate` event.
*
* The default value is `replace`
*
* @internal
*/
// TODO(atscott): Determine how/when/if to make this public API
// This shouldnt be an option at all but may need to be in order to allow migration without a
// breaking change. We need to determine if it should be made into public api (or if we forgo
// the option and release as a breaking change bug fix in a major version).
canceledNavigationResolution: 'replace'|'computed' = 'replace';
/**
* Creates the router service.
*/
@ -535,6 +566,7 @@ export class Router {
this.transitions = new BehaviorSubject<NavigationTransition>({
id: 0,
targetPageId: 0,
currentUrlTree: this.currentUrlTree,
currentRawUrl: this.currentUrlTree,
extractedUrl: this.urlHandlingStrategy.extract(this.currentUrlTree),
@ -634,9 +666,7 @@ export class Router {
tap(t => {
if (this.urlUpdateStrategy === 'eager') {
if (!t.extras.skipLocationChange) {
this.setBrowserUrl(
t.urlAfterRedirects, !!t.extras.replaceUrl, t.id,
t.extras.state);
this.setBrowserUrl(t.urlAfterRedirects, t);
}
this.browserUrlTree = t.urlAfterRedirects;
}
@ -731,11 +761,7 @@ export class Router {
filter(t => {
if (!t.guardsResult) {
this.resetUrlToCurrentUrlTree();
const navCancel =
new NavigationCancel(t.id, this.serializeUrl(t.extractedUrl), '');
eventsSubject.next(navCancel);
t.resolve(false);
this.cancelNavigationTransition(t, '');
return false;
}
return true;
@ -760,11 +786,9 @@ export class Router {
next: () => dataResolved = true,
complete: () => {
if (!dataResolved) {
const navCancel = new NavigationCancel(
t.id, this.serializeUrl(t.extractedUrl),
this.cancelNavigationTransition(
t,
`At least one route resolver didn't emit any value.`);
eventsSubject.next(navCancel);
t.resolve(false);
}
}
}),
@ -818,8 +842,7 @@ export class Router {
if (this.urlUpdateStrategy === 'deferred') {
if (!t.extras.skipLocationChange) {
this.setBrowserUrl(
this.rawUrlTree, !!t.extras.replaceUrl, t.id, t.extras.state);
this.setBrowserUrl(this.rawUrlTree, t);
}
this.browserUrlTree = t.urlAfterRedirects;
}
@ -853,13 +876,10 @@ export class Router {
// sync code which looks for a value here in order to determine whether or
// not to handle a given popstate event or to leave it to the Angular
// router.
this.resetUrlToCurrentUrlTree();
const navCancel = new NavigationCancel(
t.id, this.serializeUrl(t.extractedUrl),
this.cancelNavigationTransition(
t,
`Navigation ID ${t.id} is not equal to the current navigation id ${
this.navigationId}`);
eventsSubject.next(navCancel);
t.resolve(false);
}
// currentNavigation should always be reset to null here. If navigation was
// successful, lastSuccessfulTransition will have already been set. Therefore
@ -982,6 +1002,7 @@ export class Router {
if (state) {
const stateCopy = {...state} as Partial<RestoredState>;
delete stateCopy.navigationId;
delete stateCopy.ɵrouterPageId;
if (Object.keys(stateCopy).length !== 0) {
extras.state = stateCopy;
}
@ -1192,7 +1213,15 @@ export class Router {
const urlTree = isUrlTree(url) ? url : this.parseUrl(url);
const mergedTree = this.urlHandlingStrategy.merge(urlTree, this.rawUrlTree);
return this.scheduleNavigation(mergedTree, 'imperative', null, extras);
let restoredState: RestoredState|null = null;
if (this.canceledNavigationResolution === 'computed') {
const isInitialPage = this.currentPageId === 0;
if (isInitialPage || extras.skipLocationChange || extras.replaceUrl) {
restoredState = this.location.getState() as RestoredState | null;
}
}
return this.scheduleNavigation(mergedTree, 'imperative', restoredState, extras);
}
/**
@ -1297,6 +1326,7 @@ export class Router {
t => {
this.navigated = true;
this.lastSuccessfulId = t.id;
this.currentPageId = t.targetPageId;
(this.events as Subject<Event>)
.next(new NavigationEnd(
t.id, this.serializeUrl(t.extractedUrl), this.serializeUrl(this.currentUrlTree)));
@ -1356,8 +1386,23 @@ export class Router {
}
const id = ++this.navigationId;
let targetPageId: number;
if (this.canceledNavigationResolution === 'computed') {
// If the `ɵrouterPageId` exist in the state then `targetpageId` should have the value of
// `ɵrouterPageId`
if (restoredState && restoredState.ɵrouterPageId) {
targetPageId = restoredState.ɵrouterPageId;
} else {
targetPageId = this.currentPageId + 1;
}
} else {
// This is unused when `canceledNavigationResolution` is not computed.
targetPageId = 0;
}
this.setTransition({
id,
targetPageId,
source,
restoredState,
currentUrlTree: this.currentUrlTree,
@ -1378,15 +1423,13 @@ export class Router {
});
}
private setBrowserUrl(
url: UrlTree, replaceUrl: boolean, id: number, state?: {[key: string]: any}) {
private setBrowserUrl(url: UrlTree, t: NavigationTransition) {
const path = this.urlSerializer.serialize(url);
state = state || {};
if (this.location.isCurrentPathEqualTo(path) || replaceUrl) {
// TODO(jasonaden): Remove first `navigationId` and rely on `ng` namespace.
this.location.replaceState(path, '', {...state, navigationId: id});
const state = {...t.extras.state, ...this.generateNgRouterState(t.id, t.targetPageId)};
if (this.location.isCurrentPathEqualTo(path) || !!t.extras.replaceUrl) {
this.location.replaceState(path, '', state);
} else {
this.location.go(path, '', {...state, navigationId: id});
this.location.go(path, '', state);
}
}
@ -1399,7 +1442,44 @@ export class Router {
private resetUrlToCurrentUrlTree(): void {
this.location.replaceState(
this.urlSerializer.serialize(this.rawUrlTree), '', {navigationId: this.lastSuccessfulId});
this.urlSerializer.serialize(this.rawUrlTree), '',
this.generateNgRouterState(this.lastSuccessfulId, this.currentPageId));
}
/**
* Responsible for handling the cancellation of a navigation:
* - performs the necessary rollback action to restore the browser URL to the
* state before the transition
* - triggers the `NavigationCancel` event
* - resolves the transition promise with `false`
*/
private cancelNavigationTransition(t: NavigationTransition, reason: string) {
if (this.canceledNavigationResolution === 'computed') {
// The navigator change the location before triggered the browser event,
// so we need to go back to the current url if the navigation is canceled.
// Also, when navigation gets cancelled while using url update strategy eager, then we need to
// go back. Because, when `urlUpdateSrategy` is `eager`; `setBrowserUrl` method is called
// before any verification.
if (t.source === 'popstate' || this.urlUpdateStrategy === 'eager') {
const targetPagePosition = this.currentPageId - t.targetPageId;
this.location.historyGo(targetPagePosition);
} else {
// If update is not 'eager' and the transition navigation source isn't 'popstate', then the
// navigation was cancelled before any browser url change so nothing needs to be restored.
}
} else {
this.resetUrlToCurrentUrlTree();
}
const navCancel = new NavigationCancel(t.id, this.serializeUrl(t.extractedUrl), reason);
this.triggerEvent(navCancel);
t.resolve(false);
}
private generateNgRouterState(navigationId: number, routerPageId?: number) {
if (this.canceledNavigationResolution === 'computed') {
return {navigationId, ɵrouterPageId: routerPageId};
}
return {navigationId};
}
}

View File

@ -6,14 +6,14 @@
* found in the LICENSE file at https://angular.io/license
*/
import {CommonModule, Location} from '@angular/common';
import {CommonModule, Location, LocationStrategy, PlatformLocation} from '@angular/common';
import {SpyLocation} from '@angular/common/testing';
import {ChangeDetectionStrategy, Component, Injectable, NgModule, NgModuleFactoryLoader, NgModuleRef, NgZone, OnDestroy, ViewChild, ɵConsole as Console, ɵNoopNgZone as NoopNgZone} from '@angular/core';
import {ChangeDetectionStrategy, Component, EventEmitter, Injectable, NgModule, NgModuleFactoryLoader, NgModuleRef, NgZone, OnDestroy, ViewChild, ɵConsole as Console, ɵNoopNgZone as NoopNgZone} from '@angular/core';
import {ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing';
import {By} from '@angular/platform-browser/src/dom/debug/by';
import {expect} from '@angular/platform-browser/testing/src/matchers';
import {ActivatedRoute, ActivatedRouteSnapshot, ActivationEnd, ActivationStart, CanActivate, CanDeactivate, ChildActivationEnd, ChildActivationStart, DefaultUrlSerializer, DetachedRouteHandle, Event, GuardsCheckEnd, GuardsCheckStart, Navigation, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ParamMap, Params, PreloadAllModules, PreloadingStrategy, PRIMARY_OUTLET, Resolve, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router, RouteReuseStrategy, RouterEvent, RouterLink, RouterLinkWithHref, RouterModule, RouterPreloader, RouterStateSnapshot, RoutesRecognized, RunGuardsAndResolvers, UrlHandlingStrategy, UrlSegmentGroup, UrlSerializer, UrlTree} from '@angular/router';
import {EMPTY, Observable, Observer, of, Subscription} from 'rxjs';
import {EMPTY, Observable, Observer, of, Subscription, SubscriptionLike} from 'rxjs';
import {delay, filter, first, map, mapTo, tap} from 'rxjs/operators';
import {forEach} from '../src/utils/collection';
@ -446,10 +446,10 @@ describe('Integration', () => {
@Component({
template: `
<router-outlet (deactivate)="logDeactivate('primary')"></router-outlet>
<router-outlet name="first" (deactivate)="logDeactivate('first')"></router-outlet>
<router-outlet name="second" (deactivate)="logDeactivate('second')"></router-outlet>
`
<router-outlet (deactivate)="logDeactivate('primary')"></router-outlet>
<router-outlet name="first" (deactivate)="logDeactivate('first')"></router-outlet>
<router-outlet name="second" (deactivate)="logDeactivate('second')"></router-outlet>
`
})
class NamedOutletHost {
logDeactivate(route: string) {
@ -1000,7 +1000,7 @@ describe('Integration', () => {
expect(fixture.nativeElement).toHaveText('team 33 [ , right: ]');
})));
it('should should set `state` with urlUpdateStrategy="eagar"',
it('should set `state` with urlUpdateStrategy="eagar"',
fakeAsync(inject([Router, Location], (router: Router, location: SpyLocation) => {
router.urlUpdateStrategy = 'eager';
router.resetConfig([
@ -2289,11 +2289,11 @@ describe('Integration', () => {
@Component({
selector: 'someCmp',
template: `<router-outlet></router-outlet>
<a [routerLink]="null">Link</a>
<button [routerLink]="null">Button</button>
<a [routerLink]="undefined">Link</a>
<button [routerLink]="undefined">Button</button>
`
<a [routerLink]="null">Link</a>
<button [routerLink]="null">Button</button>
<a [routerLink]="undefined">Link</a>
<button [routerLink]="undefined">Button</button>
`
})
class CmpWithLink {
}
@ -2661,7 +2661,426 @@ describe('Integration', () => {
expect(location.path()).toEqual('/initial');
})));
});
describe('`restoredState#ɵrouterPageId`', () => {
// TODO: Remove RouterSpyLocation after #38884 is submitted.
class RouterSpyLocation implements Location {
urlChanges: string[] = [];
private _history: LocationState[] = [new LocationState('', '', null)];
private _historyIndex: number = 0;
/** @internal */
_subject: EventEmitter<any> = new EventEmitter();
/** @internal */
_baseHref: string = '';
/** @internal */
_platformStrategy: LocationStrategy = null!;
/** @internal */
_platformLocation: PlatformLocation = null!;
/** @internal */
_urlChangeListeners: ((url: string, state: unknown) => void)[] = [];
/** @internal */
_urlChangeSubscription?: SubscriptionLike;
setInitialPath(url: string) {
this._history[this._historyIndex].path = url;
}
setBaseHref(url: string) {
this._baseHref = url;
}
path(): string {
return this._history[this._historyIndex].path;
}
getState(): unknown {
return this._history[this._historyIndex].state;
}
isCurrentPathEqualTo(path: string, query: string = ''): boolean {
const givenPath = path.endsWith('/') ? path.substring(0, path.length - 1) : path;
const currPath = this.path().endsWith('/') ?
this.path().substring(0, this.path().length - 1) :
this.path();
return currPath == givenPath + (query.length > 0 ? ('?' + query) : '');
}
simulateUrlPop(pathname: string) {
this._subject.emit({'url': pathname, 'pop': true, 'type': 'popstate'});
}
simulateHashChange(pathname: string) {
// Because we don't prevent the native event, the browser will independently update the path
this.setInitialPath(pathname);
this.urlChanges.push('hash: ' + pathname);
this._subject.emit({'url': pathname, 'pop': true, 'type': 'hashchange'});
}
prepareExternalUrl(url: string): string {
if (url.length > 0 && !url.startsWith('/')) {
url = '/' + url;
}
return this._baseHref + url;
}
go(path: string, query: string = '', state: any = null) {
path = this.prepareExternalUrl(path);
if (this._historyIndex > 0) {
this._history.splice(this._historyIndex + 1);
}
this._history.push(new LocationState(path, query, state));
this._historyIndex = this._history.length - 1;
const locationState = this._history[this._historyIndex - 1];
if (locationState.path == path && locationState.query == query) {
return;
}
const url = path + (query.length > 0 ? ('?' + query) : '');
this.urlChanges.push(url);
this._subject.emit({'url': url, 'pop': false});
}
replaceState(path: string, query: string = '', state: any = null) {
path = this.prepareExternalUrl(path);
const history = this._history[this._historyIndex];
if (history.path == path && history.query == query) {
return;
}
history.path = path;
history.query = query;
history.state = state;
const url = path + (query.length > 0 ? ('?' + query) : '');
this.urlChanges.push('replace: ' + url);
}
forward() {
if (this._historyIndex < (this._history.length - 1)) {
this._historyIndex++;
this._subject.emit(
{'url': this.path(), 'state': this.getState(), 'pop': true, 'type': 'popstate'});
}
}
back() {
if (this._historyIndex > 0) {
this._historyIndex--;
this._subject.emit(
{'url': this.path(), 'state': this.getState(), 'pop': true, 'type': 'popstate'});
}
}
historyGo(relativePosition: number = 0): void {
const nextPageIndex = this._historyIndex + relativePosition;
if (nextPageIndex >= 0 && nextPageIndex < this._history.length) {
this._historyIndex = nextPageIndex;
this._subject.emit(
{'url': this.path(), 'state': this.getState(), 'pop': true, 'type': 'popstate'});
}
}
onUrlChange(fn: (url: string, state: unknown) => void) {
this._urlChangeListeners.push(fn);
if (!this._urlChangeSubscription) {
this._urlChangeSubscription = this.subscribe(v => {
this._notifyUrlChangeListeners(v.url, v.state);
});
}
}
/** @internal */
_notifyUrlChangeListeners(url: string = '', state: unknown) {
this._urlChangeListeners.forEach(fn => fn(url, state));
}
subscribe(
onNext: (value: any) => void, onThrow?: ((error: any) => void)|null,
onReturn?: (() => void)|null): SubscriptionLike {
return this._subject.subscribe({next: onNext, error: onThrow, complete: onReturn});
}
normalize(url: string): string {
return null!;
}
}
class LocationState {
constructor(public path: string, public query: string, public state: any) {}
}
@Injectable({providedIn: 'root'})
class MyCanDeactivateGuard implements CanDeactivate<any> {
allow: boolean = true;
canDeactivate(): boolean {
return this.allow;
}
}
@Injectable({providedIn: 'root'})
class MyCanActivateGuard implements CanActivate {
allow: boolean = true;
canActivate(): boolean {
return this.allow;
}
}
@Injectable({providedIn: 'root'})
class MyResolve implements Resolve<Observable<any>> {
myresolve: Observable<any> = of(2);
resolve(): Observable<any> {
return this.myresolve;
}
}
@NgModule(
{imports: [RouterModule.forChild([{path: '', component: BlankCmp}])]},
)
class LoadedModule {
}
let fixture: ComponentFixture<unknown>;
beforeEach(fakeAsync(() => {
TestBed.configureTestingModule({
imports: [TestModule],
providers: [
{provide: 'alwaysFalse', useValue: (a: any) => false},
{provide: Location, useClass: RouterSpyLocation}
]
});
const router = TestBed.inject(Router);
(router as any).canceledNavigationResolution = 'computed';
const location = TestBed.inject(Location);
fixture = createRoot(router, SimpleCmp);
router.resetConfig([
{
path: 'first',
component: SimpleCmp,
canDeactivate: [MyCanDeactivateGuard],
canActivate: [MyCanActivateGuard],
resolve: [MyResolve]
},
{
path: 'second',
component: SimpleCmp,
canDeactivate: [MyCanDeactivateGuard],
canActivate: [MyCanActivateGuard],
resolve: [MyResolve]
},
{
path: 'third',
component: SimpleCmp,
canDeactivate: [MyCanDeactivateGuard],
canActivate: [MyCanActivateGuard],
resolve: [MyResolve]
},
{path: 'loaded', loadChildren: () => of(LoadedModule), canLoad: ['alwaysFalse']}
]);
router.navigateByUrl('/first');
advance(fixture);
router.navigateByUrl('/second');
advance(fixture);
router.navigateByUrl('/third');
advance(fixture);
location.back();
advance(fixture);
}));
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: 3}));
TestBed.inject(MyCanActivateGuard).allow = false;
location.back();
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
TestBed.inject(MyCanActivateGuard).allow = true;
location.back();
advance(fixture);
expect(location.path()).toEqual('/first');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
TestBed.inject(MyCanActivateGuard).allow = false;
location.forward();
advance(fixture);
expect(location.path()).toEqual('/first');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
router.navigateByUrl('/second');
advance(fixture);
expect(location.path()).toEqual('/first');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
}));
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: 3}));
location.forward();
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
router.navigateByUrl('third');
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
TestBed.inject(MyCanDeactivateGuard).allow = true;
location.forward();
advance(fixture);
expect(location.path()).toEqual('/third');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 4}));
}));
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: 3}));
router.navigateByUrl('/first', {skipLocationChange: true});
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
router.navigateByUrl('/third');
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 4}));
location.back();
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
}));
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: 3}));
router.navigateByUrl('/first', {replaceUrl: true});
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
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: 3}));
}));
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: 3}));
TestBed.inject(MyResolve).myresolve = EMPTY;
location.back();
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
expect(location.path()).toEqual('/second');
TestBed.inject(MyResolve).myresolve = of(2);
location.back();
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
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: 2}));
expect(location.path()).toEqual('/first');
location.historyGo(2);
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
expect(location.path()).toEqual('/first');
TestBed.inject(MyResolve).myresolve = of(2);
location.historyGo(2);
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 4}));
expect(location.path()).toEqual('/third');
TestBed.inject(MyResolve).myresolve = EMPTY;
location.historyGo(-2);
advance(fixture);
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 4}));
expect(location.path()).toEqual('/third');
}));
it('should work when an error occured 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: 3}));
router.navigateByUrl('/invalid').catch(() => null);
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
location.back();
advance(fixture);
expect(location.path()).toEqual('/first');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2}));
}));
it('should work when urlUpdateStrategy="eagar"', 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: 3}));
router.urlUpdateStrategy = 'eager';
TestBed.inject(MyCanActivateGuard).allow = false;
router.navigateByUrl('/first');
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
location.back();
advance(fixture);
expect(location.path()).toEqual('/second');
expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3}));
}));
});
describe('guards', () => {
describe('CanActivate', () => {
describe('should not activate a route when CanActivate returns false', () => {
@ -2886,161 +3305,6 @@ describe('Integration', () => {
})));
});
describe('should not break the history', () => {
@Injectable({providedIn: 'root'})
class MyGuard implements CanDeactivate<any> {
allow: boolean = true;
canDeactivate(): boolean {
return this.allow;
}
}
@Component({selector: 'parent', template: '<router-outlet></router-outlet>'})
class Parent {
}
@Component({selector: 'home', template: 'home'})
class Home {
}
@Component({selector: 'child1', template: 'child1'})
class Child1 {
}
@Component({selector: 'child2', template: 'child2'})
class Child2 {
}
@Component({selector: 'child3', template: 'child3'})
class Child3 {
}
@Component({selector: 'child4', template: 'child4'})
class Child4 {
}
@Component({selector: 'child5', template: 'child5'})
class Child5 {
}
@NgModule({
declarations: [Parent, Home, Child1, Child2, Child3, Child4, Child5],
entryComponents: [Child1, Child2, Child3, Child4, Child5],
imports: [RouterModule]
})
class TestModule {
}
let fixture: ComponentFixture<unknown>;
beforeEach(fakeAsync(() => {
TestBed.configureTestingModule({imports: [TestModule]});
const router = TestBed.get(Router);
const location = TestBed.get(Location);
fixture = createRoot(router, Parent);
router.resetConfig([
{path: '', component: Home},
{path: 'first', component: Child1},
{path: 'second', component: Child2},
{path: 'third', component: Child3, canDeactivate: [MyGuard]},
{path: 'fourth', component: Child4},
{path: 'fifth', component: Child5},
]);
// Create a navigation history of pages 1-5, and go back to 3 so that there is both
// back and forward history.
router.navigateByUrl('/first');
advance(fixture);
router.navigateByUrl('/second');
advance(fixture);
router.navigateByUrl('/third');
advance(fixture);
router.navigateByUrl('/fourth');
advance(fixture);
router.navigateByUrl('/fifth');
advance(fixture);
location.back();
advance(fixture);
location.back();
advance(fixture);
}));
// TODO(https://github.com/angular/angular/issues/13586)
// A fix to this requires much more design
xit('when navigate back using Back button', fakeAsync(() => {
const location = TestBed.get(Location);
expect(location.path()).toEqual('/third');
TestBed.get(MyGuard).allow = false;
location.back();
advance(fixture);
expect(location.path()).toEqual('/third');
expect(fixture.nativeElement).toHaveText('child3');
TestBed.get(MyGuard).allow = true;
location.back();
advance(fixture);
expect(location.path()).toEqual('/second');
expect(fixture.nativeElement).toHaveText('child2');
}));
it('when navigate back imperatively', fakeAsync(() => {
const router = TestBed.get(Router);
const location = TestBed.get(Location);
expect(location.path()).toEqual('/third');
TestBed.get(MyGuard).allow = false;
router.navigateByUrl('/second');
advance(fixture);
expect(location.path()).toEqual('/third');
expect(fixture.nativeElement).toHaveText('child3');
TestBed.get(MyGuard).allow = true;
location.back();
advance(fixture);
expect(location.path()).toEqual('/second');
expect(fixture.nativeElement).toHaveText('child2');
}));
// TODO(https://github.com/angular/angular/issues/13586)
// A fix to this requires much more design
xit('when navigate back using Foward button', fakeAsync(() => {
const location = TestBed.get(Location);
expect(location.path()).toEqual('/third');
TestBed.get(MyGuard).allow = false;
location.forward();
advance(fixture);
expect(location.path()).toEqual('/third');
expect(fixture.nativeElement).toHaveText('child3');
TestBed.get(MyGuard).allow = true;
location.forward();
advance(fixture);
expect(location.path()).toEqual('/fourth');
expect(fixture.nativeElement).toHaveText('child4');
}));
it('when navigate forward imperatively', fakeAsync(() => {
const router = TestBed.get(Router);
const location = TestBed.get(Location);
expect(location.path()).toEqual('/third');
TestBed.get(MyGuard).allow = false;
router.navigateByUrl('/fourth');
advance(fixture);
expect(location.path()).toEqual('/third');
expect(fixture.nativeElement).toHaveText('child3');
TestBed.get(MyGuard).allow = true;
location.forward();
advance(fixture);
expect(location.path()).toEqual('/fourth');
expect(fixture.nativeElement).toHaveText('child4');
}));
});
describe('should redirect when guard returns UrlTree', () => {
beforeEach(() => TestBed.configureTestingModule({
providers: [
@ -4754,10 +5018,10 @@ describe('Integration', () => {
it('should expose an isActive property', fakeAsync(() => {
@Component({
template: `<a routerLink="/team" routerLinkActive #rla="routerLinkActive"></a>
<p>{{rla.isActive}}</p>
<span *ngIf="rla.isActive"></span>
<span [ngClass]="{'highlight': rla.isActive}"></span>
<router-outlet></router-outlet>`
<p>{{rla.isActive}}</p>
<span *ngIf="rla.isActive"></span>
<span [ngClass]="{'highlight': rla.isActive}"></span>
<router-outlet></router-outlet>`
})
class ComponentWithRouterLink {
}
@ -5664,8 +5928,8 @@ describe('Integration', () => {
selector: 'link-cmp',
template:
`<a [relativeTo]="route.parent" [routerLink]="[{outlets: {'secondary': null}}]">link</a>
<button [relativeTo]="route.parent" [routerLink]="[{outlets: {'secondary': null}}]">link</button>
`
<button [relativeTo]="route.parent" [routerLink]="[{outlets: {'secondary': null}}]">link</button>
`
})
class RelativeLinkCmp {
@ViewChild(RouterLink) buttonLink!: RouterLink;
@ -6032,8 +6296,8 @@ class AbsoluteLinkCmp {
selector: 'link-cmp',
template:
`<router-outlet></router-outlet><a routerLinkActive="active" [routerLinkActiveOptions]="{exact: exact}" [routerLink]="['./']">link</a>
<button routerLinkActive="active" [routerLinkActiveOptions]="{exact: exact}" [routerLink]="['./']">button</button>
`
<button routerLinkActive="active" [routerLinkActiveOptions]="{exact: exact}" [routerLink]="['./']">button</button>
`
})
class DummyLinkCmp {
private exact: boolean;
@ -6195,9 +6459,9 @@ class OutletInNgIf {
@Component({
selector: 'link-cmp',
template: `<router-outlet></router-outlet>
<div id="link-parent" routerLinkActive="active" [routerLinkActiveOptions]="{exact: exact}">
<div ngClass="{one: 'true'}"><a [routerLink]="['./']">link</a></div>
</div>`
<div id="link-parent" routerLinkActive="active" [routerLinkActiveOptions]="{exact: exact}">
<div ngClass="{one: 'true'}"><a [routerLink]="['./']">link</a></div>
</div>`
})
class DummyLinkWithParentCmp {
private exact: boolean;