Persist explicit provider toggles through the layer state coordinator

This commit is contained in:
Sameh Khamis committed 2026-10-09 19:09:30 -07:00
1 parent 3ba8c6d61b
commit eacfc1b06a
7 files changed
+152 -23

No files matched your search

+2 -1
View File
@@ -2,7 +2,8 @@
- Custom Street Level provider switches survive share links and stored state - Custom Street Level provider switches survive share links and stored state
using URL-safe stable provider IDs and a shared boolean-switch codec; existing 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 - Directions and Recent Imagery accept replaceable acquisition sources, including
route transport, imagery catalog, thumbnails, tile templates and attribution. route transport, imagery catalog, thumbnails, tile templates and attribution.
+3 -1
View File
@@ -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 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 than registration order, and round-trip through links and stored state. A receiver
applies only switches for its registered providers; absent custom switches leave 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 provider registration rejects compositions that cannot fit rather than truncating
identities. A malformed or duplicate entry invalidates the complete custom-switch identities. A malformed or duplicate entry invalidates the complete custom-switch
field. Existing whole-link size limits still apply. field. Existing whole-link size limits still apply.
+16
View File
@@ -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;
}
+2 -1
View File
@@ -1,4 +1,4 @@
import { normalizeIdSwitches } from './idSwitches.js'; import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' }; import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
const VALID_DISPOSITIONS = new Set([ const VALID_DISPOSITIONS = new Set([
@@ -416,6 +416,7 @@ const OPTION_GROUPS = Object.freeze({
booleanOption('mapillary', 'm', true), booleanOption('mapillary', 'm', true),
Object.freeze({ Object.freeze({
key: 'providerSwitches', key: 'providerSwitches',
patchRequested: patchRequestedIdSwitches,
token: 'r', token: 'r',
defaultValue: '', defaultValue: '',
normalize: (value) => normalizeIdSwitches(value, ['mapillary']), normalize: (value) => normalizeIdSwitches(value, ['mapillary']),
+99 -18
View File
@@ -1,3 +1,4 @@
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
import { readShellSource } from '../testSupport/readShellSource.mjs'; import { readShellSource } from '../testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../../build/application-html.js'; import { expandApplicationHtml } from '../../build/application-html.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs'; 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(), '3');
assert.equal( assert.equal(
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '3', bravo: '4' }), nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '3',
bravo: '4',
}),
'5', '5',
); );
const digitsExhausted = { const digitsExhausted = {
@@ -276,7 +281,9 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
[ [
...'0123456789', ...'0123456789',
...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) => ...[...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]), ].map((token, index) => [`occupied-${index}`, token]),
), ),
@@ -298,33 +305,37 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
true, true,
); );
assert.equal( assert.equal(
validateLayerStateAllocations( validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
LAYER_STATE_TOKEN_RESERVATIONS, ...LAYER_STATE_TOKEN_RESERVATIONS,
{ ...LAYER_STATE_TOKEN_RESERVATIONS, future: '3', next: '4' }, future: '3',
), next: '4',
}),
true, true,
); );
assert.throws( assert.throws(
() => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { () =>
...LAYER_STATE_TOKEN_RESERVATIONS, validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
future: '00', ...LAYER_STATE_TOKEN_RESERVATIONS,
}), future: '00',
}),
/next free token 3/, /next free token 3/,
); );
const beforeLastDigit = { ...digitsExhausted }; const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9']; delete beforeLastDigit['prior-9'];
assert.equal( assert.equal(
validateLayerStateAllocations( validateLayerStateAllocations(beforeLastDigit, {
beforeLastDigit, ...beforeLastDigit,
{ ...beforeLastDigit, futurePair: '00', futureDigit: '9' }, futurePair: '00',
), futureDigit: '9',
}),
true, true,
); );
assert.equal( assert.equal(
validateLayerStateAllocations( validateLayerStateAllocations(digitsExhausted, {
digitsExhausted, ...digitsExhausted,
{ ...digitsExhausted, pairB: '01', pairA: '00' }, pairB: '01',
), pairA: '00',
}),
true, true,
); );
assert.throws( assert.throws(
@@ -2706,3 +2717,73 @@ test('Street Level: a link without the provider switch keeps Mapillary on', () =
true, 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();
}
});
+16 -2
View File
@@ -255,8 +255,22 @@ export class LayerStateCoordinator {
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key); const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
const implicitTrackingSync = const implicitTrackingSync =
!explicitlyRequested && spec.key === trackingOptionKey; !explicitlyRequested && spec.key === trackingOptionKey;
if (!explicitlyRequested && !implicitTrackingSync) continue; // Packed options can persist only the subkeys owned by this request.
const value = spec.normalize(change.params[spec.key]); 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 (value === null) {
if (implicitTrackingSync) { if (implicitTrackingSync) {
nextOwnerOptions[spec.key] = null; nextOwnerOptions[spec.key] = null;
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { encodeParams, decodeParams } from './params.js'; import { encodeParams, decodeParams } from './params.js';
import { import {
normalizeIdSwitches, normalizeIdSwitches,
patchRequestedIdSwitches,
decodeIdSwitches, decodeIdSwitches,
encodeIdSwitches, encodeIdSwitches,
} from '../../data/idSwitches.js'; } 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); for (const [id, on] of switches) assert.equal(decoded.get(id), on);
assert.equal(decodeIdSwitches('valid-1*broken').size, 0); 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,
);
});