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:
parent
3a823abcc5
commit
e05a6f3bb3
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()', () => {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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([
|
||||||
|
|
Loading…
Reference in New Issue