diff --git a/changelog.d/1634-layer-state-registered-only.md b/changelog.d/1634-layer-state-registered-only.md new file mode 100644 index 000000000..4d8eaa897 --- /dev/null +++ b/changelog.d/1634-layer-state-registered-only.md @@ -0,0 +1,3 @@ +- An application built with fewer layers no longer restores, saves or shares a + layer it does not have when a link or saved state from a full build names it + (samehkhamis, #1634). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3bf6b1051..851ead891 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5627,6 +5627,13 @@ and unreachable upstream (502/504) separately from road geometry. explicit-empty form); unknown option tokens are ignored. Restoration settles independently per layer so one failed or unavailable source cannot block its siblings. +- An application that registers fewer layers (an embedder with a smaller + catalog) keeps only its own: `LayerStateCoordinator` drops enabled-layer ids + the application did not register before it normalizes a share, saved state or + explicit change, so such a layer is never restored, written to local storage + or carried into a link this application shares. Options that depend on a + dropped layer (a tracked selection) go with it. An application with every + layer registered is unchanged. - Layer tokens are permanent public compatibility identifiers. The reservation JSON ledger at `src/data/layerStateTokenReservations.json` pins all existing one-character assignments (all letters plus `0`, `1` and diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index b75565285..2e843db2a 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -3121,3 +3121,70 @@ test('Active Fires: the codec accepts whole megawatts in bounds and the three co confidence: 'nominal', }); }); + +// An application built without a layer (a smaller catalog) receives a link or +// saved state from a build that has it: the layer is neither restored nor +// stored nor carried into the links this application shares. +function managerWithout(withheld) { + const manager = new DataLayerManager({}); + for (const id of REGISTERED_LAYER_IDS) + if (!withheld.includes(id)) manager.register(fakeLayer(id)); + manager.finalizeRegistrations( + LAYER_STATE_REGISTRY.filter(({ id }) => !withheld.includes(id)), + ); + return manager; +} + +test('layers the application did not register are never restored, stored or shared', async () => { + const manager = managerWithout(['earthquakes']); + const storage = memoryStorage(); + const share = shareSink(); + const coordinator = new LayerStateCoordinator(manager, share, { storage }); + const incoming = createDefaultLayerState(); + incoming.enabledLayerIds = ['earthquakes', 'traffic']; + await coordinator.start({ shareLayerState: incoming }); + + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['traffic']); + assert.deepEqual(share.provider().enabledLayerIds, ['traffic']); + assert.equal(manager.layers.has('earthquakes'), false); + + await manager.setEnabled('cctv', true, { origin: 'user' }); + const [[, written]] = storage.writes; + assert.deepEqual(parseStoredLayerState(written).enabledLayerIds, [ + 'cctv', + 'traffic', + ]); + assert.deepEqual(share.provider().enabledLayerIds, ['cctv', 'traffic']); + + await coordinator.applyShareLayerState({ shareLayerState: incoming }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['traffic']); + coordinator.destroy(); + + const saved = createDefaultLayerState(); + saved.enabledLayerIds = ['earthquakes', 'traffic']; + const local = new LayerStateCoordinator( + managerWithout(['earthquakes']), + shareSink(), + { storage: memoryStorage(serializeStoredLayerState(saved)) }, + ); + await local.start(); + assert.equal(local.source, 'local'); + assert.deepEqual(local.getDurableState().enabledLayerIds, ['traffic']); + local.destroy(); +}); + +test('an application with every layer registered keeps every requested layer', async () => { + const coordinator = new LayerStateCoordinator( + productionManager(), + shareSink(), + { storage: memoryStorage() }, + ); + const incoming = createDefaultLayerState(); + incoming.enabledLayerIds = ['earthquakes', 'traffic']; + await coordinator.start({ shareLayerState: incoming }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [ + 'earthquakes', + 'traffic', + ]); + coordinator.destroy(); +}); diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 40208831a..7cd554539 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -106,9 +106,7 @@ export class LayerStateCoordinator { } = {}) { if (this._destroyed) throw new Error('Layer-state coordinator is destroyed'); - let selected = shareLayerState - ? normalizeLayerState(shareLayerState) - : null; + let selected = shareLayerState ? this._normalize(shareLayerState) : null; if (selected) { this._source = 'share'; this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs) @@ -124,7 +122,7 @@ export class LayerStateCoordinator { /* best effort */ } if (stored) { - selected = stored; + selected = this._normalize(stored); this._source = 'local'; } } else { @@ -163,9 +161,7 @@ export class LayerStateCoordinator { for (const controller of this._restoreControllers.values()) controller.abort('superseded-by-share-navigation'); this._revokePendingTrackingWatch('superseded-by-share-navigation'); - const selected = shareLayerState - ? normalizeLayerState(shareLayerState) - : null; + const selected = shareLayerState ? this._normalize(shareLayerState) : null; this._source = selected ? 'share' : 'legacy-share'; this._shareCreatedAtMs = selected && Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null; @@ -182,6 +178,25 @@ export class LayerStateCoordinator { return this._source; } + /** + * Canonical layer state holding only the layers this application + * registered. An application built without a layer never restores, stores + * or shares it, even when a link or saved state from a build that has it + * names it. Filtering before normalizing lets normalization drop options + * that depend on a layer that is not here (a tracked selection). + */ + _normalize(candidate) { + const input = candidate && typeof candidate === 'object' ? candidate : {}; + return normalizeLayerState({ + ...input, + enabledLayerIds: Array.isArray(input.enabledLayerIds) + ? input.enabledLayerIds.filter((id) => + this.dataManager.layers.has(String(id)), + ) + : [], + }); + } + getDurableState() { return cloneLayerState(this._durableState); } @@ -345,7 +360,7 @@ export class LayerStateCoordinator { } _commitExplicit(candidate) { - this._durableState = normalizeLayerState(candidate); + this._durableState = this._normalize(candidate); const serialized = serializeStoredLayerState(this._durableState); try { if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) { @@ -478,7 +493,7 @@ export class LayerStateCoordinator { ...this._durableState.options[selected.optionOwner], [selected.optionKey]: null, }; - this._durableState = normalizeLayerState({ + this._durableState = this._normalize({ ...this._durableState, options: { ...this._durableState.options,