useViewAddress: unchanged means the same view-key VALUES, not the same string

Measured in a browser on a fresh load: ?owner=x&pane=map came back as ?pane=map&owner=x through one replaceState.
viewSearch puts the view keys first, and writeViewKeys compared strings. The link meant the same thing and no Back
step was added, but it was a needless write, and on a host it would be a needless router.replace. writeViewKeys now
compares the parsed tab/pane/place. check:view-address B covers it, with a poison (44); the real-file revert fails.
This commit is contained in:
Doxa Maps 2026-10-03 06:45:39 +00:00
parent 6f94ff0e30
commit dacb9a0762
2 changed files with 11 additions and 1 deletions

View file

@ -137,6 +137,11 @@ async function arms({ va, sv, counts, pw, src }, { skipF = false } = {}) {
const r2 = va.writeViewKeys({ tab: 'regions' }, { owner: 'app', push: true })
if (r2 !== 'unchanged' || b.writes.push + b.writes.replace !== 1) fails.push(`B: an unchanged address wrote (${r2})`)
if (va.commitUrl({}) !== false || b.writes.push + b.writes.replace !== 1) fails.push('B: commitUrl wrote an identical URL')
// the same values in another order is unchanged too (a link written by someone else, a host's own params first)
va.commitUrl({ search: '?owner=x&pane=map&tab=regions' })
const n = b.writes.push + b.writes.replace
const r3 = va.writeViewKeys({ pane: 'map' }, { owner: 'app', push: false })
if (r3 !== 'unchanged' || b.writes.push + b.writes.replace !== n) fails.push(`B: the same keys in another order were rewritten (${r3}): ${b.at()}`)
}
{
const b = browser('https://partner.test/page?tab=theirs')
@ -435,6 +440,7 @@ if (process.argv.includes('--self-test')) {
['a write when not the owner', { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, owner: 'app' }) } }, /^B: a partner page/],
['a tab change that keeps a stale place', { ...real, va: { ...va, viewSearch: (s, c) => { const k = { ...va.readViewKeys(s), ...Object.fromEntries(Object.entries(c).map(([a, b]) => [a, va.cleanKey(b)])) }; const f = s.replace(/^\?/, '').split('&').filter((p) => p && !va.VIEW_KEYS.includes(p.split('=')[0])); const all = [...va.VIEW_KEYS.filter((x) => k[x]).map((x) => `${x}=${k[x]}`), ...f]; return all.length ? '?' + all.join('&') : '' } } }, /^A: a tab change drops pane and place/],
['a whitelist with no length cap', { ...real, va: { ...va, cleanKey: (v) => (/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(String(v ?? '')) ? String(v) : '') } }, /^A: the whitelist lets/],
['unchanged judged by the string, not the values', { ...real, va: { ...va, writeViewKeys: (c, o) => { if (o.owner !== 'app') return va.writeViewKeys(c, o); const s = va.viewSearch(location.search, c); if (s === location.search) return 'unchanged'; return va.commitUrl({ search: s }, { push: o.push }) ? 'written' : 'unchanged' } } }, /^B: the same keys in another order were rewritten/],
['a write when the address is unchanged', { ...real, va: { ...va, writeViewKeys: (c, o) => { const s = va.viewSearch(location.search, c); history[o.push ? 'pushState' : 'replaceState']({ ...(history.state || {}), current: location.pathname + s + location.hash }, '', location.pathname + s + location.hash); return 'written' } } }, /^B: an unchanged address wrote|^C: re-applying the tab after Back WROTE/],
["a 'host' owner that writes history itself", { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, owner: o.owner === 'host' ? 'app' : o.owner }) } }, /^B: a 'host' owner wrote/],
['a dxm-address that cannot leave a shadow root', { ...real, va: { ...va, writeViewKeys: (c, o) => { if (o.owner !== 'host') return va.writeViewKeys(c, o); const s = va.viewSearch(location.search, c); o.el.dispatchEvent(new CustomEvent(va.ADDRESS_EVENT, { bubbles: true, detail: { keys: va.readViewKeys(s), href: location.pathname + s, push: o.push } })); return 'announced' } } }, /^B: dxm-address cannot leave/],

View file

@ -98,7 +98,11 @@ export const urlOwnerOf = (cfg) => (cfg && (cfg.urlOwner === 'app' || cfg.urlOwn
export function writeViewKeys(changes, { owner = '', push = true, el = null } = {}) {
if ((owner !== 'app' && owner !== 'host') || !globalThis.location) return 'none'
const search = viewSearch(location.search, changes)
if (search === location.search) return 'unchanged'
// Unchanged means the same VALUES, not the same string: viewSearch puts the view keys first, so comparing strings
// rewrote a link like ?owner=x&pane=map as ?pane=map&owner=x on every fresh load (measured in a browser), a
// needless replace (and on a host, a needless router.replace).
const now = readViewKeys(location.search), next = readViewKeys(search)
if (VIEW_KEYS.every((k) => now[k] === next[k])) return 'unchanged'
if (owner === 'app') return commitUrl({ search }, { push }) ? 'written' : 'unchanged'
return send(el, ADDRESS_EVENT, { keys: readViewKeys(search), href: location.pathname + search + location.hash, push }) ? 'announced' : 'none'
}