diff --git a/goldens/public-api/common/common.d.ts b/goldens/public-api/common/common.d.ts index 0b5ab5a10d..4d3601fd08 100644 --- a/goldens/public-api/common/common.d.ts +++ b/goldens/public-api/common/common.d.ts @@ -101,6 +101,7 @@ export declare class HashLocationStrategy extends LocationStrategy implements On back(): void; forward(): void; getBaseHref(): string; + historyGo(relativePosition?: number): void; ngOnDestroy(): void; onPopState(fn: LocationChangeListener): void; path(includeHash?: boolean): string; @@ -156,6 +157,7 @@ export declare class Location { forward(): void; getState(): unknown; go(path: string, query?: string, state?: any): void; + historyGo(relativePosition?: number): void; isCurrentPathEqualTo(path: string, query?: string): boolean; normalize(url: string): string; onUrlChange(fn: (url: string, state: unknown) => void): void; @@ -183,6 +185,7 @@ export declare abstract class LocationStrategy { abstract back(): void; abstract forward(): void; abstract getBaseHref(): string; + historyGo?(relativePosition: number): void; abstract onPopState(fn: LocationChangeListener): void; abstract path(includeHash?: boolean): string; abstract prepareExternalUrl(internal: string): string; @@ -330,6 +333,7 @@ export declare class PathLocationStrategy extends LocationStrategy implements On back(): void; forward(): void; getBaseHref(): string; + historyGo(relativePosition?: number): void; ngOnDestroy(): void; onPopState(fn: LocationChangeListener): void; path(includeHash?: boolean): string; @@ -357,6 +361,7 @@ export declare abstract class PlatformLocation { abstract forward(): void; abstract getBaseHrefFromDOM(): string; abstract getState(): unknown; + historyGo?(relativePosition: number): void; abstract onHashChange(fn: LocationChangeListener): VoidFunction; abstract onPopState(fn: LocationChangeListener): VoidFunction; abstract pushState(state: any, title: string, url: string): void; diff --git a/goldens/public-api/common/testing/testing.d.ts b/goldens/public-api/common/testing/testing.d.ts index b4b4b8fca8..5d4b193bb4 100644 --- a/goldens/public-api/common/testing/testing.d.ts +++ b/goldens/public-api/common/testing/testing.d.ts @@ -33,6 +33,7 @@ export declare class MockPlatformLocation implements PlatformLocation { forward(): void; getBaseHrefFromDOM(): string; getState(): unknown; + historyGo(relativePosition?: number): void; onHashChange(fn: LocationChangeListener): VoidFunction; onPopState(fn: LocationChangeListener): VoidFunction; pushState(state: any, title: string, newUrl: string): void; @@ -50,6 +51,7 @@ export declare class SpyLocation implements Location { forward(): void; getState(): unknown; go(path: string, query?: string, state?: any): void; + historyGo(relativePosition?: number): void; isCurrentPathEqualTo(path: string, query?: string): boolean; normalize(url: string): string; onUrlChange(fn: (url: string, state: unknown) => void): void; diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json index 917d0775ba..7346cbb362 100644 --- a/goldens/size-tracking/integration-payloads.json +++ b/goldens/size-tracking/integration-payloads.json @@ -49,7 +49,7 @@ "master": { "uncompressed": { "runtime-es2015": 2289, - "main-es2015": 216267, + "main-es2015": 216935, "polyfills-es2015": 36723, "5-es2015": 781 } diff --git a/packages/common/src/location/hash_location_strategy.ts b/packages/common/src/location/hash_location_strategy.ts index b46ff1ccfa..91a2368415 100644 --- a/packages/common/src/location/hash_location_strategy.ts +++ b/packages/common/src/location/hash_location_strategy.ts @@ -98,4 +98,8 @@ export class HashLocationStrategy extends LocationStrategy implements OnDestroy back(): void { this._platformLocation.back(); } + + historyGo(relativePosition: number = 0): void { + this._platformLocation.historyGo?.(relativePosition); + } } diff --git a/packages/common/src/location/location.ts b/packages/common/src/location/location.ts index 55af1e738d..f531104c3c 100644 --- a/packages/common/src/location/location.ts +++ b/packages/common/src/location/location.ts @@ -188,6 +188,22 @@ export class Location { this._platformStrategy.back(); } + /** + * Navigate to a specific page from session history, identified by its relative position to the + * current page. + * + * @param relativePosition Position of the target page in the history relative to the current + * page. + * A negative value moves backwards, a positive value moves forwards, e.g. `location.historyGo(2)` + * moves forward two pages and `location.historyGo(-2)` moves back two pages. When we try to go + * beyond what's stored in the history session, we stay in the current page. Same behaviour occurs + * when `relativePosition` equals 0. + * @see https://developer.mozilla.org/en-US/docs/Web/API/History_API#Moving_to_a_specific_point_in_history + */ + historyGo(relativePosition: number = 0): void { + this._platformStrategy.historyGo?.(relativePosition); + } + /** * Registers a URL change listener. Use to catch updates performed by the Angular * framework that are not detectible through "popstate" or "hashchange" events. diff --git a/packages/common/src/location/location_strategy.ts b/packages/common/src/location/location_strategy.ts index 731f5c6091..8deebd06a8 100644 --- a/packages/common/src/location/location_strategy.ts +++ b/packages/common/src/location/location_strategy.ts @@ -36,6 +36,9 @@ export abstract class LocationStrategy { abstract replaceState(state: any, title: string, url: string, queryParams: string): void; abstract forward(): void; abstract back(): void; + historyGo?(relativePosition: number): void { + throw new Error('Not implemented'); + } abstract onPopState(fn: LocationChangeListener): void; abstract getBaseHref(): string; } @@ -169,4 +172,8 @@ export class PathLocationStrategy extends LocationStrategy implements OnDestroy back(): void { this._platformLocation.back(); } + + historyGo(relativePosition: number = 0): void { + this._platformLocation.historyGo?.(relativePosition); + } } diff --git a/packages/common/src/location/platform_location.ts b/packages/common/src/location/platform_location.ts index 42371b9e5e..4b85a61aea 100644 --- a/packages/common/src/location/platform_location.ts +++ b/packages/common/src/location/platform_location.ts @@ -64,6 +64,10 @@ export abstract class PlatformLocation { abstract forward(): void; abstract back(): void; + + historyGo?(relativePosition: number): void { + throw new Error('Not implemented'); + } } export function useBrowserPlatformLocation() { @@ -189,6 +193,10 @@ export class BrowserPlatformLocation extends PlatformLocation { this._history.back(); } + historyGo(relativePosition: number = 0): void { + this._history.go(relativePosition); + } + getState(): unknown { return this._history.state; } diff --git a/packages/common/test/location/location_spec.ts b/packages/common/test/location/location_spec.ts index d31f2aba84..aad99be5de 100644 --- a/packages/common/test/location/location_spec.ts +++ b/packages/common/test/location/location_spec.ts @@ -81,6 +81,54 @@ describe('Location Class', () => { expect(location.getState()).toEqual({url: 'test1'}); }); + + it('should work after using forward button', () => { + expect(location.getState()).toBe(null); + + location.go('/test1', '', {url: 'test1'}); + location.go('/test2', '', {url: 'test2'}); + expect(location.getState()).toEqual({url: 'test2'}); + + location.back(); + expect(location.getState()).toEqual({url: 'test1'}); + + location.forward(); + expect(location.getState()).toEqual({url: 'test2'}); + }); + + it('should work after using location.historyGo()', () => { + expect(location.getState()).toBe(null); + + location.go('/test1', '', {url: 'test1'}); + location.go('/test2', '', {url: 'test2'}); + location.go('/test3', '', {url: 'test3'}); + expect(location.getState()).toEqual({url: 'test3'}); + + location.historyGo(-2); + expect(location.getState()).toEqual({url: 'test1'}); + + location.historyGo(2); + expect(location.getState()).toEqual({url: 'test3'}); + + location.go('/test3', '', {url: 'test4'}); + location.historyGo(0); + expect(location.getState()).toEqual({url: 'test4'}); + + location.historyGo(); + expect(location.getState()).toEqual({url: 'test4'}); + + // we are testing the behaviour of the `historyGo` method at the moment when the value of + // the relativePosition goes out of bounds. + // The result should be that the locationState does not change. + location.historyGo(100); + expect(location.getState()).toEqual({url: 'test4'}); + + location.historyGo(-100); + expect(location.getState()).toEqual({url: 'test4'}); + + location.back(); + expect(location.getState()).toEqual({url: 'test3'}); + }); }); describe('location.onUrlChange()', () => { diff --git a/packages/common/testing/src/location_mock.ts b/packages/common/testing/src/location_mock.ts index e7f5595ec9..41f722ed66 100644 --- a/packages/common/testing/src/location_mock.ts +++ b/packages/common/testing/src/location_mock.ts @@ -123,6 +123,16 @@ export class SpyLocation implements Location { this._subject.emit({'url': this.path(), 'state': this.getState(), 'pop': true}); } } + + 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); diff --git a/packages/common/testing/src/mock_platform_location.ts b/packages/common/testing/src/mock_platform_location.ts index e4f3f33607..7a2ed4dbba 100644 --- a/packages/common/testing/src/mock_platform_location.ts +++ b/packages/common/testing/src/mock_platform_location.ts @@ -105,6 +105,7 @@ export const MOCK_PLATFORM_LOCATION_CONFIG = export class MockPlatformLocation implements PlatformLocation { private baseHref: string = ''; private hashUpdate = new Subject(); + private urlChangeIndex: number = 0; private urlChanges: { hostname: string, protocol: string, @@ -127,25 +128,25 @@ export class MockPlatformLocation implements PlatformLocation { } get hostname() { - return this.urlChanges[0].hostname; + return this.urlChanges[this.urlChangeIndex].hostname; } get protocol() { - return this.urlChanges[0].protocol; + return this.urlChanges[this.urlChangeIndex].protocol; } get port() { - return this.urlChanges[0].port; + return this.urlChanges[this.urlChangeIndex].port; } get pathname() { - return this.urlChanges[0].pathname; + return this.urlChanges[this.urlChangeIndex].pathname; } get search() { - return this.urlChanges[0].search; + return this.urlChanges[this.urlChangeIndex].search; } get hash() { - return this.urlChanges[0].hash; + return this.urlChanges[this.urlChangeIndex].hash; } get state() { - return this.urlChanges[0].state; + return this.urlChanges[this.urlChangeIndex].state; } @@ -183,34 +184,59 @@ export class MockPlatformLocation implements PlatformLocation { replaceState(state: any, title: string, newUrl: string): void { const {pathname, search, state: parsedState, hash} = this.parseChanges(state, newUrl); - this.urlChanges[0] = {...this.urlChanges[0], pathname, search, hash, state: parsedState}; + this.urlChanges[this.urlChangeIndex] = + {...this.urlChanges[this.urlChangeIndex], pathname, search, hash, state: parsedState}; } pushState(state: any, title: string, newUrl: string): void { const {pathname, search, state: parsedState, hash} = this.parseChanges(state, newUrl); - this.urlChanges.unshift({...this.urlChanges[0], pathname, search, hash, state: parsedState}); + if (this.urlChangeIndex > 0) { + this.urlChanges.splice(this.urlChangeIndex + 1); + } + this.urlChanges.push( + {...this.urlChanges[this.urlChangeIndex], pathname, search, hash, state: parsedState}); + this.urlChangeIndex = this.urlChanges.length - 1; } forward(): void { - throw new Error('Not implemented'); + const oldUrl = this.url; + const oldHash = this.hash; + if (this.urlChangeIndex < this.urlChanges.length) { + this.urlChangeIndex++; + } + this.scheduleHashUpdate(oldHash, oldUrl); } back(): void { const oldUrl = this.url; const oldHash = this.hash; - this.urlChanges.shift(); - const newHash = this.hash; - - if (oldHash !== newHash) { - scheduleMicroTask( - () => this.hashUpdate.next( - {type: 'hashchange', state: null, oldUrl, newUrl: this.url} as LocationChangeEvent)); + if (this.urlChangeIndex > 0) { + this.urlChangeIndex--; } + this.scheduleHashUpdate(oldHash, oldUrl); + } + + historyGo(relativePosition: number = 0): void { + const oldUrl = this.url; + const oldHash = this.hash; + const nextPageIndex = this.urlChangeIndex + relativePosition; + if (nextPageIndex >= 0 && nextPageIndex < this.urlChanges.length) { + this.urlChangeIndex = nextPageIndex; + } + this.scheduleHashUpdate(oldHash, oldUrl); } getState(): unknown { return this.state; } + + private scheduleHashUpdate(oldHash: string, oldUrl: string) { + if (oldHash !== this.hash) { + scheduleMicroTask( + () => this.hashUpdate.next( + {type: 'hashchange', state: null, oldUrl, newUrl: this.url} as LocationChangeEvent)); + } + } } export function scheduleMicroTask(cb: () => any) { diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts index 818ab08e91..ca5b677f54 100644 --- a/packages/router/test/integration.spec.ts +++ b/packages/router/test/integration.spec.ts @@ -187,6 +187,47 @@ describe('Integration', () => { expect(navigation.extras.state).toEqual(state); }))); + it('should navigate correctly when using `Location#historyGo', + fakeAsync(inject([Router, Location], (router: Router, location: SpyLocation) => { + router.resetConfig([ + {path: 'first', component: SimpleCmp}, + {path: 'second', component: SimpleCmp}, + + ]); + + createRoot(router, RootCmp); + + router.navigateByUrl('/first'); + tick(); + router.navigateByUrl('/second'); + tick(); + expect(router.url).toEqual('/second'); + + location.historyGo(-1); + tick(); + expect(router.url).toEqual('/first'); + + location.historyGo(1); + tick(); + expect(router.url).toEqual('/second'); + + location.historyGo(-100); + tick(); + expect(router.url).toEqual('/second'); + + location.historyGo(100); + tick(); + expect(router.url).toEqual('/second'); + + location.historyGo(0); + tick(); + expect(router.url).toEqual('/second'); + + location.historyGo(); + tick(); + expect(router.url).toEqual('/second'); + }))); + it('should not error if state is not {[key: string]: any}', fakeAsync(inject([Router, Location], (router: Router, location: SpyLocation) => { router.resetConfig([