fix(maps): stop all delivery once the controller is destroyed

A listener could destroy the controller and re-subscribe one still pending in
the snapshot, which then ran. Delivery now stops at destruction and a destroyed
controller takes no listeners. Adds ordering tests for adds, duplicates and
re-adds during delivery, and moves the current-state note after the section
intro.
This commit is contained in:
Sameh Khamis committed 2026-10-14 19:23:46 -07:00
1 parent 69a84c47a1
commit b9fa0eedcc
3 files changed
+41 -3

No files matched your search

+4 -2
View File
@@ -5209,8 +5209,6 @@ the project's MIT license; see `DATA_SOURCES.md`.
## Map source factories and coordination ## Map source factories and coordination
A subscriber removed, or a controller destroyed, during a settled-state delivery receives no further callbacks from that delivery.
Map selection is composed from separate imagery, terrain and 3D factories. Map selection is composed from separate imagery, terrain and 3D factories.
The default registry retains Google 3D, Bing Aerial/Labels, Esri Satellite and The default registry retains Google 3D, Bing Aerial/Labels, Esri Satellite and
OSM, including their setup guidance and existing preset/share IDs. Esri still OSM, including their setup guidance and existing preset/share IDs. Esri still
@@ -5218,6 +5216,10 @@ falls back to OSM on construction failure or two active tile failures, and
credits follow the source actually displayed. Terrain remains lazy while the credits follow the source actually displayed. Terrain remains lazy while the
photoreal globe is hidden. photoreal globe is hidden.
Map-stack subscribers hear each settled activation once. A subscriber removed
during a delivery, or every subscriber once the controller is destroyed, gets
no further callback from it, and a destroyed controller accepts no new ones.
Map credentials are passed to each source constructor rather than changing SDK-wide Google or ion defaults. Map credentials are passed to each source constructor rather than changing SDK-wide Google or ion defaults.
Bing Aerial and Bing Labels resolve their ion imagery endpoint with the supplied Bing Aerial and Bing Labels resolve their ion imagery endpoint with the supplied
+3 -1
View File
@@ -131,7 +131,8 @@ export class MapSourceController {
* @returns {() => void} Unsubscribe. * @returns {() => void} Unsubscribe.
*/ */
subscribe(listener) { subscribe(listener) {
if (typeof listener !== 'function') return () => {}; // A destroyed controller never settles again, so it takes no listeners.
if (typeof listener !== 'function' || this._destroyed) return () => {};
this._subscribers.add(listener); this._subscribers.add(listener);
return () => { return () => {
this._subscribers.delete(listener); this._subscribers.delete(listener);
@@ -142,6 +143,7 @@ export class MapSourceController {
if (this._destroyed || !this._subscribers.size) return; if (this._destroyed || !this._subscribers.size) return;
const state = this.getState(); const state = this.getState();
for (const listener of [...this._subscribers]) { for (const listener of [...this._subscribers]) {
if (this._destroyed) return;
if (!this._subscribers.has(listener)) continue; if (!this._subscribers.has(listener)) continue;
try { try {
listener(state); listener(state);
@@ -53,3 +53,37 @@ test('destroying the controller during delivery stops remaining listeners', asyn
await controller.setStack('fixture'); await controller.setStack('fixture');
assert.deepEqual(calls, ['destroy']); assert.deepEqual(calls, ['destroy']);
}); });
test('a listener re-added after destroy during delivery is not called', async () => {
const controller = fixture();
const calls = [];
const later = () => calls.push('later');
controller.subscribe(() => {
calls.push('destroy');
controller.destroy();
controller.subscribe(later);
});
controller.subscribe(later);
await controller.setStack('fixture');
assert.deepEqual(calls, ['destroy']);
});
test('delivery order: added during delivery waits, duplicates run once, re-added runs', async () => {
const controller = fixture();
const calls = [];
const twice = () => calls.push('twice');
const readded = () => calls.push('readded');
let offReadded;
controller.subscribe(() => {
calls.push('first');
controller.subscribe(() => calls.push('added-late'));
offReadded();
controller.subscribe(readded);
});
controller.subscribe(twice);
controller.subscribe(twice);
offReadded = controller.subscribe(readded);
await controller.setStack('fixture');
assert.deepEqual(calls, ['first', 'twice', 'readded']);
controller.destroy();
});