Skip to content
Permalink
Browse files

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

…0055)

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
  • Loading branch information...
jasonaden authored and benlesh committed Apr 23, 2019
1 parent f185ff3 commit 4277600d5ec1597fcc51bf85542056b9b21805f9
@@ -57,7 +57,8 @@ export class Location {
_platformStrategy: LocationStrategy;
/** @internal */
_platformLocation: PlatformLocation;
private urlChangeListeners: any[] = [];
/** @internal */
_urlChangeListeners: ((url: string, state: unknown) => void)[] = [];

constructor(platformStrategy: LocationStrategy, platformLocation: PlatformLocation) {
this._platformStrategy = platformStrategy;
@@ -147,7 +148,7 @@ export class Location {
*/
go(path: string, query: string = '', state: any = null): void {
this._platformStrategy.pushState(state, '', path, query);
this.notifyUrlChangeListeners(
this._notifyUrlChangeListeners(
this.prepareExternalUrl(path + Location.normalizeQueryParams(query)), state);
}

@@ -161,7 +162,7 @@ export class Location {
*/
replaceState(path: string, query: string = '', state: any = null): void {
this._platformStrategy.replaceState(state, '', path, query);
this.notifyUrlChangeListeners(
this._notifyUrlChangeListeners(
this.prepareExternalUrl(path + Location.normalizeQueryParams(query)), state);
}

@@ -180,13 +181,13 @@ export class Location {
* framework. These are not detectible through "popstate" or "hashchange" events.
*/
onUrlChange(fn: (url: string, state: unknown) => void) {
this.urlChangeListeners.push(fn);
this.subscribe(v => { this.notifyUrlChangeListeners(v.url, v.state); });
this._urlChangeListeners.push(fn);
this.subscribe(v => { this._notifyUrlChangeListeners(v.url, v.state); });
}


private notifyUrlChangeListeners(url: string = '', state: unknown) {
this.urlChangeListeners.forEach(fn => fn(url, state));
/** @internal */
_notifyUrlChangeListeners(url: string = '', state: unknown) {
this._urlChangeListeners.forEach(fn => fn(url, state));
}

/**
@@ -6,8 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {CommonModule, Location, LocationStrategy, PlatformLocation} from '@angular/common';
import {PathLocationStrategy} from '@angular/common/src/common';
import {CommonModule, Location, LocationStrategy, PathLocationStrategy, PlatformLocation} from '@angular/common';
import {MockPlatformLocation} from '@angular/common/testing';
import {TestBed, inject} from '@angular/core/testing';

@@ -91,23 +90,22 @@ describe('Location Class', () => {
});

it('should have onUrlChange method', inject([Location], (location: Location) => {
expect(typeof location.onUrlChange).toBe('function');
}));
expect(typeof location.onUrlChange).toBe('function');
}));

it('should add registered functions to urlChangeListeners',
inject([Location], (location: Location) => {

it('should add registered functions to urlChangeListeners', inject([Location], (location: Location) => {
function changeListener(url: string, state: unknown) { return undefined; }

function changeListener(url: string, state: unknown) {
return undefined;
}
expect((location as any)._urlChangeListeners.length).toBe(0);

expect((location as any).urlChangeListeners.length).toBe(0);
location.onUrlChange(changeListener);

location.onUrlChange(changeListener);
expect((location as any)._urlChangeListeners.length).toBe(1);
expect((location as any)._urlChangeListeners[0]).toEqual(changeListener);

expect((location as any).urlChangeListeners.length).toBe(1);
expect((location as any).urlChangeListeners[0]).toEqual(changeListener);

}));
}));

});
});
@@ -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(
@@ -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,49 +89,44 @@ 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));
return {...parseUrl(url, baseHref), state};
}

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; }
}

This file was deleted.

Oops, something went wrong.
Oops, something went wrong.

0 comments on commit 4277600

Please sign in to comment.
You can’t perform that action at this time.