Layer state keeps only layers the application registered

An application built with fewer layers no longer restores, stores or shares a
layer it does not have when a link or saved state from a build that has it
names that layer. LayerStateCoordinator drops unregistered enabled-layer ids
before normalizing, so dependent options (a tracked selection) go with them.
An application with every layer registered is unchanged.
This commit is contained in:
Sameh Khamis committed 2026-11-08 14:19:56 -08:00
1 parent 7ee7dc26b8
commit e9346cef19
3 files changed
+98 -9

No files matched your search

+7
View File
@@ -5627,6 +5627,13 @@ and unreachable upstream (502/504) separately from road geometry.
explicit-empty form); unknown option tokens are ignored. Restoration explicit-empty form); unknown option tokens are ignored. Restoration
settles independently per layer so one failed or unavailable source cannot settles independently per layer so one failed or unavailable source cannot
block its siblings. 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 - Layer tokens are permanent public compatibility identifiers. The reservation
JSON ledger at `src/data/layerStateTokenReservations.json` pins all existing JSON ledger at `src/data/layerStateTokenReservations.json` pins all existing
one-character assignments (all letters plus `0`, `1` and one-character assignments (all letters plus `0`, `1` and
+67
View File
@@ -3121,3 +3121,70 @@ test('Active Fires: the codec accepts whole megawatts in bounds and the three co
confidence: 'nominal', 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();
});
+24 -9
View File
@@ -106,9 +106,7 @@ export class LayerStateCoordinator {
} = {}) { } = {}) {
if (this._destroyed) if (this._destroyed)
throw new Error('Layer-state coordinator is destroyed'); throw new Error('Layer-state coordinator is destroyed');
let selected = shareLayerState let selected = shareLayerState ? this._normalize(shareLayerState) : null;
? normalizeLayerState(shareLayerState)
: null;
if (selected) { if (selected) {
this._source = 'share'; this._source = 'share';
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs) this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs)
@@ -124,7 +122,7 @@ export class LayerStateCoordinator {
/* best effort */ /* best effort */
} }
if (stored) { if (stored) {
selected = stored; selected = this._normalize(stored);
this._source = 'local'; this._source = 'local';
} }
} else { } else {
@@ -163,9 +161,7 @@ export class LayerStateCoordinator {
for (const controller of this._restoreControllers.values()) for (const controller of this._restoreControllers.values())
controller.abort('superseded-by-share-navigation'); controller.abort('superseded-by-share-navigation');
this._revokePendingTrackingWatch('superseded-by-share-navigation'); this._revokePendingTrackingWatch('superseded-by-share-navigation');
const selected = shareLayerState const selected = shareLayerState ? this._normalize(shareLayerState) : null;
? normalizeLayerState(shareLayerState)
: null;
this._source = selected ? 'share' : 'legacy-share'; this._source = selected ? 'share' : 'legacy-share';
this._shareCreatedAtMs = this._shareCreatedAtMs =
selected && Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null; selected && Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null;
@@ -182,6 +178,25 @@ export class LayerStateCoordinator {
return this._source; 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() { getDurableState() {
return cloneLayerState(this._durableState); return cloneLayerState(this._durableState);
} }
@@ -345,7 +360,7 @@ export class LayerStateCoordinator {
} }
_commitExplicit(candidate) { _commitExplicit(candidate) {
this._durableState = normalizeLayerState(candidate); this._durableState = this._normalize(candidate);
const serialized = serializeStoredLayerState(this._durableState); const serialized = serializeStoredLayerState(this._durableState);
try { try {
if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) { if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) {
@@ -478,7 +493,7 @@ export class LayerStateCoordinator {
...this._durableState.options[selected.optionOwner], ...this._durableState.options[selected.optionOwner],
[selected.optionKey]: null, [selected.optionKey]: null,
}; };
this._durableState = normalizeLayerState({ this._durableState = this._normalize({
...this._durableState, ...this._durableState,
options: { options: {
...this._durableState.options, ...this._durableState.options,