diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json index 7bd7d5bd9f..6fccdecb4b 100644 --- a/goldens/size-tracking/integration-payloads.json +++ b/goldens/size-tracking/integration-payloads.json @@ -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 } } }, diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 9d2c8126df..a6b7d52d54 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -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; 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({ 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; 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) .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}; } } diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts index bd76ed5432..9336085b15 100644 --- a/packages/router/test/integration.spec.ts +++ b/packages/router/test/integration.spec.ts @@ -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: ` - - - - ` + + + + ` }) 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: ` - Link - - Link - - ` + Link + + Link + + ` }) 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 = 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 { + 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> { + myresolve: Observable = of(2); + resolve(): Observable { + return this.myresolve; + } + } + + @NgModule( + {imports: [RouterModule.forChild([{path: '', component: BlankCmp}])]}, + ) + class LoadedModule { + } + + let fixture: ComponentFixture; + + 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 { - allow: boolean = true; - canDeactivate(): boolean { - return this.allow; - } - } - - @Component({selector: 'parent', template: ''}) - 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; - - 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: ` -

{{rla.isActive}}

- - - ` +

{{rla.isActive}}

+ + + ` }) class ComponentWithRouterLink { } @@ -5664,8 +5928,8 @@ describe('Integration', () => { selector: 'link-cmp', template: `link - - ` + + ` }) class RelativeLinkCmp { @ViewChild(RouterLink) buttonLink!: RouterLink; @@ -6032,8 +6296,8 @@ class AbsoluteLinkCmp { selector: 'link-cmp', template: `link - -` + + ` }) class DummyLinkCmp { private exact: boolean; @@ -6195,9 +6459,9 @@ class OutletInNgIf { @Component({ selector: 'link-cmp', template: ` - ` + ` }) class DummyLinkWithParentCmp { private exact: boolean;