diff --git a/CHANGELOG.md b/CHANGELOG.md index 335a5b9b2..3e4a0ea98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Map state updates stop delivering to subscribers removed during the same notification. + - Director assets cancelled during the final stream read are discarded with reader cleanup. - Analyst queries preserve useful rows when an upstream timestamp is outside the supported date range. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 323bb104e..e816c41b4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5224,6 +5224,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 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. Bing Aerial and Bing Labels resolve their ion imagery endpoint with the supplied diff --git a/src/maps/controller.js b/src/maps/controller.js index e595ede26..2b2ea462f 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -131,7 +131,8 @@ export class MapSourceController { * @returns {() => void} Unsubscribe. */ 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); return () => { this._subscribers.delete(listener); @@ -142,6 +143,8 @@ export class MapSourceController { if (this._destroyed || !this._subscribers.size) return; const state = this.getState(); for (const listener of [...this._subscribers]) { + if (this._destroyed) return; + if (!this._subscribers.has(listener)) continue; try { listener(state); } catch (error) { diff --git a/src/maps/map-subscription-ownership.test.mjs b/src/maps/map-subscription-ownership.test.mjs new file mode 100644 index 000000000..cf0708c09 --- /dev/null +++ b/src/maps/map-subscription-ownership.test.mjs @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { MapSourceController } from './controller.js'; +function fixture() { + return new MapSourceController( + { + scene: { + globe: { show: true }, + requestRender() {}, + frameState: { + creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} }, + }, + primitives: { add() {}, remove() {} }, + }, + imageryLayers: { add() {}, remove() {} }, + }, + { + registry: { + defaultId: 'fixture', + sources: [ + { + descriptor: { id: 'fixture', label: 'Fixture', kind: 'imagery' }, + imagery: async () => ({}), + }, + ], + }, + createImageryLayer: (provider) => ({ provider }), + }, + ); +} +test('an unsubscribed owner is skipped in the same map settlement delivery', async () => { + const controller = fixture(); + const calls = []; + let detach; + controller.subscribe(() => { + calls.push('first'); + detach(); + }); + detach = controller.subscribe(() => calls.push('detached')); + controller.subscribe(() => calls.push('last')); + await controller.setStack('fixture'); + assert.deepEqual(calls, ['first', 'last']); + controller.destroy(); +}); +test('destroying the controller during delivery stops remaining listeners', async () => { + const controller = fixture(); + const calls = []; + controller.subscribe(() => { + calls.push('destroy'); + controller.destroy(); + }); + controller.subscribe(() => calls.push('detached')); + await controller.setStack('fixture'); + 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(); +});