feat(common): provide replacement for AngularJS $location service (#30055)

This commit provides a replacement for `$location`. The new service is written in Angular, and can be consumed into existing applications by using the downgraded version
of the provider.

Prior to this addition, applications upgrading from AngularJS to Angular could get into a situation where AngularJS wanted to control the URL, and would often parse or se
rialize the URL in a different way than Angular. Additionally, AngularJS was alerted to URL changes only through the `$digest` cycle. This provided a buggy feedback loop
from Angular to AngularJS.

With this new `LocationUpgradeProvider`, the `$location` methods and events are provided in Angular, and use Angular APIs to make updates to the URL. Additionally, change
s to the URL made by other parts of the Angular framework (such as the Router) will be listened for and will cause events to fire in AngularJS, but will no longer attempt
 to update the URL (since it was already updated by the Angular framework).

This centralizes URL reads and writes to Angular and should help provide an easier path to upgrading AngularJS applications to Angular.

PR Close #30055
This commit is contained in:
Jason Aden
2019-04-23 07:16:08 -07:00
committed by Ben Lesh
parent f185ff3792
commit 4277600d5e
19 changed files with 1777 additions and 137 deletions

View File

@ -10,19 +10,13 @@ import {Location, LocationStrategy, PlatformLocation} from '@angular/common';
import {EventEmitter, Injectable} from '@angular/core';
import {SubscriptionLike} from 'rxjs';
const urlChangeListeners: ((url: string, state: unknown) => void)[] = [];
function notifyUrlChangeListeners(url: string = '', state: unknown) {
urlChangeListeners.forEach(fn => fn(url, state));
}
/**
* A spy for {@link Location} that allows tests to fire simulated location events.
*
* @publicApi
*/
@Injectable()
export class SpyLocation extends Location {
export class SpyLocation implements Location {
urlChanges: string[] = [];
private _history: LocationState[] = [new LocationState('', '', null)];
private _historyIndex: number = 0;
@ -34,6 +28,8 @@ export class SpyLocation extends Location {
_platformStrategy: LocationStrategy = null !;
/** @internal */
_platformLocation: PlatformLocation = null !;
/** @internal */
_urlChangeListeners: ((url: string, state: unknown) => void)[] = [];
setInitialPath(url: string) { this._history[this._historyIndex].path = url; }
@ -118,8 +114,13 @@ export class SpyLocation extends Location {
}
}
onUrlChange(fn: (url: string, state: unknown) => void) {
urlChangeListeners.push(fn);
this.subscribe(v => { notifyUrlChangeListeners(v.url, v.state); });
this._urlChangeListeners.push(fn);
this.subscribe(v => { this._notifyUrlChangeListeners(v.url, v.state); });
}
/** @internal */
_notifyUrlChangeListeners(url: string = '', state: unknown) {
this._urlChangeListeners.forEach(fn => fn(url, state));
}
subscribe(

View File

@ -12,7 +12,7 @@ import {Subject} from 'rxjs';
function parseUrl(urlStr: string, baseHref: string) {
const verifyProtocol = /^((http[s]?|ftp):\/\/)/;
let serverBase;
let serverBase: string|undefined;
// URL class requires full URL. If the URL string doesn't start with protocol, we need to add
// an arbitrary base URL which can be removed afterward.
@ -42,6 +42,8 @@ export const MOCK_PLATFORM_LOCATION_CONFIG = new InjectionToken('MOCK_PLATFORM_L
/**
* Mock implementation of URL state.
*
* @publicApi
*/
@Injectable()
export class MockPlatformLocation implements PlatformLocation {
@ -87,24 +89,12 @@ export class MockPlatformLocation implements PlatformLocation {
get href(): string {
let url = `${this.protocol}//${this.hostname}${this.port ? ':' + this.port : ''}`;
url += `${this.pathname === '/' ? '' : this.pathname}${this.search}${this.hash}`
url += `${this.pathname === '/' ? '' : this.pathname}${this.search}${this.hash}`;
return url;
}
get url(): string { return `${this.pathname}${this.search}${this.hash}`; }
private setHash(value: string, oldUrl: string) {
if (this.hash === value) {
// Don't fire events if the hash has not changed.
return;
}
(this as{hash: string}).hash = value;
const newUrl = this.url;
scheduleMicroTask(() => this.hashUpdate.next({
type: 'hashchange', state: null, oldUrl, newUrl
} as LocationChangeEvent));
}
private parseChanges(state: unknown, url: string, baseHref: string = '') {
// When the `history.state` value is stored, it is always copied.
state = JSON.parse(JSON.stringify(state));
@ -112,24 +102,31 @@ export class MockPlatformLocation implements PlatformLocation {
}
replaceState(state: any, title: string, newUrl: string): void {
const oldUrl = this.url;
const {pathname, search, state: parsedState, hash} = this.parseChanges(state, newUrl);
this.urlChanges[0] = {...this.urlChanges[0], pathname, search, state: parsedState};
this.setHash(hash, oldUrl);
this.urlChanges[0] = {...this.urlChanges[0], 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, state: parsedState});
this.urlChanges.unshift({...this.urlChanges[0], pathname, search, hash, state: parsedState});
}
forward(): void { throw new Error('Not implemented'); }
back(): void { this.urlChanges.shift(); }
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));
}
}
// History API isn't available on server, therefore return undefined
getState(): unknown { return this.state; }
}