diff --git a/packages/common/upgrade/src/location_shim.ts b/packages/common/upgrade/src/location_shim.ts index 49eb8d4c4b..6a81891e94 100644 --- a/packages/common/upgrade/src/location_shim.ts +++ b/packages/common/upgrade/src/location_shim.ts @@ -39,9 +39,16 @@ export class $locationShim { private $$search: any = ''; private $$hash: string = ''; 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; + + constructor( $injector: any, private location: Location, private platformLocation: PlatformLocation, 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) { let pathUrl: string|undefined; if (url.startsWith('/')) { @@ -363,6 +396,7 @@ export class $locationShim { // state object; this makes possible quick checking if the state changed in the digest // loop. Checking deep equality would be too expensive. this.$$state = this.browserState(); + this.$$notifyChangeListeners(url, state, oldUrl, oldState); } catch (e) { // Restore old values if pushState fails this.url(oldUrl); diff --git a/packages/common/upgrade/test/upgrade.spec.ts b/packages/common/upgrade/test/upgrade.spec.ts index 72b476eb9c..14d422fa2b 100644 --- a/packages/common/upgrade/test/upgrade.spec.ts +++ b/packages/common/upgrade/test/upgrade.spec.ts @@ -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) { const resetUrl = location.$$parseLinkUrl(toUrl, relHref); return resetUrl && location.absUrl() || undefined; diff --git a/tools/public_api_guard/common/upgrade.d.ts b/tools/public_api_guard/common/upgrade.d.ts index 3f50d4970f..420eb3411e 100644 --- a/tools/public_api_guard/common/upgrade.d.ts +++ b/tools/public_api_guard/common/upgrade.d.ts @@ -6,6 +6,7 @@ export declare class $locationShim { hash(hash: string | number | null): this; hash(): string; host(): string; + onChange(fn: (url: string, state: unknown, oldUrl: string, oldState: unknown) => void, err?: (e: Error) => void): void; path(): string; path(path: string | number | null): this; port(): number | null;