From eacfc1b06a9a95b57df3f59951960f5233e0b773 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 19:09:30 -0700 Subject: [PATCH] Persist explicit provider toggles through the layer state coordinator --- CHANGELOG.md | 3 +- docs/CURRENT-STATE.md | 4 +- src/data/idSwitches.js | 16 +++ src/data/layerState.js | 3 +- src/data/layerState.test.mjs | 117 +++++++++++++++--- src/data/layerStateCoordinator.js | 18 ++- .../streetLevel/providerSwitches.test.mjs | 14 +++ 7 files changed, 152 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bebc8806..a93296425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ - Custom Street Level provider switches survive share links and stored state using URL-safe stable provider IDs and a shared boolean-switch codec; existing - Mapillary links keep their meaning. + Mapillary links keep their meaning. Explicit provider toggles reach durable + state without adopting unrelated live switch changes. - Directions and Recent Imagery accept replaceable acquisition sources, including route transport, imagery catalog, thumbnails, tile templates and attribution. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f2ad3225e..dc9a9278b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -31,7 +31,9 @@ stable. Custom provider switches use the `r` option (`example-1*panoramax-0`), with URL-safe separators and a shared layer-neutral boolean-switch codec, sorted by provider ID rather than registration order, and round-trip through links and stored state. A receiver applies only switches for its registered providers; absent custom switches leave -current defaults unchanged. The custom switch field is bounded to 256 characters; +current defaults unchanged. Explicit provider toggles persist only their requested +switch IDs through the state coordinator; unrelated live values remain transient. +The custom switch field is bounded to 256 characters; provider registration rejects compositions that cannot fit rather than truncating identities. A malformed or duplicate entry invalidates the complete custom-switch field. Existing whole-link size limits still apply. diff --git a/src/data/idSwitches.js b/src/data/idSwitches.js index 7b75b5da4..0fe8f8de0 100644 --- a/src/data/idSwitches.js +++ b/src/data/idSwitches.js @@ -45,3 +45,19 @@ export function decodeIdSwitches(value) { : [], ); } + +/** Persist only explicitly requested switch IDs, not unrelated live changes. */ +export function patchRequestedIdSwitches(previous, applied, requested) { + const current = decodeIdSwitches(applied); + const next = new Map( + [...decodeIdSwitches(previous)].filter(([id]) => current.has(id)), + ); + let changed = false; + for (const [id, on] of current) { + if (Object.hasOwn(requested, id) && typeof requested[id] === 'boolean') { + next.set(id, on); + changed = true; + } + } + return changed ? encodeIdSwitches(next) : undefined; +} diff --git a/src/data/layerState.js b/src/data/layerState.js index 50b61c66c..373eac20d 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -1,4 +1,4 @@ -import { normalizeIdSwitches } from './idSwitches.js'; +import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js'; import reservationRows from './layerStateTokenReservations.json' with { type: 'json' }; const VALID_DISPOSITIONS = new Set([ @@ -416,6 +416,7 @@ const OPTION_GROUPS = Object.freeze({ booleanOption('mapillary', 'm', true), Object.freeze({ key: 'providerSwitches', + patchRequested: patchRequestedIdSwitches, token: 'r', defaultValue: '', normalize: (value) => normalizeIdSwitches(value, ['mapillary']), diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 7377bdaa4..08120a604 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -1,3 +1,4 @@ +import { encodeParams, decodeParams } from '../layers/streetLevel/params.js'; import { readShellSource } from '../testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../../build/application-html.js'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; @@ -243,7 +244,11 @@ test('production registry is exact, canonical, and rejects incomplete contracts' } assert.equal(nextLayerStateToken(), '3'); assert.equal( - nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '3', bravo: '4' }), + nextLayerStateToken({ + ...LAYER_STATE_TOKEN_RESERVATIONS, + alpha: '3', + bravo: '4', + }), '5', ); const digitsExhausted = { @@ -276,7 +281,9 @@ test('production registry is exact, canonical, and rejects incomplete contracts' [ ...'0123456789', ...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) => - [...LAYER_STATE_TOKEN_ALPHABET].map((second) => `${first}${second}`), + [...LAYER_STATE_TOKEN_ALPHABET].map( + (second) => `${first}${second}`, + ), ), ].map((token, index) => [`occupied-${index}`, token]), ), @@ -298,33 +305,37 @@ test('production registry is exact, canonical, and rejects incomplete contracts' true, ); assert.equal( - validateLayerStateAllocations( - LAYER_STATE_TOKEN_RESERVATIONS, - { ...LAYER_STATE_TOKEN_RESERVATIONS, future: '3', next: '4' }, - ), + validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { + ...LAYER_STATE_TOKEN_RESERVATIONS, + future: '3', + next: '4', + }), true, ); assert.throws( - () => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { - ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '00', - }), + () => + validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { + ...LAYER_STATE_TOKEN_RESERVATIONS, + future: '00', + }), /next free token 3/, ); const beforeLastDigit = { ...digitsExhausted }; delete beforeLastDigit['prior-9']; assert.equal( - validateLayerStateAllocations( - beforeLastDigit, - { ...beforeLastDigit, futurePair: '00', futureDigit: '9' }, - ), + validateLayerStateAllocations(beforeLastDigit, { + ...beforeLastDigit, + futurePair: '00', + futureDigit: '9', + }), true, ); assert.equal( - validateLayerStateAllocations( - digitsExhausted, - { ...digitsExhausted, pairB: '01', pairA: '00' }, - ), + validateLayerStateAllocations(digitsExhausted, { + ...digitsExhausted, + pairB: '01', + pairA: '00', + }), true, ); assert.throws( @@ -2706,3 +2717,73 @@ test('Street Level: a link without the provider switch keeps Mapillary on', () = true, ); }); + +test('explicit provider switches reach durable state without adopting unrelated live switches', () => { + const manager = new DataLayerManager({}); + const providers = new Map([ + ['mapillary', true], + ['alpha', true], + ['beta', true], + ]); + const filter = { pano: 'all', sinceDays: 0 }; + for (const id of REGISTERED_LAYER_IDS) { + manager.register( + id === 'street-level' + ? { + ...fakeLayer(id), + getParams: () => encodeParams({ providers, filter }), + setParams(params) { + const decoded = decodeParams(params, { + providerIds: providers.keys(), + filter, + }); + for (const [id, on] of decoded.providers) providers.set(id, on); + return true; + }, + } + : fakeLayer(id), + ); + } + manager.finalizeRegistrations(LAYER_STATE_REGISTRY); + const storage = memoryStorage(); + const coordinator = new LayerStateCoordinator(manager, shareSink(), { + storage, + }); + try { + providers.set('beta', false); // Passive state does not belong to the next action. + assert.equal( + manager.setLayerParams( + 'street-level', + { alpha: false }, + { origin: 'user' }, + ), + true, + ); + let saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY)); + assert.equal(saved.options['street-level'].providerSwitches, 'alpha-0'); + const shared = decodeLayerStateParams(new URLSearchParams(encode(saved))); + const restored = decodeParams(shared.options['street-level'], { + providerIds: ['beta', 'alpha'], + filter, + }); + assert.deepEqual([...restored.providers], [['alpha', false]]); + manager.setLayerParams('street-level', { beta: true }, { origin: 'user' }); + saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY)); + assert.equal( + saved.options['street-level'].providerSwitches, + 'alpha-0*beta-1', + ); + manager.setLayerParams( + 'street-level', + { alpha: true }, + { origin: 'local-restore' }, + ); + saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY)); + assert.equal( + saved.options['street-level'].providerSwitches, + 'alpha-0*beta-1', + ); + } finally { + coordinator.destroy(); + } +}); diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 86b6af8e7..687b87dfb 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -255,8 +255,22 @@ export class LayerStateCoordinator { const explicitlyRequested = Object.hasOwn(requestedParams, spec.key); const implicitTrackingSync = !explicitlyRequested && spec.key === trackingOptionKey; - if (!explicitlyRequested && !implicitTrackingSync) continue; - const value = spec.normalize(change.params[spec.key]); + // Packed options can persist only the subkeys owned by this request. + const requestedValue = + explicitlyRequested || implicitTrackingSync + ? change.params[spec.key] + : spec.patchRequested?.( + nextOwnerOptions[spec.key], + change.params[spec.key], + requestedParams, + ); + if ( + !explicitlyRequested && + !implicitTrackingSync && + requestedValue === undefined + ) + continue; + const value = spec.normalize(requestedValue); if (value === null) { if (implicitTrackingSync) { nextOwnerOptions[spec.key] = null; diff --git a/src/layers/streetLevel/providerSwitches.test.mjs b/src/layers/streetLevel/providerSwitches.test.mjs index 78abe6d3e..03a161dcb 100644 --- a/src/layers/streetLevel/providerSwitches.test.mjs +++ b/src/layers/streetLevel/providerSwitches.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { encodeParams, decodeParams } from './params.js'; import { normalizeIdSwitches, + patchRequestedIdSwitches, decodeIdSwitches, encodeIdSwitches, } from '../../data/idSwitches.js'; @@ -137,3 +138,16 @@ test('neutral switch codec preserves hyphens and numeric suffixes in IDs', () => for (const [id, on] of switches) assert.equal(decoded.get(id), on); assert.equal(decodeIdSwitches('valid-1*broken').size, 0); }); + +test('editing a receiving composition drops unknown IDs before enforcing the share bound', () => { + const previous = `${'x'.repeat(254)}-1`; + assert.equal(normalizeIdSwitches(previous), previous); + assert.equal( + patchRequestedIdSwitches(previous, 'alpha-0*beta-0', { alpha: false }), + 'alpha-0', + ); + assert.equal( + patchRequestedIdSwitches(previous, 'alpha-0', { pano: 'flat' }), + undefined, + ); +});