doxa-maps/internal/check-view-address.mjs
Doxa Maps 30dc9a1457 Shareable pages P1: one address helper, and the filters write through it
library/composables/useViewAddress.js is the page's address for the view a reader is on: ?tab= &pane= &place=, with
a level rule, an id whitelist, and ONE history write (commitUrl). It keeps history.state and sets its `current` to
the new URL, so a link survives the next in-app navigation on a Vue Router host. The OUTERMOST owner writes,
named by profile-config urlOwner:
- 'app' writes history itself;
- 'host' hands its router one dxm-address;
- absent, nothing is written and links are still read.
Nested elements announce dxm-view. Back/Forward is read and never written.

useShareableView's filter write used to call replaceState(null, ...) itself, which wiped the router's state. It
now goes through commitUrl (writeFilterView). A filter write keeps tab/pane/place, and a tab/pane/place write keeps
the filters.

Level 2 is `pane`, not `view`: ?view= is already published by four of our pages.

check:view-address (gates:pre) runs on a simulated browser history. It covers:
- the whitelist and the level rule;
- one push per tab change, with state + current kept;
- no write when unchanged, on a partner page, or by a host owner;
- the filters and the view keys keeping each other through writes, Back and Forward;
- a census of every history writer in library/ and plugin-designs/ (6 today). A new writer fails until it is listed.

The self-test has 14 poisons, each caught. Real-source mutations are caught too: drop current, drop the unchanged
guard, drop the level rule, an uncomposed event, no length cap, an aliased pushState in a plugin.
2026-10-03 05:20:43 +00:00

237 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// check:view-address — the page's address (library/composables/useViewAddress.js) and its ONE history write.
// A. the pure helpers: the id whitelist + 64 cap; an unknown or malformed value reads as absent (the default opens);
// the level rule (a tab change drops pane and place, a pane change drops place, unless the change sets them);
// foreign params keep their exact text.
// B. the writes, on a simulated browser history: an 'app' owner pushes once and keeps history.state with `current`
// and the hash; an unchanged address writes nothing; a partner page (no owner) writes nothing; a 'host' owner
// writes nothing and hands the host one dxm-address event; nested elements announce dxm-view; Back/Forward is
// only read, never written.
// C. COEXISTENCE with the filters (useShareableView, which writes through the same commitUrl): a filter write keeps
// tab/pane/place, the hash or the query it does not own, and the router's history.state; a tab/pane/place write
// keeps the filters (query AND hash form); both survive Back and Forward, and re-applying the old tab after Back
// writes nothing, so Forward still exists.
// D. THE CENSUS of history writers, enumerated from the tree: every history.pushState/replaceState call in library/
// and plugin-designs/ is the helper's or a listed writer. A new one fails until it is listed here with its reason.
// --self-test: each poison below must FAIL its arm. Pure node: no Vite, no build, no browser.
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
// The history writers that exist today besides the helper, by call count. Adding one means a row here with a reason.
const KNOWN = {
'library/composables/useViewAddress.js': [1, 'THE write (commitUrl)'],
'library/utils/languageRequest.js': [1, '?lang= on a generated page only (MAP_OWNS_PAGE); keeps state and every other param'],
'library/layouts/app-wrapper-material/index.html': [1, "the layout page's own #/<map> hash; a fragment-only URL keeps the query"],
'plugin-designs/gospel-internet/src/useBoard.js': [1, 'its own keys on its own leaves (writeUrl); keeps state, foreign params and the hash'],
'plugin-designs/gospel-resources/data-tables.vue': [1, "clearFilter: replaceState({}, '', pathname) wipes the whole query and hash; reported to its owner"],
'plugin-designs/regions-dashboard/region-dashboards.vue': [2, '#<slug> on its own leaf (URL_SYNC); P2 moves it to `place`'],
}
// SOURCE files only: gates and node scripts may simulate history. A write is `.pushState(` / `.replaceState(` (an aliased
// history included) or a computed `history[… 'pushState' …]`.
const WRITE = /\.(?:push|replace)State\s*\(|\[[^\]\n]*['"](?:push|replace)State['"]/g
function census() {
const out = execFileSync('git', ['grep', '--untracked', '-cE', '(push|replace)State', '--', 'library', 'plugin-designs',
':!*.md', ':!*.mjs', ':!**/scripts/**', ':!**/test/**', ':!**/*.test.*', ':!**/*.spec.*'], { cwd: ROOT, encoding: 'utf8' }).trim()
const counts = {}
for (const line of out.split('\n').filter(Boolean)) {
const i = line.lastIndexOf(':')
const file = line.slice(0, i)
// -c counts LINES; count the calls themselves (a ternary writes twice on one line).
const n = (readFileSync(join(ROOT, file), 'utf8').match(WRITE) || []).length
if (n) counts[file] = n
}
return counts
}
// ── a browser's history, simulated: entries + index, popstate on back/forward, write counters ──────────────────────
function browser(href, state = null) {
const entries = [{ url: new URL(href), state }]
let i = 0
const writes = { push: 0, replace: 0 }
const listeners = new Set()
const loc = {
get href() { return entries[i].url.href },
get pathname() { return entries[i].url.pathname },
get search() { return entries[i].url.search },
get hash() { return entries[i].url.hash },
}
const copy = (s) => (s == null ? s : JSON.parse(JSON.stringify(s)))
const hist = {
get state() { return entries[i].state },
get length() { return entries.length },
pushState(s, _, url) { writes.push++; entries.splice(i + 1); entries.push({ url: new URL(url, entries[i].url), state: copy(s) }); i++ },
replaceState(s, _, url) { writes.replace++; entries[i] = { url: new URL(url, entries[i].url), state: copy(s) } },
back() { if (i > 0) { i--; listeners.forEach((h) => h()) } },
forward() { if (i < entries.length - 1) { i++; listeners.forEach((h) => h()) } },
}
globalThis.location = loc; globalThis.history = hist
globalThis.addEventListener = (t, h) => t === 'popstate' && listeners.add(h)
globalThis.removeEventListener = (t, h) => t === 'popstate' && listeners.delete(h)
const at = () => loc.pathname + loc.search + loc.hash
const q = () => new URLSearchParams(loc.search)
return { loc, hist, writes, at, q, listeners }
}
const unset = () => { for (const k of ['location', 'history', 'addEventListener', 'removeEventListener']) delete globalThis[k] }
async function arms({ va, sv, counts }) {
const fails = []
const eq = (tag, got, want) => { if (JSON.stringify(got) !== JSON.stringify(want)) fails.push(`${tag}: got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`) }
try {
// ── A. pure helpers ──
eq('A: a plain id is kept', va.cleanKey('regions-dashboard'), 'regions-dashboard')
eq('A: a 64-char id is kept', va.cleanKey('a'.repeat(64)), 'a'.repeat(64))
for (const bad of ['a'.repeat(65), '<b>', 'a b', '-lead', '../x', 'x?y', 'x#y', '']) {
if (va.cleanKey(bad) !== '') fails.push(`A: the whitelist lets ${JSON.stringify(bad.slice(0, 20))} through`)
}
eq('A: an unknown or malformed value reads as absent', va.readViewKeys('?tab=a%20b&pane=%E0%A4%A&place=angola&tab=second'), { tab: '', pane: '', place: 'angola' })
eq('A: a tab change drops pane and place', va.viewSearch('?tab=a&pane=b&place=c&lang=ar', { tab: 'd' }), '?tab=d&lang=ar')
eq('A: a pane change drops place, keeps tab', va.viewSearch('?tab=a&pane=b&place=c', { pane: 'e' }), '?tab=a&pane=e')
eq('A: a place change keeps tab and pane', va.viewSearch('?tab=a&pane=b&place=c', { place: 'f' }), '?tab=a&pane=b&place=f')
eq('A: a change that sets the deeper level keeps it', va.viewSearch('?tab=a&pane=b&place=c', { tab: 'd', pane: 'p' }), '?tab=d&pane=p')
eq('A: the same tab changes nothing', va.viewSearch('?tab=a&pane=b&place=c', { tab: 'a' }), '?tab=a&pane=b&place=c')
eq('A: foreign params keep their exact text', va.viewSearch('?lang=ar&access=yes%7Cpartial&q=rice+%26+beans&tab=a', { tab: 'b' }), '?tab=b&lang=ar&access=yes%7Cpartial&q=rice+%26+beans')
eq('A: removing the last key leaves no "?"', va.viewSearch('?tab=a', { tab: '' }), '')
eq('A: urlOwner', ['app', 'host', 'App', true, undefined, 'partner'].map((o) => va.urlOwnerOf({ urlOwner: o })), ['app', 'host', '', '', '', ''])
// ── B. writes ──
{
const b = browser('https://x.test/deep-research?lang=ar#top', { back: '/', current: '/deep-research?lang=ar#top', position: 3 })
const r = va.writeViewKeys({ tab: 'regions' }, { owner: 'app', push: true })
if (r !== 'written' || b.writes.push !== 1) fails.push(`B: an app owner's tab change is not ONE push (${r}, ${b.writes.push} pushes)`)
if (b.at() !== '/deep-research?tab=regions&lang=ar#top') fails.push(`B: the write lost the hash or a foreign param: ${b.at()}`)
const s = b.hist.state || {}
if (s.position !== 3 || s.back !== '/') fails.push(`B: history.state was not kept: ${JSON.stringify(s)}`)
if (s.current !== b.at()) fails.push(`B: history.state.current is not the new URL (${s.current}): a Vue Router host loses the link`)
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')
}
{
const b = browser('https://partner.test/page?tab=theirs')
for (const owner of ['', undefined, 'partner']) va.writeViewKeys({ tab: 'regions' }, { owner, push: true })
if (b.writes.push + b.writes.replace) fails.push(`B: a partner page (no owner) was written ${b.writes.push + b.writes.replace}×`)
}
{
const b = browser('https://dx.test/deep-research?lang=ar')
const el = new EventTarget(); const got = []
el.addEventListener(va.ADDRESS_EVENT, (e) => got.push(e))
const r = va.writeViewKeys({ tab: 'posters' }, { owner: 'host', push: true, el })
if (b.writes.push + b.writes.replace) fails.push("B: a 'host' owner wrote history itself (the host's router must)")
if (r !== 'announced' || got.length !== 1) fails.push(`B: a 'host' owner did not hand the host ONE ${va.ADDRESS_EVENT} (${r}, ${got.length})`)
else {
const e = got[0]
if (!e.bubbles || !e.composed) fails.push(`B: ${va.ADDRESS_EVENT} cannot leave a shadow root (bubbles ${e.bubbles}, composed ${e.composed})`)
if (e.detail?.keys?.tab !== 'posters' || e.detail?.push !== true || e.detail?.href !== '/deep-research?tab=posters&lang=ar') fails.push(`B: ${va.ADDRESS_EVENT} detail is wrong: ${JSON.stringify(e.detail)}`)
}
// the host has not navigated yet; once it has, the same choice again must not ask it to navigate twice
b.hist.replaceState(b.hist.state, '', '/deep-research?tab=posters&lang=ar'); b.writes.replace--
const r2 = va.writeViewKeys({ tab: 'posters' }, { owner: 'host', push: true, el })
if (r2 !== 'unchanged' || got.length !== 1) fails.push(`B: a 'host' owner announced an unchanged address (${r2}, ${got.length} events)`)
}
{
browser('https://x.test/p')
const el = new EventTarget(); const got = []
el.addEventListener(va.VIEW_EVENT, (e) => got.push(e))
va.announceView(el, 'pane', 'dashboard')
if (va.announceView(el, 'filters', 'x') !== false) fails.push('B: announceView accepts a key that is not tab/pane/place')
if (got.length !== 1 || !got[0].bubbles || !got[0].composed || got[0].detail?.key !== 'pane' || got[0].detail?.value !== 'dashboard') fails.push(`B: a nested element's ${va.VIEW_EVENT} is wrong or cannot leave its shadow root`)
}
// ── C. coexistence with the filters, and Back/Forward ──
{
const b = browser('https://x.test/board?tab=a&pane=b&place=c#top', { current: '/board?tab=a&pane=b&place=c#top', position: 2 })
sv.writeFilterView({ access_level: 'yes' }, 'rice', { mode: 'query', facetKeys: { access: 'access_level' } })
const q = b.q()
if (q.get('tab') !== 'a' || q.get('pane') !== 'b' || q.get('place') !== 'c') fails.push(`C: a filter write (query) dropped tab/pane/place: ${b.at()}`)
if (q.get('access') !== 'yes' || q.get('q') !== 'rice' || b.loc.hash !== '#top') fails.push(`C: a filter write (query) lost its own params or the hash: ${b.at()}`)
if (b.hist.state?.position !== 2 || b.hist.state?.current !== b.at()) fails.push(`C: a filter write dropped the router's history.state or its current: ${JSON.stringify(b.hist.state)}`)
if (b.writes.push) fails.push('C: a filter write pushed (filters replace)')
}
{
const b = browser('https://x.test/research?tab=a&pane=b')
sv.writeFilterView({ region: 'asia' }, '', {})
if (b.loc.search !== '?tab=a&pane=b' || !b.loc.hash.startsWith('#f=region:asia')) fails.push(`C: a filter write (hash) dropped tab/pane/place: ${b.at()}`)
}
{
const b = browser('https://x.test/board?access=yes&q=rice#f=region:asia')
va.writeViewKeys({ tab: 'map' }, { owner: 'app', push: true })
const q = b.q()
if (q.get('access') !== 'yes' || q.get('q') !== 'rice' || b.loc.hash !== '#f=region:asia') fails.push(`C: a tab write dropped the filters: ${b.at()}`)
va.writeViewKeys({ place: 'angola' }, { owner: 'app', push: true })
if (b.q().get('access') !== 'yes' || b.loc.hash !== '#f=region:asia') fails.push(`C: a place write dropped the filters: ${b.at()}`)
}
{
// A shell with the filters inside: first paint, a filter, a tab change, another filter, then Back and Forward.
// The app re-applies the tab on popstate the way an owner does: it sets its tab, and its tab watcher writes.
const b = browser('https://x.test/deep-research')
const app = { tab: '' }
const setTab = (t, push) => { app.tab = t; va.writeViewKeys({ tab: t }, { owner: 'app', push }) }
const off = va.onAddressChange((keys) => setTab(keys.tab || 'home', true))
setTab('a', false)
sv.writeFilterView({ access_level: 'yes' }, '', { mode: 'query', facetKeys: { access: 'access_level' } })
setTab('b', true)
sv.writeFilterView({ access_level: 'no' }, '', { mode: 'query', facetKeys: { access: 'access_level' } })
const len = b.hist.length; const w = b.writes.push + b.writes.replace
b.hist.back()
if (b.q().get('tab') !== 'a' || b.q().get('access') !== 'yes') fails.push(`C: after Back the entry lost its tab or its filters: ${b.at()}`)
if (app.tab !== 'a') fails.push('C: Back did not re-apply the tab')
if (b.writes.push + b.writes.replace !== w) fails.push('C: re-applying the tab after Back WROTE history (Forward is lost)')
b.hist.forward()
if (b.hist.length !== len || b.q().get('tab') !== 'b' || b.q().get('access') !== 'no') fails.push(`C: after Forward the entry lost its tab or its filters, or Forward was gone: ${b.at()} (${b.hist.length}/${len})`)
off()
if (b.listeners.size) fails.push('C: onAddressChange off() left its popstate listener')
}
} finally { unset() }
// ── D. the census ──
for (const [file, n] of Object.entries(counts)) {
if (!KNOWN[file]) fails.push(`D: a new history writer: ${file} (${n}). Route it through useViewAddress (commitUrl), or list it in KNOWN with its reason`)
else if (KNOWN[file][0] !== n) fails.push(`D: ${file} now has ${n} history writes, the census says ${KNOWN[file][0]}`)
}
for (const file of Object.keys(KNOWN)) if (!counts[file]) fails.push(`D: ${file} is listed as a writer but no longer writes: drop its row`)
return fails
}
const va = await import(join(ROOT, 'library/composables/useViewAddress.js'))
const sv = await import(join(ROOT, 'library/composables/useShareableView.js'))
const counts = census()
const real = { va, sv, counts }
let code = 0
if (process.argv.includes('--self-test')) {
// The old filter write, as it was: its own replaceState(null, …) of the whole URL.
const oldFilterWrite = (fs, term, o = {}) => {
const url = o.mode === 'query' ? sv.serializeQuery(fs, term, location.search, o) + location.hash : location.search + sv.serializeView(fs, term, o)
history.replaceState(null, '', location.pathname + url); return true
}
const poisons = [
['a write that drops history.state.current', { ...real, va: { ...va, writeViewKeys: (c, o) => { const s = va.viewSearch(location.search, c); history[o.push ? 'pushState' : 'replaceState']({ ...history.state }, '', location.pathname + s + location.hash); return 'written' } } }, /^B: history\.state\.current/],
['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/],
['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/],
// the three coexistence poisons
['a FILTER write that drops tab/pane/place (no foreign params kept)', { ...real, sv: { ...sv, writeFilterView: (fs, term, o = {}) => va.commitUrl(o.mode === 'query' ? { search: sv.serializeQuery(fs, term, '', o) } : { search: '', hash: sv.serializeView(fs, term, o) }) } }, /^C: a filter write \((query|hash)\) dropped tab\/pane\/place/],
['the old filter write: its own replaceState(null) (the router state is wiped)', { ...real, sv: { ...sv, writeFilterView: oldFilterWrite } }, /^C: a filter write dropped the router's history\.state/],
['a TAB write that drops the filters (foreign params and hash)', { ...real, va: { ...va, writeViewKeys: (c, o) => { if (o.owner !== 'app') return va.writeViewKeys(c, o); const k = va.readViewKeys(va.viewSearch(location.search, c)); const s = va.VIEW_KEYS.filter((x) => k[x]).map((x) => `${x}=${k[x]}`).join('&'); return va.commitUrl({ search: s ? '?' + s : '', hash: '' }, { push: o.push }) ? 'written' : 'unchanged' } } }, /^C: a (tab|place) write dropped the filters/],
['a tab change written as a REPLACE (Back leaves the page)', { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, push: false }) } }, /^B: an app owner's tab change is not ONE push|^C: after Back/],
['a popstate handler that writes the address', { ...real, va: { ...va, onAddressChange: (cb) => va.onAddressChange((k) => { cb(k); history.pushState(history.state, '', location.href) }) } }, /^C: re-applying the tab after Back WROTE|^C: after Forward/],
// the census
['a new history writer in the tree', { ...real, counts: { ...counts, 'plugin-designs/some-plugin/page.vue': 1 } }, /^D: a new history writer/],
['useShareableView writing history itself again', { ...real, counts: { ...counts, 'library/composables/useShareableView.js': 1 } }, /^D: a new history writer: library\/composables\/useShareableView\.js/],
]
for (const [name, inp, rx] of poisons) {
const f = await arms(inp); const ok = f.some((x) => rx.test(x))
console.log(ok ? `✓ caught: ${name} -> ${f.find((x) => rx.test(x))}` : `✗ MISSED: ${name}${f.length ? ' (failed instead: ' + f[0] + ')' : ''}`)
if (!ok) code = 1
}
} else {
const f = await arms(real)
if (f.length) { f.forEach((x) => console.log('FAIL ' + x)); code = 1 }
else console.log(`PASS check:view-address — the id whitelist and the level rule; one push per tab change with history.state + current kept; nothing written when unchanged, on a partner page, or by a 'host' owner (one dxm-address instead); filters and tab/pane/place keep each other through writes, Back and Forward; ${Object.keys(counts).length} history writers in the tree, all accounted for`)
}
process.exit(code)