diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..362eb93e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. +- Map state updates stop delivering to subscribers removed during the same notification. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..558963496 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(maps): stop delivering settled state to detached subscribers + +Recheck listener ownership and controller lifetime before each settled-state callback. Still-active subscribers keep their ordering and exception isolation. diff --git a/src/maps/controller.js b/src/maps/controller.js index e595ede26..d51b67082 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -142,6 +142,7 @@ export class MapSourceController { if (this._destroyed || !this._subscribers.size) return; const state = this.getState(); for (const listener of [...this._subscribers]) { + if (this._destroyed || !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..638de65a2 --- /dev/null +++ b/src/maps/map-subscription-ownership.test.mjs @@ -0,0 +1,55 @@ +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']); +});