feat(common): add `historyGo` method to `Location` service (#38890)

Add new method `historyGo`, that will let
the user navigate to a specific page from session history identified by its
relative position to the current page.

We add some tests to `location_spec.ts` to validate the behavior of the
`historyGo` and `forward` methods.

Add more tests for `location_spec` to test `location.historyGo(0)`, `location.historyGo()`,
`location.historyGo(100)` and `location.historyGo(-100)`. We also add new tests for
`Integration` spec to validate the navigation when we using
`location#historyGo`.

Update the `historyGo` function docs

Note that this was made an optional function in the abstract classes to
avoid a breaking change. Because our location classes use `implements PlatformLocation`
rather than `extends PlatformLocation`, simply adding a default
implementation was not sufficient to make this a non-breaking change.
While we could fix the classes internal to Angular, this would still have been
a breaking change for any external developers who may have followed our
implementations as an example.

PR Close #38890
This commit is contained in:
Ahmed Ayed 2020-09-17 14:10:21 -04:00 committed by atscott
parent 3a823abcc5
commit e05a6f3bb3
11 changed files with 185 additions and 18 deletions

View File

@ -101,6 +101,7 @@ export declare class HashLocationStrategy extends LocationStrategy implements On
back(): void; back(): void;
forward(): void; forward(): void;
getBaseHref(): string; getBaseHref(): string;
historyGo(relativePosition?: number): void;
ngOnDestroy(): void; ngOnDestroy(): void;
onPopState(fn: LocationChangeListener): void; onPopState(fn: LocationChangeListener): void;
path(includeHash?: boolean): string; path(includeHash?: boolean): string;
@ -156,6 +157,7 @@ export declare class Location {
forward(): void; forward(): void;
getState(): unknown; getState(): unknown;
go(path: string, query?: string, state?: any): void; go(path: string, query?: string, state?: any): void;
historyGo(relativePosition?: number): void;
isCurrentPathEqualTo(path: string, query?: string): boolean; isCurrentPathEqualTo(path: string, query?: string): boolean;
normalize(url: string): string; normalize(url: string): string;
onUrlChange(fn: (url: string, state: unknown) => void): void; onUrlChange(fn: (url: string, state: unknown) => void): void;
@ -183,6 +185,7 @@ export declare abstract class LocationStrategy {
abstract back(): void; abstract back(): void;
abstract forward(): void; abstract forward(): void;
abstract getBaseHref(): string; abstract getBaseHref(): string;
historyGo?(relativePosition: number): void;
abstract onPopState(fn: LocationChangeListener): void; abstract onPopState(fn: LocationChangeListener): void;
abstract path(includeHash?: boolean): string; abstract path(includeHash?: boolean): string;
abstract prepareExternalUrl(internal: string): string; abstract prepareExternalUrl(internal: string): string;
@ -330,6 +333,7 @@ export declare class PathLocationStrategy extends LocationStrategy implements On
back(): void; back(): void;
forward(): void; forward(): void;
getBaseHref(): string; getBaseHref(): string;
historyGo(relativePosition?: number): void;
ngOnDestroy(): void; ngOnDestroy(): void;
onPopState(fn: LocationChangeListener): void; onPopState(fn: LocationChangeListener): void;
path(includeHash?: boolean): string; path(includeHash?: boolean): string;
@ -357,6 +361,7 @@ export declare abstract class PlatformLocation {
abstract forward(): void; abstract forward(): void;
abstract getBaseHrefFromDOM(): string; abstract getBaseHrefFromDOM(): string;
abstract getState(): unknown; abstract getState(): unknown;
historyGo?(relativePosition: number): void;
abstract onHashChange(fn: LocationChangeListener): VoidFunction; abstract onHashChange(fn: LocationChangeListener): VoidFunction;
abstract onPopState(fn: LocationChangeListener): VoidFunction; abstract onPopState(fn: LocationChangeListener): VoidFunction;
abstract pushState(state: any, title: string, url: string): void; abstract pushState(state: any, title: string, url: string): void;

View File

@ -33,6 +33,7 @@ export declare class MockPlatformLocation implements PlatformLocation {
forward(): void; forward(): void;
getBaseHrefFromDOM(): string; getBaseHrefFromDOM(): string;
getState(): unknown; getState(): unknown;
historyGo(relativePosition?: number): void;
onHashChange(fn: LocationChangeListener): VoidFunction; onHashChange(fn: LocationChangeListener): VoidFunction;
onPopState(fn: LocationChangeListener): VoidFunction; onPopState(fn: LocationChangeListener): VoidFunction;
pushState(state: any, title: string, newUrl: string): void; pushState(state: any, title: string, newUrl: string): void;
@ -50,6 +51,7 @@ export declare class SpyLocation implements Location {
forward(): void; forward(): void;
getState(): unknown; getState(): unknown;
go(path: string, query?: string, state?: any): void; go(path: string, query?: string, state?: any): void;
historyGo(relativePosition?: number): void;
isCurrentPathEqualTo(path: string, query?: string): boolean; isCurrentPathEqualTo(path: string, query?: string): boolean;
normalize(url: string): string; normalize(url: string): string;
onUrlChange(fn: (url: string, state: unknown) => void): void; onUrlChange(fn: (url: string, state: unknown) => void): void;

View File

@ -49,7 +49,7 @@
"master": { "master": {
"uncompressed": { "uncompressed": {
"runtime-es2015": 2289, "runtime-es2015": 2289,
"main-es2015": 216267, "main-es2015": 216935,
"polyfills-es2015": 36723, "polyfills-es2015": 36723,
"5-es2015": 781 "5-es2015": 781
} }

View File

@ -98,4 +98,8 @@ export class HashLocationStrategy extends LocationStrategy implements OnDestroy
back(): void { back(): void {
this._platformLocation.back(); this._platformLocation.back();
} }
historyGo(relativePosition: number = 0): void {
this._platformLocation.historyGo?.(relativePosition);
}
} }

View File

@ -188,6 +188,22 @@ export class Location {
this._platformStrategy.back(); 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 * Registers a URL change listener. Use to catch updates performed by the Angular
* framework that are not detectible through "popstate" or "hashchange" events. * framework that are not detectible through "popstate" or "hashchange" events.

View File

@ -36,6 +36,9 @@ export abstract class LocationStrategy {
abstract replaceState(state: any, title: string, url: string, queryParams: string): void; abstract replaceState(state: any, title: string, url: string, queryParams: string): void;
abstract forward(): void; abstract forward(): void;
abstract back(): void; abstract back(): void;
historyGo?(relativePosition: number): void {
throw new Error('Not implemented');
}
abstract onPopState(fn: LocationChangeListener): void; abstract onPopState(fn: LocationChangeListener): void;
abstract getBaseHref(): string; abstract getBaseHref(): string;
} }
@ -169,4 +172,8 @@ export class PathLocationStrategy extends LocationStrategy implements OnDestroy
back(): void { back(): void {
this._platformLocation.back(); this._platformLocation.back();
} }
historyGo(relativePosition: number = 0): void {
this._platformLocation.historyGo?.(relativePosition);
}
} }

View File

@ -64,6 +64,10 @@ export abstract class PlatformLocation {
abstract forward(): void; abstract forward(): void;
abstract back(): void; abstract back(): void;
historyGo?(relativePosition: number): void {
throw new Error('Not implemented');
}
} }
export function useBrowserPlatformLocation() { export function useBrowserPlatformLocation() {
@ -189,6 +193,10 @@ export class BrowserPlatformLocation extends PlatformLocation {
this._history.back(); this._history.back();
} }
historyGo(relativePosition: number = 0): void {
this._history.go(relativePosition);
}
getState(): unknown { getState(): unknown {
return this._history.state; return this._history.state;
} }

View File

@ -81,6 +81,54 @@ describe('Location Class', () => {
expect(location.getState()).toEqual({url: 'test1'}); 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()', () => { describe('location.onUrlChange()', () => {

View File

@ -123,6 +123,16 @@ export class SpyLocation implements Location {
this._subject.emit({'url': this.path(), 'state': this.getState(), 'pop': true}); 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) { onUrlChange(fn: (url: string, state: unknown) => void) {
this._urlChangeListeners.push(fn); this._urlChangeListeners.push(fn);

View File

@ -105,6 +105,7 @@ export const MOCK_PLATFORM_LOCATION_CONFIG =
export class MockPlatformLocation implements PlatformLocation { export class MockPlatformLocation implements PlatformLocation {
private baseHref: string = ''; private baseHref: string = '';
private hashUpdate = new Subject<LocationChangeEvent>(); private hashUpdate = new Subject<LocationChangeEvent>();
private urlChangeIndex: number = 0;
private urlChanges: { private urlChanges: {
hostname: string, hostname: string,
protocol: string, protocol: string,
@ -127,25 +128,25 @@ export class MockPlatformLocation implements PlatformLocation {
} }
get hostname() { get hostname() {
return this.urlChanges[0].hostname; return this.urlChanges[this.urlChangeIndex].hostname;
} }
get protocol() { get protocol() {
return this.urlChanges[0].protocol; return this.urlChanges[this.urlChangeIndex].protocol;
} }
get port() { get port() {
return this.urlChanges[0].port; return this.urlChanges[this.urlChangeIndex].port;
} }
get pathname() { get pathname() {
return this.urlChanges[0].pathname; return this.urlChanges[this.urlChangeIndex].pathname;
} }
get search() { get search() {
return this.urlChanges[0].search; return this.urlChanges[this.urlChangeIndex].search;
} }
get hash() { get hash() {
return this.urlChanges[0].hash; return this.urlChanges[this.urlChangeIndex].hash;
} }
get state() { 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 { replaceState(state: any, title: string, newUrl: string): void {
const {pathname, search, state: parsedState, hash} = this.parseChanges(state, newUrl); 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 { pushState(state: any, title: string, newUrl: string): void {
const {pathname, search, state: parsedState, hash} = this.parseChanges(state, newUrl); 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 { 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 { back(): void {
const oldUrl = this.url; const oldUrl = this.url;
const oldHash = this.hash; const oldHash = this.hash;
this.urlChanges.shift(); if (this.urlChangeIndex > 0) {
const newHash = this.hash; this.urlChangeIndex--;
if (oldHash !== newHash) {
scheduleMicroTask(
() => this.hashUpdate.next(
{type: 'hashchange', state: null, oldUrl, newUrl: this.url} as LocationChangeEvent));
} }
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 { getState(): unknown {
return this.state; 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) { export function scheduleMicroTask(cb: () => any) {

View File

@ -187,6 +187,47 @@ describe('Integration', () => {
expect(navigation.extras.state).toEqual(state); 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}', it('should not error if state is not {[key: string]: any}',
fakeAsync(inject([Router, Location], (router: Router, location: SpyLocation) => { fakeAsync(inject([Router, Location], (router: Router, location: SpyLocation) => {
router.resetConfig([ router.resetConfig([