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
					
				
							
								
								
									
										10
									
								
								goldens/public-api/common/common.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								goldens/public-api/common/common.d.ts
									
									
									
									
										vendored
									
									
								
							| @ -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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user