fix(common): cleanup location change listeners when the root view is removed (#40867)
In the new behavior Angular cleanups `popstate` and `hashchange` event listeners when the root view gets destroyed, thus event handlers are not added twice when the application is bootstrapped again. BREAKING CHANGE: Methods of the `PlatformLocation` class, namely `onPopState` and `onHashChange`, used to return `void`. Now those methods return functions that can be called to remove event handlers. PR Close #31546 PR Close #40867
This commit is contained in:
parent
ca721c2972
commit
38524c4d29
|
@ -96,11 +96,12 @@ export declare function getLocaleWeekEndRange(locale: string): [WeekDay, WeekDay
|
||||||
|
|
||||||
export declare function getNumberOfCurrencyDigits(code: string): number;
|
export declare function getNumberOfCurrencyDigits(code: string): number;
|
||||||
|
|
||||||
export declare class HashLocationStrategy extends LocationStrategy {
|
export declare class HashLocationStrategy extends LocationStrategy implements OnDestroy {
|
||||||
constructor(_platformLocation: PlatformLocation, _baseHref?: string);
|
constructor(_platformLocation: PlatformLocation, _baseHref?: string);
|
||||||
back(): void;
|
back(): void;
|
||||||
forward(): void;
|
forward(): void;
|
||||||
getBaseHref(): string;
|
getBaseHref(): string;
|
||||||
|
ngOnDestroy(): void;
|
||||||
onPopState(fn: LocationChangeListener): void;
|
onPopState(fn: LocationChangeListener): void;
|
||||||
path(includeHash?: boolean): string;
|
path(includeHash?: boolean): string;
|
||||||
prepareExternalUrl(internal: string): string;
|
prepareExternalUrl(internal: string): string;
|
||||||
|
@ -324,11 +325,12 @@ export declare enum NumberSymbol {
|
||||||
CurrencyGroup = 13
|
CurrencyGroup = 13
|
||||||
}
|
}
|
||||||
|
|
||||||
export declare class PathLocationStrategy extends LocationStrategy {
|
export declare class PathLocationStrategy extends LocationStrategy implements OnDestroy {
|
||||||
constructor(_platformLocation: PlatformLocation, href?: string);
|
constructor(_platformLocation: PlatformLocation, href?: string);
|
||||||
back(): void;
|
back(): void;
|
||||||
forward(): void;
|
forward(): void;
|
||||||
getBaseHref(): string;
|
getBaseHref(): string;
|
||||||
|
ngOnDestroy(): void;
|
||||||
onPopState(fn: LocationChangeListener): void;
|
onPopState(fn: LocationChangeListener): void;
|
||||||
path(includeHash?: boolean): string;
|
path(includeHash?: boolean): string;
|
||||||
prepareExternalUrl(internal: string): string;
|
prepareExternalUrl(internal: string): string;
|
||||||
|
@ -355,8 +357,8 @@ export declare abstract class PlatformLocation {
|
||||||
abstract forward(): void;
|
abstract forward(): void;
|
||||||
abstract getBaseHrefFromDOM(): string;
|
abstract getBaseHrefFromDOM(): string;
|
||||||
abstract getState(): unknown;
|
abstract getState(): unknown;
|
||||||
abstract onHashChange(fn: LocationChangeListener): void;
|
abstract onHashChange(fn: LocationChangeListener): VoidFunction;
|
||||||
abstract onPopState(fn: LocationChangeListener): void;
|
abstract onPopState(fn: LocationChangeListener): VoidFunction;
|
||||||
abstract pushState(state: any, title: string, url: string): void;
|
abstract pushState(state: any, title: string, url: string): void;
|
||||||
abstract replaceState(state: any, title: string, url: string): void;
|
abstract replaceState(state: any, title: string, url: string): void;
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,8 +33,8 @@ export declare class MockPlatformLocation implements PlatformLocation {
|
||||||
forward(): void;
|
forward(): void;
|
||||||
getBaseHrefFromDOM(): string;
|
getBaseHrefFromDOM(): string;
|
||||||
getState(): unknown;
|
getState(): unknown;
|
||||||
onHashChange(fn: LocationChangeListener): void;
|
onHashChange(fn: LocationChangeListener): VoidFunction;
|
||||||
onPopState(fn: LocationChangeListener): void;
|
onPopState(fn: LocationChangeListener): VoidFunction;
|
||||||
pushState(state: any, title: string, newUrl: string): void;
|
pushState(state: any, title: string, newUrl: string): void;
|
||||||
replaceState(state: any, title: string, newUrl: string): void;
|
replaceState(state: any, title: string, newUrl: string): void;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,8 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Inject, Injectable, Optional} from '@angular/core';
|
import {Inject, Injectable, OnDestroy, Optional} from '@angular/core';
|
||||||
|
|
||||||
import {APP_BASE_HREF, LocationStrategy} from './location_strategy';
|
import {APP_BASE_HREF, LocationStrategy} from './location_strategy';
|
||||||
import {LocationChangeListener, PlatformLocation} from './platform_location';
|
import {LocationChangeListener, PlatformLocation} from './platform_location';
|
||||||
import {joinWithSlash, normalizeQueryParams} from './util';
|
import {joinWithSlash, normalizeQueryParams} from './util';
|
||||||
|
@ -32,8 +33,10 @@ import {joinWithSlash, normalizeQueryParams} from './util';
|
||||||
* @publicApi
|
* @publicApi
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HashLocationStrategy extends LocationStrategy {
|
export class HashLocationStrategy extends LocationStrategy implements OnDestroy {
|
||||||
private _baseHref: string = '';
|
private _baseHref: string = '';
|
||||||
|
private _removeListenerFns: (() => void)[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private _platformLocation: PlatformLocation,
|
private _platformLocation: PlatformLocation,
|
||||||
@Optional() @Inject(APP_BASE_HREF) _baseHref?: string) {
|
@Optional() @Inject(APP_BASE_HREF) _baseHref?: string) {
|
||||||
|
@ -43,9 +46,15 @@ export class HashLocationStrategy extends LocationStrategy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
while (this._removeListenerFns.length) {
|
||||||
|
this._removeListenerFns.pop()!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onPopState(fn: LocationChangeListener): void {
|
onPopState(fn: LocationChangeListener): void {
|
||||||
this._platformLocation.onPopState(fn);
|
this._removeListenerFns.push(
|
||||||
this._platformLocation.onHashChange(fn);
|
this._platformLocation.onPopState(fn), this._platformLocation.onHashChange(fn));
|
||||||
}
|
}
|
||||||
|
|
||||||
getBaseHref(): string {
|
getBaseHref(): string {
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Inject, Injectable, InjectionToken, Optional, ɵɵinject} from '@angular/core';
|
import {Inject, Injectable, InjectionToken, OnDestroy, Optional, ɵɵinject} from '@angular/core';
|
||||||
import {DOCUMENT} from '../dom_tokens';
|
import {DOCUMENT} from '../dom_tokens';
|
||||||
import {LocationChangeListener, PlatformLocation} from './platform_location';
|
import {LocationChangeListener, PlatformLocation} from './platform_location';
|
||||||
import {joinWithSlash, normalizeQueryParams} from './util';
|
import {joinWithSlash, normalizeQueryParams} from './util';
|
||||||
|
@ -105,8 +105,9 @@ export const APP_BASE_HREF = new InjectionToken<string>('appBaseHref');
|
||||||
* @publicApi
|
* @publicApi
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PathLocationStrategy extends LocationStrategy {
|
export class PathLocationStrategy extends LocationStrategy implements OnDestroy {
|
||||||
private _baseHref: string;
|
private _baseHref: string;
|
||||||
|
private _removeListenerFns: (() => void)[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private _platformLocation: PlatformLocation,
|
private _platformLocation: PlatformLocation,
|
||||||
|
@ -125,9 +126,15 @@ export class PathLocationStrategy extends LocationStrategy {
|
||||||
this._baseHref = href;
|
this._baseHref = href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
while (this._removeListenerFns.length) {
|
||||||
|
this._removeListenerFns.pop()!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onPopState(fn: LocationChangeListener): void {
|
onPopState(fn: LocationChangeListener): void {
|
||||||
this._platformLocation.onPopState(fn);
|
this._removeListenerFns.push(
|
||||||
this._platformLocation.onHashChange(fn);
|
this._platformLocation.onPopState(fn), this._platformLocation.onHashChange(fn));
|
||||||
}
|
}
|
||||||
|
|
||||||
getBaseHref(): string {
|
getBaseHref(): string {
|
||||||
|
|
|
@ -40,8 +40,14 @@ import {DOCUMENT} from '../dom_tokens';
|
||||||
export abstract class PlatformLocation {
|
export abstract class PlatformLocation {
|
||||||
abstract getBaseHrefFromDOM(): string;
|
abstract getBaseHrefFromDOM(): string;
|
||||||
abstract getState(): unknown;
|
abstract getState(): unknown;
|
||||||
abstract onPopState(fn: LocationChangeListener): void;
|
/**
|
||||||
abstract onHashChange(fn: LocationChangeListener): void;
|
* Returns a function that, when executed, removes the `popstate` event handler.
|
||||||
|
*/
|
||||||
|
abstract onPopState(fn: LocationChangeListener): VoidFunction;
|
||||||
|
/**
|
||||||
|
* Returns a function that, when executed, removes the `hashchange` event handler.
|
||||||
|
*/
|
||||||
|
abstract onHashChange(fn: LocationChangeListener): VoidFunction;
|
||||||
|
|
||||||
abstract get href(): string;
|
abstract get href(): string;
|
||||||
abstract get protocol(): string;
|
abstract get protocol(): string;
|
||||||
|
@ -122,12 +128,16 @@ export class BrowserPlatformLocation extends PlatformLocation {
|
||||||
return getDOM().getBaseHref(this._doc)!;
|
return getDOM().getBaseHref(this._doc)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
onPopState(fn: LocationChangeListener): void {
|
onPopState(fn: LocationChangeListener): VoidFunction {
|
||||||
getDOM().getGlobalEventTarget(this._doc, 'window').addEventListener('popstate', fn, false);
|
const window = getDOM().getGlobalEventTarget(this._doc, 'window');
|
||||||
|
window.addEventListener('popstate', fn, false);
|
||||||
|
return () => window.removeEventListener('popstate', fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
onHashChange(fn: LocationChangeListener): void {
|
onHashChange(fn: LocationChangeListener): VoidFunction {
|
||||||
getDOM().getGlobalEventTarget(this._doc, 'window').addEventListener('hashchange', fn, false);
|
const window = getDOM().getGlobalEventTarget(this._doc, 'window');
|
||||||
|
window.addEventListener('hashchange', fn, false);
|
||||||
|
return () => window.removeEventListener('hashchange', fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
get href(): string {
|
get href(): string {
|
||||||
|
|
|
@ -153,13 +153,15 @@ export class MockPlatformLocation implements PlatformLocation {
|
||||||
return this.baseHref;
|
return this.baseHref;
|
||||||
}
|
}
|
||||||
|
|
||||||
onPopState(fn: LocationChangeListener): void {
|
onPopState(fn: LocationChangeListener): VoidFunction {
|
||||||
// No-op: a state stack is not implemented, so
|
// No-op: a state stack is not implemented, so
|
||||||
// no events will ever come.
|
// no events will ever come.
|
||||||
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
onHashChange(fn: LocationChangeListener): void {
|
onHashChange(fn: LocationChangeListener): VoidFunction {
|
||||||
this.hashUpdate.subscribe(fn);
|
const subscription = this.hashUpdate.subscribe(fn);
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
get href(): string {
|
get href(): string {
|
||||||
|
|
|
@ -70,13 +70,15 @@ export class ServerPlatformLocation implements PlatformLocation {
|
||||||
return getDOM().getBaseHref(this._doc)!;
|
return getDOM().getBaseHref(this._doc)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
onPopState(fn: LocationChangeListener): void {
|
onPopState(fn: LocationChangeListener): VoidFunction {
|
||||||
// No-op: a state stack is not implemented, so
|
// No-op: a state stack is not implemented, so
|
||||||
// no events will ever come.
|
// no events will ever come.
|
||||||
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
onHashChange(fn: LocationChangeListener): void {
|
onHashChange(fn: LocationChangeListener): VoidFunction {
|
||||||
this._hashUpdate.subscribe(fn);
|
const subscription = this._hashUpdate.subscribe(fn);
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
get url(): string {
|
get url(): string {
|
||||||
|
|
|
@ -6,13 +6,12 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {APP_BASE_HREF, DOCUMENT, Location, ɵgetDOM as getDOM} from '@angular/common';
|
import {APP_BASE_HREF, DOCUMENT, ɵgetDOM as getDOM} from '@angular/common';
|
||||||
import {ApplicationRef, Component, CUSTOM_ELEMENTS_SCHEMA, destroyPlatform, NgModule} from '@angular/core';
|
import {ApplicationRef, Component, CUSTOM_ELEMENTS_SCHEMA, destroyPlatform, NgModule} from '@angular/core';
|
||||||
import {inject} from '@angular/core/testing';
|
import {inject} from '@angular/core/testing';
|
||||||
import {BrowserModule} from '@angular/platform-browser';
|
import {BrowserModule} from '@angular/platform-browser';
|
||||||
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
|
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
|
||||||
import {NavigationEnd, Resolve, Router, RouterModule} from '@angular/router';
|
import {NavigationEnd, Resolve, Router, RouterModule} from '@angular/router';
|
||||||
import {filter, first} from 'rxjs/operators';
|
|
||||||
|
|
||||||
describe('bootstrap', () => {
|
describe('bootstrap', () => {
|
||||||
if (isNode) return;
|
if (isNode) return;
|
||||||
|
@ -369,7 +368,30 @@ describe('bootstrap', () => {
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
function waitForNavigationToComplete(router: Router): Promise<any> {
|
it('should cleanup "popstate" and "hashchange" listeners', async () => {
|
||||||
return router.events.pipe(filter((e: any) => e instanceof NavigationEnd), first()).toPromise();
|
@NgModule({
|
||||||
|
imports: [BrowserModule, RouterModule.forRoot([])],
|
||||||
|
declarations: [RootCmp],
|
||||||
|
bootstrap: [RootCmp],
|
||||||
|
providers: testProviders,
|
||||||
|
})
|
||||||
|
class TestModule {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
spyOn(window, 'addEventListener').and.callThrough();
|
||||||
|
spyOn(window, 'removeEventListener').and.callThrough();
|
||||||
|
|
||||||
|
const ngModuleRef = await platformBrowserDynamic().bootstrapModule(TestModule);
|
||||||
|
ngModuleRef.destroy();
|
||||||
|
|
||||||
|
expect(window.addEventListener).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
expect(window.addEventListener)
|
||||||
|
.toHaveBeenCalledWith('popstate', jasmine.any(Function), jasmine.any(Boolean));
|
||||||
|
expect(window.addEventListener)
|
||||||
|
.toHaveBeenCalledWith('hashchange', jasmine.any(Function), jasmine.any(Boolean));
|
||||||
|
|
||||||
|
expect(window.removeEventListener).toHaveBeenCalledWith('popstate', jasmine.any(Function));
|
||||||
|
expect(window.removeEventListener).toHaveBeenCalledWith('hashchange', jasmine.any(Function));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue