Host floor: re-probe only when the host may have changed kind, and never move a scrolled child
The measure-once design missed a host restyled from sized to height:auto after mount WITH in-flow content: it stayed sized at its content height instead of getting the floor back, as the old probe did. - After mount, follow() re-probes only when the reading says the host may have changed kind: floor on and the host shorter than the floor (styled after mount, or capped), or floor off and the host changed size (it may now follow its content). The re-probe saves every scrolled descendant (through shadow roots) and puts it back before the frame paints, so no reader state is lost. A window-height change on a content-sized host still writes nothing. - A host capped below the floor (max-height) is latched by the probe that finds it, until it reaches the floor again, so it is never re-probed in a loop. The 1 s post-flip window is gone. - check:host-floor gains arm H (sized → auto with 420 px of content: the floor returns, at most one flip, the scrolled child keeps its scrollTop) and counts re-probes in D/E. Poisons: the old per-resize probe, no capped latch, no re-check of a sized host, no scroll restore.
This commit is contained in:
parent
cd55cfd877
commit
5362d84bc6
2 changed files with 84 additions and 50 deletions
|
|
@ -2,24 +2,27 @@
|
|||
/**
|
||||
* check-host-floor.mjs — the profile floor is decided against the host WITHOUT disturbing live content.
|
||||
*
|
||||
* library/elements/hostFloor.js (used by ProfileLoader.vue) measures the host ONCE, at mount, and after that only
|
||||
* reads sizes. The regression this pins: the probe ran on EVERY host resize, collapsing the loader under live
|
||||
* content; a scrolling child's scrollTop was clamped to 0 and the embedded UPG-100 app flipped from Map back to its
|
||||
* first view whenever a phone's URL bar resized the host.
|
||||
* library/elements/hostFloor.js (used by ProfileLoader.vue) probes the host at mount, and after that re-probes ONLY
|
||||
* when the host may have changed kind, saving and restoring every scrolled descendant. The regression this pins:
|
||||
* the probe ran on EVERY host resize, collapsing the loader under live content; a scrolling child's scrollTop was
|
||||
* clamped to 0 and the embedded UPG-100 app flipped from Map back to its first view whenever a phone's URL bar
|
||||
* resized the host.
|
||||
*
|
||||
* A stub DOM models the layout that matters: the host's height from its mode and the loader's min-height, the
|
||||
* floor (100svh) as a number the test moves like a window resize, and a scrolled CHILD whose scrollTop clamps to 0
|
||||
* whenever the loader collapses.
|
||||
* A. after mount, host resizes (a content-sized host following a window resize; a sized host) never move the
|
||||
* child's scrollTop and never write the loader's min-height;
|
||||
* A. after mount, a content-sized host following a window resize, and a sized host that keeps its size, never
|
||||
* write the loader's min-height nor move the child's scrollTop;
|
||||
* B. a sized host (523 px) is filled: the floor goes off at mount;
|
||||
* C. a host styled SHORTER than the floor after mount flips to sized ONCE, then stays;
|
||||
* D. a host capped by max-height (600 < floor) is found at mount and never flips: 0 flips over 20 resizes;
|
||||
* E. a max-height cap that arrives AFTER mount flips at most once and reverts once, then stays (no loop);
|
||||
* F. a sized host that collapses below 1px gets the floor back;
|
||||
* D. a host capped by max-height (600 < floor) is found at mount: 0 flips and 0 re-probes over 20 resizes;
|
||||
* E. a max-height cap that arrives AFTER mount: at most 1 flip and 1 re-probe over 20 resizes, ends content-sized;
|
||||
* F. a sized host restyled to height:auto with NO content gets the floor back;
|
||||
* H. a sized host restyled to height:auto WITH in-flow content (420) gets the floor back: at most 1 flip over 20
|
||||
* resizes, and the scrolled child keeps its scrollTop through the re-probe;
|
||||
* G. an inline host is left alone after mount.
|
||||
* `--self-test`: the old per-resize probe (A fails: the child's scrollTop is clamped) and a missing latch (E
|
||||
* loops) must each FAIL.
|
||||
* `--self-test`: the old per-resize probe (A), a missing capped latch (D/E re-probe in a loop), a missing re-check
|
||||
* of a sized host (H stays sized) and a missing scroll restore (H loses the child's scrollTop) must each FAIL.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
|
|
@ -58,6 +61,7 @@ function world({ mode = 'content', fixed = 523, cap = 600, content = 0, floor =
|
|||
w.loader = loader; w.host = host
|
||||
w.style = (n) => (n === host ? { display: w.display, minHeight: '0px' } : { display: 'block', minHeight: (loader.style._min === '' ? w.floor : parseFloat(loader.style._min)) + 'px' })
|
||||
w.sized = () => attrs.has('data-host-sized')
|
||||
w.probes = () => w.writes.filter((v) => v === '100000px').length
|
||||
return w
|
||||
}
|
||||
|
||||
|
|
@ -65,7 +69,7 @@ async function audit(src) {
|
|||
const { createHostFloor } = await asModule(src)
|
||||
const fails = []
|
||||
let t = 0
|
||||
const make = (opts) => { const w = world(opts); w.f = createHostFloor(w.loader, w.host, { style: w.style, now: () => t }); return w }
|
||||
const make = (opts) => { const w = world(opts); w.f = createHostFloor(w.loader, w.host, { style: w.style, scrolled: () => (w.child.scrollTop ? [w.child] : []) }); return w }
|
||||
|
||||
// A: content-sized host, a window resize moves the floor and the host with it
|
||||
{ const w = make({ mode: 'content', content: 0 }); w.f.probeOnce(); w.child.scrollTop = 787; w.writes.length = 0
|
||||
|
|
@ -87,12 +91,21 @@ async function audit(src) {
|
|||
// D
|
||||
{ const w = make({ mode: 'capped', cap: 600, content: 0 }); w.f.probeOnce(); let flips = 0, was = w.sized()
|
||||
for (let i = 0; i < 20; i++) { t += 50; w.f.follow(); if (w.sized() !== was) { flips++; was = w.sized() } }
|
||||
if (flips !== 0 || w.sized()) fails.push(`D: a max-height host flipped ${flips} time(s) (it must be found at mount and left content-sized)`) }
|
||||
const probes = w.probes() - 1
|
||||
if (flips !== 0 || w.sized() || probes > 0) fails.push(`D: a max-height host flipped ${flips} time(s) / was re-probed ${probes} time(s) (it must be found at mount and left alone)`) }
|
||||
// E
|
||||
{ const w = make({ mode: 'content', content: 0 }); w.f.probeOnce(); let flips = 0, was = w.sized()
|
||||
{ const w = make({ mode: 'content', content: 0 }); w.f.probeOnce(); let flips = 0, was = w.sized(); const p0 = w.probes()
|
||||
w.mode = 'capped'; w.cap = 600
|
||||
for (let i = 0; i < 20; i++) { t += 50; w.f.follow(); if (w.sized() !== was) { flips++; was = w.sized() } }
|
||||
if (flips > 2 || w.sized()) fails.push(`E: a max-height cap that arrived after mount LOOPS or stays sized (flips ${flips}, sized ${w.sized()})`) }
|
||||
const probes = w.probes() - p0
|
||||
if (flips > 1 || w.sized() || probes > 1) fails.push(`E: a max-height cap that arrived after mount LOOPS or stays sized (flips ${flips}, re-probes ${probes}, sized ${w.sized()})`) }
|
||||
// H: sized → height:auto with in-flow content
|
||||
{ const w = make({ mode: 'sized', fixed: 523 }); w.f.probeOnce(); w.child.scrollTop = 300; let flips = 0, was = w.sized()
|
||||
w.mode = 'content'; w.content = 420
|
||||
for (let i = 0; i < 20; i++) { t += 50; w.f.follow(); if (w.sized() !== was) { flips++; was = w.sized() } }
|
||||
if (w.sized()) fails.push('H: a sized host restyled to height:auto with content (420) stayed sized (the floor did not come back)')
|
||||
if (flips > 1) fails.push(`H: the sized → auto host flipped ${flips} times`)
|
||||
if (w.child.scrollTop !== 300) fails.push(`H: the re-probe moved a scrolled child (scrollTop 300 → ${w.child.scrollTop})`) }
|
||||
// F
|
||||
{ const w = make({ mode: 'sized', fixed: 523 }); w.f.probeOnce(); w.mode = 'content'; w.content = 0; w.f.follow()
|
||||
// with the floor off and no content the host is 0 high; follow() must bring the floor back
|
||||
|
|
@ -106,8 +119,10 @@ async function audit(src) {
|
|||
const src = readFileSync(SRC, 'utf8')
|
||||
if (process.argv.includes('--self-test')) {
|
||||
const poisons = [
|
||||
['the old per-resize probe (follow() re-measures)', src.replace(/function follow\(\) \{/, 'function follow() { probeOnce(); return;'), /^A: /],
|
||||
['no latch for a host capped after mount', src.replace('set(false); flipH = null; capped = true; return', 'set(false); flipH = null; return'), /^E: /],
|
||||
['the old per-resize probe (follow() re-measures, no restore)', src.replace(/function follow\(\) \{/, 'function follow() { decide(); return;'), /^A: /],
|
||||
['no latch for a capped host', src.replace('if (capped) { if (h + 1 >= f) capped = false }\n else if', 'if'), /^[DE]: /],
|
||||
['no re-check of a sized host', src.replace('if (lastH === null || Math.abs(h - lastH) >= 1) reprobe()', '/* no re-check */'), /^H: .*stayed sized/],
|
||||
['no scroll restore in the re-probe', src.replace("for (const [e, top, left] of kept) { if (e.scrollTop !== top) e.scrollTop = top; if (e.scrollLeft !== left) e.scrollLeft = left }", ''), /^H: the re-probe moved/],
|
||||
]
|
||||
let missed = 0
|
||||
for (const [name, poisoned, rx] of poisons) {
|
||||
|
|
@ -120,5 +135,5 @@ if (process.argv.includes('--self-test')) {
|
|||
} else {
|
||||
const f = await audit(src)
|
||||
if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:host-floor (${f.length})`); process.exitCode = 1 }
|
||||
else console.log('PASS check:host-floor — measured once at mount; host resizes never collapse the loader or move a scrolled child; a sized host fills; late sizing flips once; a max-height cap never loops; a collapsed host gets the floor back; inline hosts are left alone')
|
||||
else console.log('PASS check:host-floor — probed at mount; after it, re-probed only on a change of kind and without moving a scrolled child; a window resize writes nothing; a sized host fills; late sizing flips once; a max-height cap never loops; a sized host restyled to auto (with or without content) gets the floor back; inline hosts are left alone')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,57 +5,76 @@
|
|||
* scroll-page leaf; BUG-UPG100). A host that is SIZED from outside (DX Life's map slot: a definite height, overflow
|
||||
* hidden) must be filled exactly: there the floor made the map taller than its host and clipped its legend.
|
||||
*
|
||||
* ★ MEASURED ONCE, THEN ONLY FOLLOWED. Which kind of host this is cannot be read from CSS, so probeOnce() measures it
|
||||
* at mount (the floor at 0, then at a huge value: a sized host keeps its height, a content-sized one grows), before
|
||||
* the profile has rendered. It must never run again: re-measuring on every host resize collapsed the loader under
|
||||
* live content, clamped a scrolling child's scrollTop, and the embedded UPG-100 app (its view follows its column's
|
||||
* scroll) flipped from Map back to its first view whenever a phone's URL bar resized the host. After mount, follow()
|
||||
* only READS sizes and flips one value:
|
||||
* - floor on: a block host SHORTER than the floor can only be sized from outside, so the floor goes off (a host
|
||||
* styled after mount);
|
||||
* - floor off: a block host that collapsed below 1px has lost its size, so the floor comes back;
|
||||
* - a host CAPPED below the floor (max-height) follows its content up to the cap. It is found at mount (probeOnce)
|
||||
* or, if the cap arrives later, by the host shrinking right after a flip; either way it is latched and never
|
||||
* flipped again, so it cannot loop;
|
||||
* - a window-height change on a content-sized host moves the host WITH the floor: nothing changes.
|
||||
* check:host-floor pins all of this, with the old per-resize probe and a missing latch as poisons.
|
||||
* ★ PROBED AT MOUNT; AFTER THAT, ONLY WHEN THE HOST CHANGES KIND, AND WITHOUT A TRACE. Which kind of host this is
|
||||
* cannot be read from CSS, so it is MEASURED: the floor at 0, then at a huge value; a sized host keeps its height,
|
||||
* a content-sized one grows (and one that stops growing below the floor is CAPPED, by max-height). At mount the
|
||||
* profile has not rendered, so that probe touches no reader state.
|
||||
* The regression this replaces: re-probing on EVERY host resize collapsed the loader under live content, clamped a
|
||||
* scrolling child's scrollTop, and the embedded UPG-100 app (its view follows its column's scroll) flipped from Map
|
||||
* back to its first view whenever a phone's URL bar resized the host. So after mount follow() re-probes ONLY when
|
||||
* the reading says the kind may have changed, and that re-probe saves every scrolled descendant and puts it back
|
||||
* before the frame paints:
|
||||
* - floor on, host SHORTER than the floor: sized from outside (styled after mount), or capped. Re-probe decides;
|
||||
* a capped host is latched until it reaches the floor again, so it is never re-probed in a loop;
|
||||
* - floor off (sized), host CHANGED size: it may have stopped holding a size (restyled to height:auto, now
|
||||
* following its content). Re-probe decides, and the floor comes back if so;
|
||||
* - a window-height change on a content-sized host moves the host WITH the floor: no re-probe, nothing written.
|
||||
* check:host-floor pins all of this, with the old per-resize probe, a missing latch, a missing sized re-check and a
|
||||
* missing scroll restore as poisons.
|
||||
*/
|
||||
export const FLIP_SETTLE_MS = 1000
|
||||
const scrolledUnder = (root) => {
|
||||
const out = []
|
||||
const walk = (r) => {
|
||||
for (const e of r.querySelectorAll ? r.querySelectorAll('*') : []) {
|
||||
if (e.scrollTop || e.scrollLeft) out.push(e)
|
||||
if (e.shadowRoot) walk(e.shadowRoot)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return out
|
||||
}
|
||||
|
||||
export function createHostFloor(loader, host, { style = (n) => getComputedStyle(n), now = () => Date.now() } = {}) {
|
||||
let flipH = null, flipAt = 0, capped = false
|
||||
export function createHostFloor(loader, host, { style = (n) => getComputedStyle(n), scrolled = () => scrolledUnder(loader) } = {}) {
|
||||
let capped = false, lastH = null
|
||||
const isSized = () => loader.hasAttribute('data-host-sized')
|
||||
const floorPx = () => parseFloat(style(loader).minHeight) || 0
|
||||
const hostH = () => host.getBoundingClientRect().height
|
||||
function set(sized) {
|
||||
loader.style.minHeight = sized ? '0px' : ''
|
||||
if (sized) loader.setAttribute('data-host-sized', '')
|
||||
else loader.removeAttribute('data-host-sized')
|
||||
}
|
||||
/** At mount only, before the profile renders: nothing below the loader holds state yet. */
|
||||
function probeOnce() {
|
||||
function decide() {
|
||||
loader.style.minHeight = '0px'
|
||||
const h0 = host.getBoundingClientRect().height
|
||||
const h0 = hostH()
|
||||
loader.style.minHeight = '100000px'
|
||||
const h1 = host.getBoundingClientRect().height
|
||||
const h1 = hostH()
|
||||
const sized = h0 > 0 && Math.abs(h1 - h0) < 1
|
||||
set(sized)
|
||||
if (!sized) { const f = floorPx(); if (f > 0 && h1 + 1 < f) capped = true } // grows, but stops below the floor
|
||||
const f = sized ? 0 : floorPx()
|
||||
capped = !sized && f > 0 && h1 + 1 < f // grows with its content, but stops below the floor
|
||||
}
|
||||
/** On every host resize: reads sizes, and at most flips the floor (one write); it never re-measures. */
|
||||
/** At mount only, before the profile renders. */
|
||||
function probeOnce() { decide(); lastH = hostH() }
|
||||
/** After mount: the same decision, leaving every scrolled descendant where it was. */
|
||||
function reprobe() {
|
||||
const kept = scrolled().map((e) => [e, e.scrollTop, e.scrollLeft])
|
||||
decide()
|
||||
for (const [e, top, left] of kept) { if (e.scrollTop !== top) e.scrollTop = top; if (e.scrollLeft !== left) e.scrollLeft = left }
|
||||
}
|
||||
/** On every host resize: reads, and re-probes only when the host may have changed kind. */
|
||||
function follow() {
|
||||
const display = style(host).display
|
||||
if (display === 'inline' || display === 'contents') return // no box of its own to read
|
||||
const h = host.getBoundingClientRect().height
|
||||
const h = hostH()
|
||||
if (isSized()) {
|
||||
// Right after a flip, a host that SHRANK followed its content (it was capped, not sized): revert and latch.
|
||||
// Checked FIRST, so a capped host whose content is empty (it shrinks to 0) is latched, not flipped back and forth.
|
||||
if (flipH !== null && now() - flipAt < FLIP_SETTLE_MS && h + 1 < flipH) { set(false); flipH = null; capped = true; return }
|
||||
if (h < 1) { set(false); flipH = null }
|
||||
return
|
||||
if (lastH === null || Math.abs(h - lastH) >= 1) reprobe()
|
||||
} else {
|
||||
const f = floorPx()
|
||||
if (capped) { if (h + 1 >= f) capped = false }
|
||||
else if (f > 0 && h + 1 < f) reprobe()
|
||||
}
|
||||
if (capped) return
|
||||
const f = floorPx()
|
||||
if (f > 0 && h + 1 < f) { set(true); flipH = h; flipAt = now() }
|
||||
lastH = hostH()
|
||||
}
|
||||
return { probeOnce, follow }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue