Merge pull request #1047 from rudycelekli/contrib/gods-026-map-subscription-ownership-20261011-reviewed

fix(maps): stop delivering settled state to detached subscribers
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-14 21:28:08 -07:00
commit f0a1b22dc8
4 files changed
+99 -1

No files matched your search

+2
View File
@@ -36,6 +36,8 @@
- Director timeline seeks cross the dateline along the short longitude arc. - 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. - 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. - Analyst queries preserve useful rows when an upstream timestamp is outside the supported date range.
+4
View File
@@ -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 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
+4 -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,8 @@ 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;
try { try {
listener(state); listener(state);
} catch (error) { } catch (error) {
@@ -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();
});