Skip to content

Commit cc503c4

Browse files
authored
fix(google-maps): maintain subscriptions across event targets (#20897)
This came up during the review of #20873. Currently the class that manages events for the Google Maps objects assumes that once a target is assigned, it'll either be maintained or eventually removed which means that the fix from #20873 will cause any existing event listeners to be dropped. These changes add some code which will preserve the listeners. Furthermore, I've added a dedicated testing setup for the `MapEventManager` since there's a fair bit of logic encapsulated in it and we've only been testing it by proxy through the various Google Maps components.
1 parent 5f2407e commit cc503c4

File tree

2 files changed

+194
-19
lines changed

2 files changed

+194
-19
lines changed
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import {NgZone} from '@angular/core';
2+
import {MapEventManager} from './map-event-manager';
3+
4+
describe('MapEventManager', () => {
5+
let dummyZone: NgZone;
6+
let manager: MapEventManager;
7+
let target: TestEventTarget;
8+
9+
beforeEach(() => {
10+
dummyZone = {
11+
run: jasmine.createSpy('NgZone.run').and.callFake((callback: () => void) => callback())
12+
} as unknown as NgZone;
13+
target = new TestEventTarget();
14+
manager = new MapEventManager(dummyZone);
15+
});
16+
17+
afterEach(() => {
18+
manager.destroy();
19+
});
20+
21+
it('should register a listener when subscribing to an event', () => {
22+
expect(target.addListener).not.toHaveBeenCalled();
23+
24+
manager.setTarget(target);
25+
const stream = manager.getLazyEmitter('click');
26+
27+
expect(target.addListener).not.toHaveBeenCalled();
28+
expect(target.events.get('click')).toBeFalsy();
29+
30+
const subscription = stream.subscribe();
31+
expect(target.addListener).toHaveBeenCalledTimes(1);
32+
expect(target.events.get('click')?.size).toBe(1);
33+
subscription.unsubscribe();
34+
});
35+
36+
it('should register a listener if the subscription happened before there was a target', () => {
37+
const stream = manager.getLazyEmitter('click');
38+
const subscription = stream.subscribe();
39+
40+
expect(target.addListener).not.toHaveBeenCalled();
41+
expect(target.events.get('click')).toBeFalsy();
42+
43+
manager.setTarget(target);
44+
45+
expect(target.addListener).toHaveBeenCalledTimes(1);
46+
expect(target.events.get('click')?.size).toBe(1);
47+
subscription.unsubscribe();
48+
});
49+
50+
it('should remove the listener when unsubscribing', () => {
51+
const stream = manager.getLazyEmitter('click');
52+
const subscription = stream.subscribe();
53+
manager.setTarget(target);
54+
expect(target.events.get('click')?.size).toBe(1);
55+
56+
subscription.unsubscribe();
57+
expect(target.events.get('click')?.size).toBe(0);
58+
});
59+
60+
it('should remove the listener when the manager is destroyed', () => {
61+
const stream = manager.getLazyEmitter('click');
62+
stream.subscribe();
63+
manager.setTarget(target);
64+
expect(target.events.get('click')?.size).toBe(1);
65+
66+
manager.destroy();
67+
expect(target.events.get('click')?.size).toBe(0);
68+
});
69+
70+
it('should remove the listener when the target is changed', () => {
71+
const stream = manager.getLazyEmitter('click');
72+
stream.subscribe();
73+
manager.setTarget(target);
74+
expect(target.events.get('click')?.size).toBe(1);
75+
76+
manager.setTarget(undefined);
77+
expect(target.events.get('click')?.size).toBe(0);
78+
});
79+
80+
it('should trigger the subscription to an event', () => {
81+
const spy = jasmine.createSpy('subscription');
82+
const stream = manager.getLazyEmitter('click');
83+
stream.subscribe(spy);
84+
manager.setTarget(target);
85+
expect(spy).not.toHaveBeenCalled();
86+
87+
target.triggerListeners('click');
88+
expect(spy).toHaveBeenCalledTimes(1);
89+
});
90+
91+
it('should be able to register multiple listeners to the same event', () => {
92+
const firstSpy = jasmine.createSpy('subscription one');
93+
const secondSpy = jasmine.createSpy('subscription two');
94+
const stream = manager.getLazyEmitter('click');
95+
manager.setTarget(target);
96+
stream.subscribe(firstSpy);
97+
stream.subscribe(secondSpy);
98+
expect(firstSpy).not.toHaveBeenCalled();
99+
expect(secondSpy).not.toHaveBeenCalled();
100+
expect(target.events.get('click')?.size).toBe(2);
101+
102+
target.triggerListeners('click');
103+
expect(firstSpy).toHaveBeenCalledTimes(1);
104+
expect(secondSpy).toHaveBeenCalledTimes(1);
105+
});
106+
107+
it('should run listeners inside the NgZone', () => {
108+
const spy = jasmine.createSpy('subscription');
109+
const stream = manager.getLazyEmitter('click');
110+
stream.subscribe(spy);
111+
manager.setTarget(target);
112+
expect(dummyZone.run).not.toHaveBeenCalled();
113+
114+
target.triggerListeners('click');
115+
expect(dummyZone.run).toHaveBeenCalledTimes(1);
116+
});
117+
118+
it('should maintain subscriptions when swapping out targets', () => {
119+
const spy = jasmine.createSpy('subscription');
120+
const stream = manager.getLazyEmitter('click');
121+
stream.subscribe(spy);
122+
manager.setTarget(target);
123+
expect(spy).not.toHaveBeenCalled();
124+
125+
target.triggerListeners('click');
126+
expect(spy).toHaveBeenCalledTimes(1);
127+
128+
const alternateTarget = new TestEventTarget();
129+
manager.setTarget(alternateTarget);
130+
131+
expect(spy).toHaveBeenCalledTimes(1);
132+
expect(target.events.get('click')?.size).toBe(0);
133+
expect(alternateTarget.events.get('click')?.size).toBe(1);
134+
135+
alternateTarget.triggerListeners('click');
136+
expect(spy).toHaveBeenCalledTimes(2);
137+
138+
manager.setTarget(undefined);
139+
expect(alternateTarget.events.get('click')?.size).toBe(0);
140+
141+
alternateTarget.triggerListeners('click');
142+
expect(spy).toHaveBeenCalledTimes(2);
143+
});
144+
145+
});
146+
147+
/** Imitates a Google Maps event target and keeps track of the registered events. */
148+
class TestEventTarget {
149+
events = new Map<string, Set<() => void>>();
150+
151+
addListener = jasmine.createSpy('addListener').and.callFake(
152+
(name: string, listener: () => void) => {
153+
if (!this.events.has(name)) {
154+
this.events.set(name, new Set());
155+
}
156+
this.events.get(name)!.add(listener);
157+
158+
return {remove: () => this.events.get(name)!.delete(listener)};
159+
});
160+
161+
triggerListeners(name: string) {
162+
const listeners = this.events.get(name);
163+
164+
if (!listeners) {
165+
throw Error(`No listeners registered for "${name}" event.`);
166+
}
167+
168+
listeners.forEach(listener => listener());
169+
}
170+
}

src/google-maps/map-event-manager.ts

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
*/
88

99
import {NgZone} from '@angular/core';
10-
import {Observable, Subscriber} from 'rxjs';
10+
import {BehaviorSubject, Observable, Subscriber} from 'rxjs';
11+
import {switchMap} from 'rxjs/operators';
1112

1213
type MapEventManagerTarget = {
1314
addListener: (name: string, callback: (...args: any[]) => void) => google.maps.MapsEventListener;
@@ -18,11 +19,11 @@ export class MapEventManager {
1819
/** Pending listeners that were added before the target was set. */
1920
private _pending: {observable: Observable<any>, observer: Subscriber<any>}[] = [];
2021
private _listeners: google.maps.MapsEventListener[] = [];
21-
private _target: MapEventManagerTarget;
22+
private _targetStream = new BehaviorSubject<MapEventManagerTarget>(undefined);
2223

2324
/** Clears all currently-registered event listeners. */
2425
private _clearListeners() {
25-
for (let listener of this._listeners) {
26+
for (const listener of this._listeners) {
2627
listener.remove();
2728
}
2829

@@ -33,36 +34,40 @@ export class MapEventManager {
3334

3435
/** Gets an observable that adds an event listener to the map when a consumer subscribes to it. */
3536
getLazyEmitter<T>(name: string): Observable<T> {
36-
const observable = new Observable<T>(observer => {
37-
// If the target hasn't been initialized yet, cache the observer so it can be added later.
38-
if (!this._target) {
39-
this._pending.push({observable, observer});
40-
return undefined;
41-
}
37+
return this._targetStream.pipe(switchMap(target => {
38+
const observable = new Observable<T>(observer => {
39+
// If the target hasn't been initialized yet, cache the observer so it can be added later.
40+
if (!target) {
41+
this._pending.push({observable, observer});
42+
return undefined;
43+
}
4244

43-
const listener = this._target.addListener(name, (event: T) => {
44-
this._ngZone.run(() => observer.next(event));
45+
const listener = target.addListener(name, (event: T) => {
46+
this._ngZone.run(() => observer.next(event));
47+
});
48+
this._listeners.push(listener);
49+
return () => listener.remove();
4550
});
46-
this._listeners.push(listener);
47-
return () => listener.remove();
48-
});
4951

50-
return observable;
52+
return observable;
53+
}));
5154
}
5255

5356
/** Sets the current target that the manager should bind events to. */
5457
setTarget(target: MapEventManagerTarget) {
55-
if (target === this._target) {
58+
const currentTarget = this._targetStream.value;
59+
60+
if (target === currentTarget) {
5661
return;
5762
}
5863

5964
// Clear the listeners from the pre-existing target.
60-
if (this._target) {
65+
if (currentTarget) {
6166
this._clearListeners();
6267
this._pending = [];
6368
}
6469

65-
this._target = target;
70+
this._targetStream.next(target);
6671

6772
// Add the listeners that were bound before the map was initialized.
6873
this._pending.forEach(subscriber => subscriber.observable.subscribe(subscriber.observer));
@@ -73,6 +78,6 @@ export class MapEventManager {
7378
destroy() {
7479
this._clearListeners();
7580
this._pending = [];
76-
this._target = undefined;
81+
this._targetStream.complete();
7782
}
7883
}

0 commit comments

Comments
 (0)