feat(common): add ability to watch for AngularJS URL updates through `onUrlChange` hook (#30466)

The LocationShim (replacement for `$location`) was added to centralize dealing with the browser URL. Additionally, an `onUrlChange` method was added to Angular's Location service. This PR adds a corresponding method to the LocationShim so updates from AngularJS can be tracked in Angular.

PR Close #30466
This commit is contained in:
Jason Aden 2019-05-14 15:00:21 -07:00
parent 077809398c
commit 1aff524b63
3 changed files with 116 additions and 0 deletions

View File

@ -39,9 +39,16 @@ export class $locationShim {
private $$search: any = ''; private $$search: any = '';
private $$hash: string = ''; private $$hash: string = '';
private $$state: unknown; private $$state: unknown;
private $$changeListeners: [
((url: string, state: unknown, oldUrl: string, oldState: unknown, err?: (e: Error) => void) =>
void),
(e: Error) => void
][] = [];
private cachedState: unknown = null; private cachedState: unknown = null;
constructor( constructor(
$injector: any, private location: Location, private platformLocation: PlatformLocation, $injector: any, private location: Location, private platformLocation: PlatformLocation,
private urlCodec: UrlCodec, private locationStrategy: LocationStrategy) { private urlCodec: UrlCodec, private locationStrategy: LocationStrategy) {
@ -313,6 +320,32 @@ export class $locationShim {
} }
} }
/**
* Register URL change listeners. This API can be used to catch updates performed by the
* AngularJS framework. These changes are a subset of the `$locationChangeStart/Success` events
* as those events fire when AngularJS updates it's internally referenced version of the browser
* URL. It's possible for `$locationChange` events to happen, but for the browser URL
* (window.location) to remain unchanged. This `onChange` callback will fire only when AngularJS
* actually updates the browser URL (window.location).
*/
onChange(
fn: (url: string, state: unknown, oldUrl: string, oldState: unknown) => void,
err: (e: Error) => void = (e: Error) => {}) {
this.$$changeListeners.push([fn, err]);
}
/** @internal */
$$notifyChangeListeners(
url: string = '', state: unknown, oldUrl: string = '', oldState: unknown) {
this.$$changeListeners.forEach(([fn, err]) => {
try {
fn(url, state, oldUrl, oldState);
} catch (e) {
err(e);
}
});
}
$$parse(url: string) { $$parse(url: string) {
let pathUrl: string|undefined; let pathUrl: string|undefined;
if (url.startsWith('/')) { if (url.startsWith('/')) {
@ -363,6 +396,7 @@ export class $locationShim {
// state object; this makes possible quick checking if the state changed in the digest // state object; this makes possible quick checking if the state changed in the digest
// loop. Checking deep equality would be too expensive. // loop. Checking deep equality would be too expensive.
this.$$state = this.browserState(); this.$$state = this.browserState();
this.$$notifyChangeListeners(url, state, oldUrl, oldState);
} catch (e) { } catch (e) {
// Restore old values if pushState fails // Restore old values if pushState fails
this.url(oldUrl); this.url(oldUrl);

View File

@ -624,6 +624,87 @@ describe('New URL Parsing', () => {
}); });
}); });
describe('$location.onChange()', () => {
let $location: $locationShim;
let upgradeModule: UpgradeModule;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
CommonModule,
LocationUpgradeTestModule.config({useHash: false, startUrl: 'http://host.com/'}),
],
providers: [UpgradeModule],
});
upgradeModule = TestBed.get(UpgradeModule);
upgradeModule.$injector = {get: injectorFactory()};
});
beforeEach(inject([$locationShim], (loc: $locationShim) => { $location = loc; }));
it('should have onChange method', () => { expect(typeof $location.onChange).toBe('function'); });
it('should add registered functions to changeListeners', () => {
function changeListener(url: string, state: unknown) { return undefined; }
function errorHandler(e: Error) {}
expect(($location as any).$$changeListeners.length).toBe(0);
$location.onChange(changeListener, errorHandler);
expect(($location as any).$$changeListeners.length).toBe(1);
expect(($location as any).$$changeListeners[0][0]).toEqual(changeListener);
expect(($location as any).$$changeListeners[0][1]).toEqual(errorHandler);
});
it('should call changeListeners when URL is updated', () => {
const onChangeVals =
{url: 'url', state: 'state' as unknown, oldUrl: 'oldUrl', oldState: 'oldState' as unknown};
function changeListener(url: string, state: unknown, oldUrl: string, oldState: unknown) {
onChangeVals.url = url;
onChangeVals.state = state;
onChangeVals.oldUrl = oldUrl;
onChangeVals.oldState = oldState;
}
$location.onChange(changeListener);
// Mock out setting browserUrl
($location as any).browserUrl = (url: string, replace: boolean, state: unknown) => {};
const newState = {foo: 'bar'};
($location as any).setBrowserUrlWithFallback('/newUrl', false, newState);
expect(onChangeVals.url).toBe('/newUrl');
expect(onChangeVals.state).toBe(newState);
expect(onChangeVals.oldUrl).toBe('/');
expect(onChangeVals.oldState).toBe(null);
});
it('should call forward errors to error handler', () => {
let error !: Error;
function changeListener(url: string, state: unknown, oldUrl: string, oldState: unknown) {
throw new Error('Handle error');
}
function errorHandler(e: Error) { error = e; }
$location.onChange(changeListener, errorHandler);
// Mock out setting browserUrl
($location as any).browserUrl = (url: string, replace: boolean, state: unknown) => {};
($location as any).setBrowserUrlWithFallback('/newUrl');
expect(error.message).toBe('Handle error');
});
});
function parseLinkAndReturn(location: $locationShim, toUrl: string, relHref?: string) { function parseLinkAndReturn(location: $locationShim, toUrl: string, relHref?: string) {
const resetUrl = location.$$parseLinkUrl(toUrl, relHref); const resetUrl = location.$$parseLinkUrl(toUrl, relHref);
return resetUrl && location.absUrl() || undefined; return resetUrl && location.absUrl() || undefined;

View File

@ -6,6 +6,7 @@ export declare class $locationShim {
hash(hash: string | number | null): this; hash(hash: string | number | null): this;
hash(): string; hash(): string;
host(): string; host(): string;
onChange(fn: (url: string, state: unknown, oldUrl: string, oldState: unknown) => void, err?: (e: Error) => void): void;
path(): string; path(): string;
path(path: string | number | null): this; path(path: string | number | null): this;
port(): number | null; port(): number | null;