Merge pull request #985 from bilawalsidhu/fix/street-level-provider-sharing

Preserve custom Street Level provider switches in shared state
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-09 20:03:02 -07:00
commit 89e566aa9b
12 files changed
+416 -29

No files matched your search

+5
View File
@@ -1,5 +1,10 @@
# Changelog
- 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. 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.
Explicitly removing either source leaves its layer unavailable; standalone
+11 -2
View File
@@ -26,8 +26,17 @@ all imagery providers. Without that option, Mapillary is registered only when
with a truthful no-provider state, preserving layer controls and share tokens.
Malformed selected sources and provider definitions still fail validation.
An explicit provider list bypasses the unused Mapillary source, including its
validation. Layer share identities are preserved; custom provider switches require
a corresponding serialization contract.
validation. Layer share identities and the legacy Mapillary `m` option remain
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. 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.
## God's Eye View in conversations — October 2, 2026
+11
View File
@@ -552,6 +552,7 @@
"src/contextModePolicy.js",
"src/data/detectionPolicy.js",
"src/data/installationFeedback.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -800,6 +801,7 @@
"src/data/cockpitContactDot.js",
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
@@ -842,6 +844,7 @@
"src/data/cockpitContactDot.js",
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
@@ -879,6 +882,7 @@
"src/data/cockpitContactDot.js",
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -1204,6 +1208,7 @@
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/pickRouter.js",
"src/data/idSwitches.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
"src/layers/streetLevel/filter.js",
@@ -1249,6 +1254,7 @@
"exports": ["./layers/radio"],
"modules": [
"src/data/iconOrientation.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/radioCountry.js",
@@ -1537,6 +1543,7 @@
"src/data/installationFeedback.js",
"src/data/issPass.js",
"src/data/labelArbiter.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -2286,6 +2293,7 @@
"src/data/issPass.js",
"src/data/satellitePass.js",
"src/data/labelArbiter.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/localGeojson.js",
@@ -2807,6 +2815,7 @@
"exports": ["./ui/share-restoration"],
"modules": [
"src/data/installationFeedback.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -3327,6 +3336,7 @@
"src/tools/queries/brief.js",
"src/tools/queries/maritime.js",
"src/tools/queries/share.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/sources/gbfsStations.js",
@@ -3449,6 +3459,7 @@
"exports": ["./view"],
"modules": [
"src/view/index.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json"
],
+63
View File
@@ -0,0 +1,63 @@
const MAX_SWITCHES_LENGTH = 256;
const ENTRY = /^([a-z][a-z0-9-]*)-([01])$/;
/** Canonical, bounded boolean switches keyed by stable slugs. */
export function normalizeIdSwitches(value, reservedIds = []) {
if (typeof value !== 'string' || value.length > MAX_SWITCHES_LENGTH)
return null;
if (!value) return '';
const entries = new Map();
for (const field of value.split('*')) {
const match = ENTRY.exec(field);
if (!match || reservedIds.includes(match[1]) || entries.has(match[1]))
return null;
entries.set(match[1], match[2] === '1');
}
return [...entries]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([id, on]) => `${id}-${on ? '1' : '0'}`)
.join('*');
}
/** Encode by stable IDs rather than registration order or generated tokens. */
export function encodeIdSwitches(switches) {
const value = [...switches]
.map(([id, on]) => `${id}-${on === true ? '1' : '0'}`)
.join('*');
if (value.length > MAX_SWITCHES_LENGTH)
throw new RangeError(
`Switch IDs exceed the ${MAX_SWITCHES_LENGTH}-character share-link limit`,
);
const normalized = normalizeIdSwitches(value);
if (normalized === null) throw new TypeError('Invalid boolean switch IDs');
return normalized;
}
/** A Map keeps switch IDs separate from object prototype properties. */
export function decodeIdSwitches(value) {
const normalized = normalizeIdSwitches(value);
return new Map(
normalized
? normalized.split('*').map((entry) => {
const [, id, on] = ENTRY.exec(entry);
return [id, on === '1'];
})
: [],
);
}
/** 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;
}
+11 -1
View File
@@ -1,3 +1,4 @@
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
const VALID_DISPOSITIONS = new Set([
@@ -411,8 +412,17 @@ const OPTION_GROUPS = Object.freeze({
booleanOption('viirs', 'v', false),
]),
'street-level': Object.freeze([
// One switch per registered imagery provider (src/app/layers/streetLevel.js).
// Preserve the published Mapillary token; custom switches use stable IDs.
booleanOption('mapillary', 'm', true),
Object.freeze({
key: 'providerSwitches',
patchRequested: patchRequestedIdSwitches,
token: 'r',
defaultValue: '',
normalize: (value) => normalizeIdSwitches(value, ['mapillary']),
encode: normalizeIdSwitches,
decode: (value) => normalizeIdSwitches(value, ['mapillary']),
}),
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
all: 'a',
pano: 'p',
+102 -18
View File
@@ -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(
@@ -2660,12 +2671,14 @@ test('Street Level: every option round-trips through a share link and stored sta
state.enabledLayerIds = ['street-level'];
state.options['street-level'] = {
mapillary: false,
providerSwitches: '',
pano: 'flat',
sinceDays: MAX_SINCE_DAYS,
};
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
assert.deepEqual(decoded.options['street-level'], {
mapillary: false,
providerSwitches: '',
pano: 'flat',
sinceDays: MAX_SINCE_DAYS,
});
@@ -2693,6 +2706,7 @@ test('Street Level: the link codec accepts exactly the windows the filter keeps'
test('Street Level: a link without the provider switch keeps Mapillary on', () => {
assert.deepEqual(streetLevelOptions(''), {
mapillary: true,
providerSwitches: '',
pano: 'all',
sinceDays: 0,
});
@@ -2703,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();
}
});
+16 -2
View File
@@ -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;
+6
View File
@@ -98,6 +98,12 @@ function withPublishedBase(
}
function writeCodecFixture(cwd, rows, entries) {
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
mkdirSync(path.dirname(providerCodec), { recursive: true });
writeFileSync(
providerCodec,
readFileSync(new URL('./idSwitches.js', import.meta.url)),
);
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
assert.equal(source.split(registryEnd).length, 2);
+16 -5
View File
@@ -1,13 +1,19 @@
import { normalizeFilter } from './filter.js';
import { encodeIdSwitches, decodeIdSwitches } from '../../data/idSwitches.js';
/**
* Share-link params: one boolean per provider plus the filter. The codec in
* src/data/layerState.js names the same keys, so a new provider needs one there.
* Provider params plus the filter. Custom switches share a stable ID-based
* field; Mapillary retains its existing boolean and share token.
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
*/
export function encodeParams({ providers, filter }) {
const params = {};
for (const [id, on] of providers) params[id] = on === true;
const entries = [...providers];
for (const [id, on] of entries) params[id] = on === true;
const switches = encodeIdSwitches(
entries.filter(([id]) => id !== 'mapillary'),
);
if (switches) params.providerSwitches = switches;
params.pano = filter.pano;
params.sinceDays = filter.sinceDays;
return params;
@@ -23,8 +29,13 @@ export function encodeParams({ providers, filter }) {
export function decodeParams(params, { providerIds, filter }) {
const providers = new Map();
const source = params && typeof params === 'object' ? params : {};
for (const id of providerIds)
if (typeof source[id] === 'boolean') providers.set(id, source[id]);
const switches = decodeIdSwitches(source.providerSwitches);
switches.delete('mapillary');
for (const id of providerIds) {
if (switches.has(id)) providers.set(id, switches.get(id));
else if (Object.hasOwn(source, id) && typeof source[id] === 'boolean')
providers.set(id, source[id]);
}
const next = {};
if ('pano' in source) next.pano = source.pano;
if ('sinceDays' in source) next.sinceDays = source.sinceDays;
+7 -1
View File
@@ -11,7 +11,13 @@ test('encodeParams writes one boolean per provider plus the filter', () => {
],
filter: { pano: 'flat', sinceDays: 365 },
}),
{ mapillary: true, panoramax: false, pano: 'flat', sinceDays: 365 },
{
mapillary: true,
panoramax: false,
providerSwitches: 'panoramax-0',
pano: 'flat',
sinceDays: 365,
},
);
});
@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { encodeParams, decodeParams } from './params.js';
import {
normalizeIdSwitches,
patchRequestedIdSwitches,
decodeIdSwitches,
encodeIdSwitches,
} from '../../data/idSwitches.js';
import {
createDefaultLayerState,
encodeLayerStateParams,
decodeLayerStateParams,
serializeStoredLayerState,
parseStoredLayerState,
} from '../../data/layerState.js';
import { validateProviders } from './registry.js';
import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs';
const filter = { pano: 'flat', sinceDays: 365 };
test('custom switches survive sharing and storage without depending on provider order', () => {
const providers = [
['panoramax', false],
['example', true],
['mapillary', false],
];
const options = encodeParams({ providers, filter });
assert.equal(options.providerSwitches, 'example-1*panoramax-0');
assert.equal(
encodeParams({ providers: [...providers].reverse(), filter })
.providerSwitches,
options.providerSwitches,
);
const state = createDefaultLayerState();
state.enabledLayerIds = ['street-level'];
state.options['street-level'] = options;
const params = new URLSearchParams({ v: '2' });
encodeLayerStateParams(params, state);
assert.match(params.get('lo'), /0\.m\.0/);
assert.match(params.get('lo'), /0\.r\.example-1\*panoramax-0/);
assert.ok(
params.toString().includes('0.r.example-1*panoramax-0'),
'custom switches need no percent escaping',
);
for (const restored of [
decodeLayerStateParams(params),
parseStoredLayerState(serializeStoredLayerState(state)),
]) {
const decoded = decodeParams(restored.options['street-level'], {
providerIds: ['example', 'mapillary', 'panoramax'],
filter: { pano: 'all', sinceDays: 0 },
});
assert.deepEqual(
[...decoded.providers],
[
['example', true],
['mapillary', false],
['panoramax', false],
],
);
assert.deepEqual(decoded.filter, filter);
assert.deepEqual(
[
...decodeParams(restored.options['street-level'], {
providerIds: ['panoramax'],
filter,
}).providers,
],
[['panoramax', false]],
);
assert.equal(
decodeParams(restored.options['street-level'], {
providerIds: [],
filter,
}).providers.size,
0,
);
}
});
test('legacy links retain Mapillary semantics and leave custom defaults alone', () => {
for (const [lo, on] of [
['', true],
['0.m.0', false],
]) {
const state = decodeLayerStateParams(
new URLSearchParams({ v: '2', l: '0', lo }),
);
const restored = decodeParams(state.options['street-level'], {
providerIds: ['mapillary', 'example'],
filter,
});
assert.deepEqual([...restored.providers], [['mapillary', on]]);
}
});
test('switches reject ambiguous or oversized fields and cannot override the legacy token', () => {
for (const value of [
'example-0*example-1',
'mapillary-0',
'__proto__-1',
'a-2',
'a-1*',
'a.b-1',
'a_b-0',
`${'a'.repeat(255)}-1`,
null,
{},
]) {
assert.equal(normalizeIdSwitches(value, ['mapillary']), null);
}
assert.equal(normalizeIdSwitches('constructor-0'), 'constructor-0');
assert.throws(
() => encodeIdSwitches([['a'.repeat(255), true]]),
/share-link/,
);
assert.throws(
() => validateProviders([fakeStreetLevelProvider({ id: 'a'.repeat(255) })]),
/share-link/,
);
const decoded = decodeParams(
{ providerSwitches: 'mapillary-0', mapillary: true },
{ providerIds: ['mapillary'], filter },
);
assert.equal(decoded.providers.get('mapillary'), true);
});
test('neutral switch codec preserves hyphens and numeric suffixes in IDs', () => {
const switches = [
['provider-0', false],
['provider-1', true],
['a-b-c', true],
];
const encoded = encodeIdSwitches(switches);
assert.equal(encoded, 'a-b-c-1*provider-0-0*provider-1-1');
const decoded = decodeIdSwitches(encoded);
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,
);
});
+15
View File
@@ -1,3 +1,5 @@
import { encodeIdSwitches } from '../../data/idSwitches.js';
/**
* The contract an imagery provider registers with the Street Level layer.
*
@@ -125,6 +127,19 @@ export function validateProviders(providers) {
ids.add(provider.id);
prefixes.push(provider.pickPrefix);
}
// Validate the complete bounded share field once, before any instance exists.
try {
encodeIdSwitches(
providers
.filter(({ id }) => id !== 'mapillary')
.map(({ id }) => [id, true]),
);
} catch (error) {
if (!(error instanceof RangeError)) throw error;
throw new RangeError(
'Street Level provider IDs cannot fit in the 256-character share-link field; use shorter IDs or fewer providers',
);
}
return Object.freeze([...providers]);
}