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:
parent
52e098730f
commit
efb440eb2f
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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 shouldn’t 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};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue