Skip to content

Commit

Permalink
feat(common): add historyGo method to Location service
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
aahmedayed authored and atscott committed Mar 25, 2021
1 parent b61c009 commit 9100b03
Show file tree
Hide file tree
Showing 11 changed files with 186 additions and 19 deletions.
5 changes: 5 additions & 0 deletions goldens/public-api/common/common.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions goldens/public-api/common/testing/testing.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions goldens/size-tracking/integration-payloads.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"master": {
"uncompressed": {
"runtime-es2015": 2285,
"main-es2015": 240352,
"main-es2015": 241063,
"polyfills-es2015": 36975,
"5-es2015": 753
}
Expand All @@ -49,7 +49,7 @@
"master": {
"uncompressed": {
"runtime-es2015": 2289,
"main-es2015": 216267,
"main-es2015": 216935,
"polyfills-es2015": 36723,
"5-es2015": 781
}
Expand All @@ -75,4 +75,4 @@
}
}
}
}
}
4 changes: 4 additions & 0 deletions packages/common/src/location/hash_location_strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
16 changes: 16 additions & 0 deletions packages/common/src/location/location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions packages/common/src/location/location_strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
}
8 changes: 8 additions & 0 deletions packages/common/src/location/platform_location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
}
Expand Down
48 changes: 48 additions & 0 deletions packages/common/test/location/location_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down
10 changes: 10 additions & 0 deletions packages/common/testing/src/location_mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
58 changes: 42 additions & 16 deletions packages/common/testing/src/mock_platform_location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export const MOCK_PLATFORM_LOCATION_CONFIG =
export class MockPlatformLocation implements PlatformLocation {
private baseHref: string = '';
private hashUpdate = new Subject<LocationChangeEvent>();
private urlChangeIndex: number = 0;
private urlChanges: {
hostname: string,
protocol: string,
Expand All @@ -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;
}


Expand Down Expand Up @@ -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 (this.urlChangeIndex > 0) {
this.urlChangeIndex--;
}
this.scheduleHashUpdate(oldHash, oldUrl);
}

if (oldHash !== newHash) {
scheduleMicroTask(
() => this.hashUpdate.next(
{type: 'hashchange', state: null, oldUrl, newUrl: this.url} as LocationChangeEvent));
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) {
Expand Down
41 changes: 41 additions & 0 deletions packages/router/test/integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down

0 comments on commit 9100b03

Please sign in to comment.