P2: studio's 5B notes. A nested element cannot switch the owner's tab, a kept-alive pane or place is claimed back when its tab is shown again, and scrollTo takes null

- ownAddress records only pane and place. tab is the owner's own key, so a nested announcement cannot switch it.
- A shell keeps Regions (and UPG100) alive when another tab is open. Switching back dropped ?pane= and ?place=
  (the tab change), while the screen still showed them. pane-wrapper and region-dashboards now watch for being
  shown again (a ResizeObserver: no height, then height) and claim their pane / place back with a replace. An
  address that names one wins, because Back to an entry of this tab is applied while the column has no layout.
- scrollTo(id, null) behaves as scrollTo(id).

check:view-address covers each of these (34 poisons, each caught). The slow pane-wrapper arm runs only for
pane-wrapper poisons, so the self-test takes 16 s.
This commit is contained in:
Doxa Maps 2026-10-03 06:26:53 +00:00
parent 003af1caa8
commit c963a2a9fa
4 changed files with 89 additions and 13 deletions

View file

@ -19,7 +19,8 @@
// F. pane-wrapper's opt-in address, on a simulated column: a link opens its pane without writing; a click is a push
// (the first pane is the default and is never written); a click's selection rides in the same push; where the
// reader's scroll settles is a replace; Back/Forward scroll and never write; nested it announces; without the
// opt-in it never writes.
// opt-in it never writes; a kept-alive column shown again claims its pane back (a replace), unless the address
// names one.
// G. THE WIRING in Regions: regions-mfe opts its column in, owns its leaf, and carries the place with its pane;
// region-dashboards claims ?place= (replace at first paint, push on a choice) and re-applies it on Back.
// --self-test: each poison below must FAIL its arm. Pure node: no Vite, no build, no browser.
@ -98,9 +99,9 @@ class FakeEl extends EventTarget {
scrollTo({ top }) { this.scrollTop = top; this.dispatchEvent(new Event('scroll')) }
}
const SPEC = { paneHint: false, panes: [{ id: 'list', label: 'List', at: [0, 0] }, { id: 'dashboard', label: 'Dashboard', at: [1, 0] }] }
const settle = () => new Promise((r) => setTimeout(r, 160))
const settle = () => new Promise((r) => setTimeout(r, 125)) // just past the 120 ms debounce; an earlier-due timer always fires first
async function arms({ va, sv, counts, pw, src }) {
async function arms({ va, sv, counts, pw, src }, { skipF = false } = {}) {
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 {
@ -235,15 +236,19 @@ async function arms({ va, sv, counts, pw, src }) {
const e2 = fire({ place: 'benin' })
if (b.writes.push !== 1 || b.writes.replace) fails.push('E: ownAddress wrote while the page has no owner')
if (e2.cancelBubble) fails.push('E: an announcement an element does not own was stopped (the real owner further out never hears it)')
owner = 'app'; off(); fire({ place: 'benin' })
owner = 'app'
const before = b.at()
fire({ tab: 'posters' })
if (b.at() !== before) fails.push(`E: a nested announcement switched the OWNER's tab: ${b.at()}`)
off(); fire({ place: 'benin' })
if (b.writes.push !== 1) fails.push('E: ownAddress off() left its listener')
b.hist.back()
if (b.at() !== '/shell?tab=regions&lang=ar') fails.push(`E: Back after a pane + place click did not return in one step: ${b.at()}`)
}
// ── F. pane-wrapper's address, on a simulated column ──
// ── F. pane-wrapper's address, on a simulated column (the slow arm: it waits out the scroll debounce) ──
globalThis.document = { createElement: () => new FakeEl() }
{
if (!skipF) {
const b = browser('https://x.test/regions-mfe?pane=dashboard&lang=ar')
const host = new FakeEl(); const got = []
host.addEventListener(va.VIEW_EVENT, (e) => got.push(e))
@ -268,7 +273,33 @@ async function arms({ va, sv, counts, pw, src }) {
if (b.listeners.size) fails.push('F: destroy() left its popstate listener')
if (got.length) fails.push('F: an owner composite announced instead of writing')
}
{
if (!skipF) {
// A KEPT-ALIVE column whose tab is hidden and shown again (ResizeObserver: no height, then height).
const ros = []
globalThis.ResizeObserver = class { constructor(cb) { this.cb = cb; ros.push(this) } observe() { this.cb([]) } disconnect() { this.off = true } }
const b = browser('https://x.test/shell?tab=regions')
const host = new FakeEl()
const c = pw.buildPaneColumn(SPEC, { host, address: { owner: () => 'app' } })
const col = host.children[0]
const resize = async (hgt) => { col.clientHeight = hgt; ros.forEach((r) => !r.off && r.cb([])); await settle() }
c.scrollTo('dashboard', null); await settle()
if (b.at() !== '/shell?tab=regions&pane=dashboard') fails.push(`F: scrollTo(id, null) did not behave as scrollTo(id): ${b.at()}`)
await resize(0)
va.writeViewKeys({ tab: 'posters' }, { owner: 'app' }); va.writeViewKeys({ tab: 'regions' }, { owner: 'app' })
const w = b.writes.push + b.writes.replace
await resize(100)
if (b.at() !== '/shell?tab=regions&pane=dashboard' || b.writes.push + b.writes.replace !== w + 1 || b.writes.replace < 1) fails.push(`F: a kept-alive column shown again did not claim its pane back with ONE replace: ${b.at()}`)
await resize(0)
col.scrollTop = 0
va.commitUrl({ search: '?tab=regions&pane=dashboard' })
const w2 = b.writes.push + b.writes.replace
await resize(100)
if (col.scrollTop !== 100 || b.writes.push + b.writes.replace !== w2) fails.push(`F: shown again with an address that names a pane, the column did not show it (or wrote): scrollTop ${col.scrollTop}`)
c.destroy()
if (!ros.every((r) => r.off)) fails.push('F: destroy() left its ResizeObserver')
delete globalThis.ResizeObserver
}
if (!skipF) {
const b = browser('https://x.test/shell?tab=regions')
const host = new FakeEl(); const got = []
host.addEventListener(va.VIEW_EVENT, (e) => got.push(e))
@ -278,7 +309,7 @@ async function arms({ va, sv, counts, pw, src }) {
if (!got.length || got[0].detail?.changes?.pane !== 'dashboard' || got[0].detail?.push !== true) fails.push('F: a nested composite did not announce its pane to the owner')
c.destroy()
}
{
if (!skipF) {
const b = browser('https://x.test/plain?pane=dashboard')
const host = new FakeEl(); const got = []
host.addEventListener(va.VIEW_EVENT, (e) => got.push(e))
@ -299,6 +330,7 @@ async function arms({ va, sv, counts, pw, src }) {
if (!/claimView\(\{ place: currentSlug\(\) \}/.test(d)) fails.push('G: region-dashboards does not claim ?place=')
if (!/claimPlace\(false\)/.test(d) || !/claimPlace\(true\)/.test(d)) fails.push('G: region-dashboards does not replace at first paint and push on a choice')
if (!/onAddressChange\(onAddress\)/.test(d)) fails.push('G: region-dashboards does not re-apply ?place= on Back/Forward')
if (!/new ResizeObserver\([\s\S]{0,400}else if \(!place\) claimPlace\(false\)/.test(d)) fails.push('G: a kept-alive region-dashboards shown again does not claim its place back')
}
// ── D. the census ──
@ -363,12 +395,20 @@ if (process.argv.includes('--self-test')) {
['pane-wrapper writing on Back (the pane it is leaving)', { ...real, pw: await pwVariant("show(known(pane) ? pane : column[0].id, 'smooth')", "(claimPane(current(), true), show(known(pane) ? pane : column[0].id, 'smooth'))") }, /^F: Back did not show the address's pane, or it WROTE/],
['pane-wrapper ignoring a link', { ...real, pw: await pwVariant("if (known(asked)) show(asked, 'instant')", 'void asked') }, /^F: a link to \?pane=dashboard did not open/],
['pane-wrapper writing without the opt-in', { ...real, pw: await pwVariant("=> address && claimView({ pane: id === column[0].id ? '' : id, ...changes }, { owner: address.owner(),", "=> claimView({ pane: id === column[0].id ? '' : id, ...changes }, { owner: address ? address.owner() : 'app',") }, /^F: a column WITHOUT the address opt-in/],
['ownAddress that lets a nested element switch the tab', { ...real, va: { ...va, ownAddress: (el, g) => { const h = (e) => { const o = g(); if (o) { e.stopPropagation(); va.writeViewKeys(e.detail.changes, { owner: o, el, push: e.detail.push }) } }; el.addEventListener(va.VIEW_EVENT, h); return () => el.removeEventListener(va.VIEW_EVENT, h) } } }, /^E: a nested announcement switched the OWNER's tab/],
['pane-wrapper not claiming its pane when shown again', { ...real, pw: await pwVariant("if (typeof ResizeObserver !== 'undefined') {", 'if (false) {') }, /^F: a kept-alive column shown again/],
['pane-wrapper letting the screen beat an address that names a pane', { ...real, pw: await pwVariant('if (known(asked) && asked !== current()) show(asked, \'instant\')\n else if (!asked) claimPane', 'if (false) show(asked, \'instant\')\n else claimPane') }, /^F: shown again with an address that names a pane/],
['pane-wrapper whose scrollTo(id, null) throws (the old default)', { ...real, pw: await pwVariant('const { write = true, push = true, changes = {} } = o || {}', 'const { write = true, push = true, changes = {} } = o === undefined ? {} : o') }, /^F: scrollTo\(id, null\)|^THREW: .*null/],
['region-dashboards not claiming its place when shown again', { ...real, src: { ...src, dash: src.dash.replace('else if (!place) claimPlace(false)', 'else if (!place) void 0') } }, /^G: a kept-alive region-dashboards shown again/],
['regions-mfe opening the dashboard without its place', { ...real, src: { ...src, mfe: src.mfe.replace("go('dashboard', { place: detail.countrySlug || detail.regionSlug || '' })", "go('dashboard')") } }, /^G: regions-mfe opens the dashboard without its place/],
['regions-mfe without the address opt-in', { ...real, src: { ...src, mfe: src.mfe.replace('{ host: stage.value, address: { owner } }', '{ host: stage.value }') } }, /^G: regions-mfe does not opt its column in/],
['region-dashboards not re-applying ?place= on Back', { ...real, src: { ...src, dash: src.dash.replace('onAddressChange(onAddress)', 'null') } }, /^G: region-dashboards does not re-apply/],
]
for (const [name, inp, rx] of poisons) {
const f = await arms(inp); const ok = f.some((x) => rx.test(x))
let f
// Only a pane-wrapper poison needs the slow arm F; every other poison must fail its own, faster arm.
try { f = await arms(inp, { skipF: !/^\/\^F|THREW/.test(String(rx)) }) } catch (e) { f = ['THREW: ' + e.message] }
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
}

View file

@ -117,14 +117,16 @@ export function announceView(el, changes, { push = true } = {}) {
export const claimView = (changes, { owner = '', el = null, push = true } = {}) =>
owner ? writeViewKeys(changes, { owner, el, push }) : (announceView(el, changes, { push }) ? 'announced' : 'none')
/** An owner records what the elements inside it announce. getOwner() is read per event (a host may set profile-config
* after mount); while it is '' the event passes on to an owner further out. Returns off(). */
/** An owner records what the elements inside it announce: their pane and place. `tab` is the owner's own key, so a
* nested element cannot switch it. getOwner() is read per event (a host may set profile-config after mount); while
* it is '' the event passes on to an owner further out. Returns off(). */
export function ownAddress(el, getOwner) {
const h = (e) => {
const owner = getOwner()
if (!owner || !e.detail) return
e.stopPropagation()
writeViewKeys(viewChanges(e.detail.changes), { owner, el, push: e.detail.push !== false })
const { tab, ...changes } = viewChanges(e.detail.changes)
if (Object.keys(changes).length) writeViewKeys(changes, { owner, el, push: e.detail.push !== false })
}
el?.addEventListener?.(VIEW_EVENT, h)
return () => el?.removeEventListener?.(VIEW_EVENT, h)

View file

@ -198,6 +198,22 @@ export function buildPaneColumn(spec, { host, mountPane, address }) {
col.addEventListener('scroll', settle, { passive: true })
offs.push(() => { if (t) clearTimeout(t); col.removeEventListener('scroll', settle) })
offs.push(onAddressChange(({ pane }) => show(known(pane) ? pane : column[0].id, 'smooth')))
// A KEPT-ALIVE column shown again (a shell switched back to its tab: no height, then height). The tab change
// dropped ?pane= while the column kept its pane, so the screen claims it again (a replace). An address that names
// a pane wins: Back to an entry of this tab is applied while the column had no layout to scroll.
if (typeof ResizeObserver !== 'undefined') {
let h = null
const ro = new ResizeObserver(() => {
const was = h
h = col.clientHeight
if (was !== 0 || !h) return
const asked = readViewKeys(location.search).pane
if (known(asked) && asked !== current()) show(asked, 'instant')
else if (!asked) claimPane(current(), false)
})
ro.observe(col)
offs.push(() => ro.disconnect())
}
}
return {
@ -210,7 +226,8 @@ export function buildPaneColumn(spec, { host, mountPane, address }) {
// the scroll position that puts the pane at the top.
// With `address`, the reader's choice is also written (a push). `changes` rides along in the SAME write, so a
// click that opens a pane AND selects in it is one Back step. write:false is for a caller re-showing a pane.
scrollTo(paneId, { write = true, push = true, changes = {} } = {}) {
scrollTo(paneId, o) {
const { write = true, push = true, changes = {} } = o || {}
show(paneId, 'smooth')
if (write && known(paneId)) claimPane(paneId, push, changes)
},

View file

@ -726,6 +726,7 @@ async function loadRegions() {
watch(languageCode, () => { loadRegions() })
let offAddress = null
let reshown = null
onMounted(async () => {
window.addEventListener(SELECT_EVENT, onSelect)
window.addEventListener(POSTER_WALL_EVENT, onPosterShowOnMap)
@ -741,12 +742,28 @@ onMounted(async () => {
claimPlace(false)
if (legacy && currentSlug() && urlOwnerOf(cfg.value) === 'app') commitUrl({ hash: '' })
syncTitle()
// A KEPT-ALIVE Regions shown again (a shell switched back to its tab: no height, then height). The tab change
// dropped ?place= while this element kept its subject, so the screen claims it again (a replace). An address that
// names a subject wins (Back already applied it).
if (typeof ResizeObserver !== 'undefined' && scrollEl.value) {
let h = null
reshown = new ResizeObserver(() => {
const was = h
h = scrollEl.value?.clientHeight || 0
if (was !== 0 || !h) return
const place = placeInAddress()
if (place && place !== currentSlug()) sel.value = selectionForSlug(place)
else if (!place) claimPlace(false)
})
reshown.observe(scrollEl.value)
}
})
onBeforeUnmount(() => {
window.removeEventListener(SELECT_EVENT, onSelect)
window.removeEventListener(POSTER_WALL_EVENT, onPosterShowOnMap)
offAddress?.()
reshown?.disconnect()
})
// Palette is DX-life material light, matching regions-list.vue tab-for-tab so swapping between