pane-wrapper: a fresh load with ?pane= opens that pane once the column has its layout

Measured in a browser on Country Posters and on Regions: a fresh load with ?pane=<second pane> stayed on the first
pane, with the column's scrollTop 0 at 6 s. The column is built in the composite's onMounted, before it has a
layout, so the pane's offsetTop was 0 when the link was shown. And a ResizeObserver's first callback, which comes
after layout, only recorded the height.

Now the link waits until the column has a height and the pane a top, then shows it once. The wait is the observer's
callback, or a bounded rAF without one. A scroll that settles while the link waits does not overwrite it. The
composite's view readout follows from the scroll event it already listens to.

check:view-address F models a column with no layout at build, whose observer first fires after layout. There are 2
new poisons (42 in all), and restoring the old immediate show in the real file fails the gate.
This commit is contained in:
Doxa Maps 2026-10-03 06:39:09 +00:00
parent 54fdead5e1
commit 7ad84e5610
2 changed files with 49 additions and 9 deletions

View file

@ -16,7 +16,8 @@
// E. ONE WRITER, NESTED: claimView writes for an owner and announces for anyone else (one event per action, view keys
// only); ownAddress records an announcement only while it owns (and stops it there), so a pane and its selection
// clicked together are ONE Back step.
// F. pane-wrapper's opt-in address, on a simulated column: a link opens its pane without writing; a click is a push
// F. pane-wrapper's opt-in address, on a simulated column: a link opens its pane without writing, also on a FRESH
// load where the column is built before its layout (it waits for a height, measured in a browser); 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; a kept-alive column shown again claims its pane back (a replace), unless the address
@ -89,10 +90,11 @@ const unset = () => { for (const k of ['location', 'history', 'addEventListener'
// A column of panes for pane-wrapper, simulated: each pane is 100 px tall, scrolling dispatches 'scroll'.
class FakeEl extends EventTarget {
constructor() { super(); this.className = ''; this.dataset = {}; this.children = []; this.parent = null; this.scrollTop = 0; this.clientHeight = 100; this.innerHTML = '' }
constructor() { super(); this.className = ''; this.dataset = {}; this.children = []; this.parent = null; this.scrollTop = 0; this.clientHeight = FakeEl.h0 ?? 100; this.innerHTML = '' }
appendChild(c) { c.parent = this; this.children.push(c); return c }
remove() { if (this.parent) this.parent.children = this.parent.children.filter((x) => x !== this) }
get offsetTop() { return this.parent ? this.parent.children.filter((c) => c.className === 'pane').indexOf(this) * 100 : 0 }
// A pane's top is its index times the column's height: 0 while the column has no layout.
get offsetTop() { return this.parent ? this.parent.children.filter((c) => c.className === 'pane').indexOf(this) * this.parent.clientHeight : 0 }
querySelector(sel) {
const id = /data-pane-id="([^"]+)"/.exec(sel)?.[1]
const walk = (n) => { for (const c of n.children) { if (c.className === 'pane' && c.dataset.paneId === id) return c; const r = walk(c); if (r) return r } return null }
@ -275,6 +277,25 @@ async function arms({ va, sv, counts, pw, src }, { skipF = false } = {}) {
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 FRESH LOAD: the column is built before it has a layout (measured in a browser), and a ResizeObserver's
// FIRST callback comes after layout, already with the full height. The link must still open.
const ros = []
globalThis.ResizeObserver = class { constructor(cb) { this.cb = cb; ros.push(this) } observe() {} disconnect() { this.off = true } }
const b = browser('https://x.test/country-posters-mfe?pane=dashboard&lang=ar')
const host = new FakeEl()
FakeEl.h0 = 0
const c = pw.buildPaneColumn(SPEC, { host, address: { owner: () => 'app' } })
FakeEl.h0 = undefined
const col = host.children[0]
col.dispatchEvent(new Event('scroll')); await settle()
if (b.at() !== '/country-posters-mfe?pane=dashboard&lang=ar') fails.push(`F: before its layout, the column overwrote the link it has not opened yet: ${b.at()}`)
col.clientHeight = 100; ros.forEach((r) => !r.off && r.cb([])); await settle()
if (col.scrollTop !== 100) fails.push(`F: a FRESH LOAD with ?pane=dashboard did not open that pane once the column had its layout (scrollTop ${col.scrollTop})`)
if (b.writes.push + b.writes.replace) fails.push('F: opening a link on a fresh load wrote the address')
c.destroy()
delete globalThis.ResizeObserver
}
if (!skipF) {
// A KEPT-ALIVE column whose tab is hidden and shown again (ResizeObserver: no height, then height).
const ros = []
@ -419,7 +440,9 @@ if (process.argv.includes('--self-test')) {
["pane-wrapper writing a click's selection separately", { ...real, pw: await pwVariant('if (write && known(paneId)) claimPane(paneId, push, changes)', 'if (write && known(paneId)) { claimPane(paneId, push); if (Object.keys(changes).length) claimView(changes, { owner: address.owner(), el: host, push }) }') }, /^F: a click's pane and its selection are not ONE push/],
['pane-wrapper pushing where a scroll settles', { ...real, pw: await pwVariant('claimPane(current(), false)', 'claimPane(current(), true)') }, /^F: where the reader's scroll settles/],
['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 opening a link at build time only (before the column has a layout)', { ...real, pw: await pwVariant("let pending = known(linked) && linked !== column[0].id ? linked : ''", "let pending = ''; if (known(linked)) show(linked, 'instant')") }, /^F: a FRESH LOAD with \?pane=dashboard did not open/],
['pane-wrapper whose settle overwrites a link still waiting to open', { ...real, pw: await pwVariant('if (col.clientHeight && !pending) claimPane(current(), false)', 'if (!pending || true) claimPane(current(), false)') }, /^F: before its layout, the column overwrote the link/],
['pane-wrapper ignoring a link', { ...real, pw: await pwVariant("let pending = known(linked) && linked !== column[0].id ? linked : ''", "let pending = ''") }, /^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/],

View file

@ -188,13 +188,29 @@ export function buildPaneColumn(spec, { host, mountPane, address }) {
const show = (id, behavior) => { const t = paneEl(id); if (t) col.scrollTo({ top: t.offsetTop, behavior }) }
const offs = []
if (address) {
// A link opens its pane at once. Reading writes nothing, and the default needs no write.
const asked = readViewKeys(typeof location !== 'undefined' ? location.search : '').pane
if (known(asked)) show(asked, 'instant') // 'auto' would take the column's CSS smooth scrolling
// A link opens its pane. Reading writes nothing, and the default needs no write. ★ NOT AT ONCE. MEASURED in a
// browser (a fresh load with ?pane=<second pane>, on Country Posters and Regions): when the column is built it
// has no layout yet, so the pane's offsetTop is 0 and the column stayed on its first pane. The link waits until
// the column has a height and the pane a top, then shows it, once. 'instant': 'auto' would take the column's CSS
// smooth scrolling.
const linked = readViewKeys(typeof location !== 'undefined' ? location.search : '').pane
let pending = known(linked) && linked !== column[0].id ? linked : ''
const openLink = () => {
if (!pending || !col.clientHeight || !(paneEl(pending)?.offsetTop > 0)) return
show(pending, 'instant')
pending = ''
}
openLink()
if (pending && typeof ResizeObserver === 'undefined' && typeof requestAnimationFrame !== 'undefined') {
let n = 0
const tick = () => { openLink(); if (pending && ++n < 120) requestAnimationFrame(tick) }
requestAnimationFrame(tick)
}
// Where the reader's own scroll settles is a REPLACE, so a copied link opens what is on screen without a Back
// step per scroll. A column with no height (a hidden tab) has no "where", so it writes nothing.
// step per scroll. A column with no height (a hidden tab) has no "where", and a link still waiting to open must
// not be overwritten, so neither writes.
let t = null
const settle = () => { if (t) clearTimeout(t); t = setTimeout(() => { if (col.clientHeight) claimPane(current(), false) }, 120) }
const settle = () => { if (t) clearTimeout(t); t = setTimeout(() => { if (col.clientHeight && !pending) claimPane(current(), false) }, 120) }
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')))
@ -204,6 +220,7 @@ export function buildPaneColumn(spec, { host, mountPane, address }) {
if (typeof ResizeObserver !== 'undefined') {
let h = null
const ro = new ResizeObserver(() => {
openLink()
const was = h
h = col.clientHeight
if (was !== 0 || !h) return