Skip to content

Commit bedb257

Browse files
arturovtalxhub
authored andcommitted
fix(common): cleanup URL change listeners when the root view is removed (angular#44901)
The `Location` creates the `_urlChangeSubscription` when the `onUrlChange` is called for the first time. The subscription `next` function captures `this` and prevents the `Location` from being garbage collected when the root view is removed. PR Close angular#44901
1 parent f17e26f commit bedb257

File tree

5 files changed

+92
-10
lines changed

5 files changed

+92
-10
lines changed

goldens/public-api/common/common.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ export class KeyValuePipe implements PipeTransform {
308308
}
309309

310310
// @public
311-
class Location_2 {
311+
class Location_2 implements OnDestroy {
312312
constructor(platformStrategy: LocationStrategy, platformLocation: PlatformLocation);
313313
back(): void;
314314
forward(): void;
@@ -317,9 +317,11 @@ class Location_2 {
317317
historyGo(relativePosition?: number): void;
318318
isCurrentPathEqualTo(path: string, query?: string): boolean;
319319
static joinWithSlash: (start: string, end: string) => string;
320+
// (undocumented)
321+
ngOnDestroy(): void;
320322
normalize(url: string): string;
321323
static normalizeQueryParams: (params: string) => string;
322-
onUrlChange(fn: (url: string, state: unknown) => void): void;
324+
onUrlChange(fn: (url: string, state: unknown) => void): VoidFunction;
323325
path(includeHash?: boolean): string;
324326
prepareExternalUrl(url: string): string;
325327
replaceState(path: string, query?: string, state?: any): void;

goldens/public-api/common/testing/testing.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,11 @@ export class SpyLocation implements Location_2 {
120120
// (undocumented)
121121
isCurrentPathEqualTo(path: string, query?: string): boolean;
122122
// (undocumented)
123+
ngOnDestroy(): void;
124+
// (undocumented)
123125
normalize(url: string): string;
124126
// (undocumented)
125-
onUrlChange(fn: (url: string, state: unknown) => void): void;
127+
onUrlChange(fn: (url: string, state: unknown) => void): VoidFunction;
126128
// (undocumented)
127129
path(): string;
128130
// (undocumented)

packages/common/src/location/location.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {EventEmitter, Injectable, ɵɵinject} from '@angular/core';
9+
import {EventEmitter, Injectable, OnDestroy, ɵɵinject} from '@angular/core';
1010
import {SubscriptionLike} from 'rxjs';
11+
1112
import {LocationStrategy} from './location_strategy';
1213
import {PlatformLocation} from './platform_location';
1314
import {joinWithSlash, normalizeQueryParams, stripTrailingSlash} from './util';
@@ -53,7 +54,7 @@ export interface PopStateEvent {
5354
// See #23917
5455
useFactory: createLocation,
5556
})
56-
export class Location {
57+
export class Location implements OnDestroy {
5758
/** @internal */
5859
_subject: EventEmitter<any> = new EventEmitter();
5960
/** @internal */
@@ -65,7 +66,7 @@ export class Location {
6566
/** @internal */
6667
_urlChangeListeners: ((url: string, state: unknown) => void)[] = [];
6768
/** @internal */
68-
_urlChangeSubscription?: SubscriptionLike;
69+
_urlChangeSubscription: SubscriptionLike|null = null;
6970

7071
constructor(platformStrategy: LocationStrategy, platformLocation: PlatformLocation) {
7172
this._platformStrategy = platformStrategy;
@@ -82,6 +83,12 @@ export class Location {
8283
});
8384
}
8485

86+
/** @nodoc */
87+
ngOnDestroy(): void {
88+
this._urlChangeSubscription?.unsubscribe();
89+
this._urlChangeListeners = [];
90+
}
91+
8592
/**
8693
* Normalizes the URL path for this location.
8794
*
@@ -209,15 +216,26 @@ export class Location {
209216
* framework that are not detectible through "popstate" or "hashchange" events.
210217
*
211218
* @param fn The change handler function, which take a URL and a location history state.
219+
* @returns A function that, when executed, unregisters a URL change listener.
212220
*/
213-
onUrlChange(fn: (url: string, state: unknown) => void) {
221+
onUrlChange(fn: (url: string, state: unknown) => void): VoidFunction {
214222
this._urlChangeListeners.push(fn);
215223

216224
if (!this._urlChangeSubscription) {
217225
this._urlChangeSubscription = this.subscribe(v => {
218226
this._notifyUrlChangeListeners(v.url, v.state);
219227
});
220228
}
229+
230+
return () => {
231+
const fnIndex = this._urlChangeListeners.indexOf(fn);
232+
this._urlChangeListeners.splice(fnIndex, 1);
233+
234+
if (this._urlChangeListeners.length === 0) {
235+
this._urlChangeSubscription?.unsubscribe();
236+
this._urlChangeSubscription = null;
237+
}
238+
};
221239
}
222240

223241
/** @internal */

packages/common/testing/src/location_mock.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ export class SpyLocation implements Location {
3333
/** @internal */
3434
_urlChangeListeners: ((url: string, state: unknown) => void)[] = [];
3535
/** @internal */
36-
_urlChangeSubscription?: SubscriptionLike;
36+
_urlChangeSubscription: SubscriptionLike|null = null;
37+
38+
ngOnDestroy(): void {
39+
this._urlChangeSubscription?.unsubscribe();
40+
this._urlChangeListeners = [];
41+
}
3742

3843
setInitialPath(url: string) {
3944
this._history[this._historyIndex].path = url;
@@ -138,14 +143,24 @@ export class SpyLocation implements Location {
138143
}
139144
}
140145

141-
onUrlChange(fn: (url: string, state: unknown) => void) {
146+
onUrlChange(fn: (url: string, state: unknown) => void): VoidFunction {
142147
this._urlChangeListeners.push(fn);
143148

144149
if (!this._urlChangeSubscription) {
145150
this._urlChangeSubscription = this.subscribe(v => {
146151
this._notifyUrlChangeListeners(v.url, v.state);
147152
});
148153
}
154+
155+
return () => {
156+
const fnIndex = this._urlChangeListeners.indexOf(fn);
157+
this._urlChangeListeners.splice(fnIndex, 1);
158+
159+
if (this._urlChangeListeners.length === 0) {
160+
this._urlChangeSubscription?.unsubscribe();
161+
this._urlChangeSubscription = null;
162+
}
163+
};
149164
}
150165

151166
/** @internal */

packages/router/test/bootstrap.spec.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {APP_BASE_HREF, DOCUMENT, ɵgetDOM as getDOM} from '@angular/common';
9+
import {APP_BASE_HREF, DOCUMENT, Location, ɵgetDOM as getDOM} from '@angular/common';
1010
import {ApplicationRef, Component, CUSTOM_ELEMENTS_SCHEMA, destroyPlatform, NgModule} from '@angular/core';
1111
import {inject} from '@angular/core/testing';
1212
import {BrowserModule} from '@angular/platform-browser';
@@ -407,6 +407,51 @@ describe('bootstrap', () => {
407407
expect(window.removeEventListener).toHaveBeenCalledWith('hashchange', jasmine.any(Function));
408408
});
409409

410+
it('should unregister a URL change listener and unsubscribe from URL changes when the root view is removed',
411+
async () => {
412+
const changeListener = jasmine.createSpy('changeListener');
413+
414+
@Component({template: 'second simple'})
415+
class SecondSimpleCmp {
416+
}
417+
418+
@NgModule({
419+
imports: [
420+
BrowserModule,
421+
RouterModule.forRoot(
422+
[{path: 'a', component: SimpleCmp}, {path: 'b', component: SecondSimpleCmp}],
423+
{initialNavigation: 'enabled'})
424+
],
425+
declarations: [RootCmp, SimpleCmp, SecondSimpleCmp],
426+
bootstrap: [RootCmp],
427+
providers: testProviders
428+
})
429+
class TestModule {
430+
}
431+
432+
const ngModuleRef = await platformBrowserDynamic().bootstrapModule(TestModule);
433+
const router = ngModuleRef.injector.get(Router);
434+
const location = ngModuleRef.injector.get(Location);
435+
436+
const removeUrlChangeFn = location.onUrlChange(changeListener);
437+
438+
await router.navigateByUrl('/a');
439+
expect(changeListener).toHaveBeenCalledTimes(1);
440+
441+
removeUrlChangeFn();
442+
await router.navigateByUrl('/b');
443+
expect(changeListener).toHaveBeenCalledTimes(1);
444+
445+
location.onUrlChange((url: string, state: unknown) => {});
446+
447+
ngModuleRef.destroy();
448+
449+
// Let's ensure that URL change listeners are unregistered when the root view is removed,
450+
// tho the last returned `onUrlChange` function hasn't been invoked.
451+
expect((location as any)._urlChangeListeners.length).toEqual(0);
452+
expect((location as any)._urlChangeSubscription.closed).toEqual(true);
453+
});
454+
410455
it('can schedule a navigation from the NavigationEnd event #37460', async (done) => {
411456
@NgModule({
412457
imports: [

0 commit comments

Comments
 (0)