diff --git a/internal/check-view-address.mjs b/internal/check-view-address.mjs index e38ea660b..d8f312f39 100644 --- a/internal/check-view-address.mjs +++ b/internal/check-view-address.mjs @@ -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 } diff --git a/library/composables/useViewAddress.js b/library/composables/useViewAddress.js index a050c6cf4..704b01dbd 100644 --- a/library/composables/useViewAddress.js +++ b/library/composables/useViewAddress.js @@ -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) diff --git a/library/layouts/pane-wrapper/pane-wrapper.js b/library/layouts/pane-wrapper/pane-wrapper.js index 81e878ae4..331b31cff 100644 --- a/library/layouts/pane-wrapper/pane-wrapper.js +++ b/library/layouts/pane-wrapper/pane-wrapper.js @@ -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) }, diff --git a/plugin-designs/regions-dashboard/region-dashboards.vue b/plugin-designs/regions-dashboard/region-dashboards.vue index a0b3c8ccb..bc6436553 100644 --- a/plugin-designs/regions-dashboard/region-dashboards.vue +++ b/plugin-designs/regions-dashboard/region-dashboards.vue @@ -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