From dacb9a0762bca62f75e4bfdeb83ac95a9ef76893 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 3 Oct 2026 06:45:39 +0000 Subject: [PATCH] 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. --- internal/check-view-address.mjs | 6 ++++++ library/composables/useViewAddress.js | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/check-view-address.mjs b/internal/check-view-address.mjs index a5ab21674..019c453ef 100644 --- a/internal/check-view-address.mjs +++ b/internal/check-view-address.mjs @@ -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/], diff --git a/library/composables/useViewAddress.js b/library/composables/useViewAddress.js index 704b01dbd..323b8ad2a 100644 --- a/library/composables/useViewAddress.js +++ b/library/composables/useViewAddress.js @@ -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' }