💐 Sync 2026-09-10 14:49:21

This commit is contained in:
github-actions[bot] committed 2026-09-10 14:49:21 +08:00
1 parent 6fe213a608
commit 07d418c120
181 files changed
+23375 -22143

No files matched your search

@@ -182,18 +182,20 @@ function shellGeometry() {
return _geom;
}
/* The window's width and the column's gutter are read here, from a fitter, and nowhere else.
/* The window's width and the column's gutter are read here, from a fitter or from
* `contentWidth()`'s own staleness check below — nowhere else.
*
* The gutter is measured where it is applied rather than read off `--fs-content-pad`: below 767px
* `theme/20-shell.css` re-pads `.fs-content` to `var(--fs-space-4)`, so the token says 28px a side
* while the real gutter is 16px. The breakpoint may not be restated here — a width literal in JS is
* what these reads exist to avoid — so the element is asked what it actually got.
*
* Only a fitter calls this because `clientWidth` is a layout read and `getComputedStyle` resolves
* style, while `contentWidth()` below must answer mid-scroll without either (fs-select's pass, for
* a table the poll brought in under the reader's thumb). A fitter runs on every resize and content
* mutation and defers during a flick, so a mid-scroll answer is the geometry as of the last still
* moment.
* `clientWidth` is a layout read and `getComputedStyle` resolves style, so a fitter is the one
* caller allowed to reach BOTH unconditionally: it defers the whole pass during a flick
* (`fit.scrolling()`, fs-fit.js) rather than pay either mid-scroll. `contentWidth()` reaches only
* the first (comparison, not the write) on every call, and this function's own body only when
* that comparison says the width moved — its answer is otherwise the geometry as of the last still
* moment, exactly what a fitter last measured.
*
* A hostile declaration is no threat here: `.fs-content` carries no chrome mark, so if an app
* re-pads it then that padding IS the column's gutter. Before any fitter has run — the login page
@@ -270,21 +272,33 @@ function fitChrome() {
* fitShell's data-narrow, and is untouched here.) */
const topBar = !!bar && !!menu && prefs.isTopLayout();
/* THE BAR MAY NOT GET SHORTER WHILE IT IS BEING MEASURED. The three classes below are taken off
* so the menu can be asked whether it fits on one row (fs-fit rule 1), and on a narrow bar that
* makes the whole chrome one row instead of two for that layout — every pixel of it above the
* reader, who is moved by exactly as much and moved back a moment later. Chromium and Firefox
* hide it behind their scroll anchoring; Safari implements none, on any platform, so on an
* iPhone this is the Overview creeping up once per poll tick. Reported from one, and bisected to
* this pass on the reporter's own router: `?off=chromefit` stopped it, `?off=measure` (the
* tables' own re-measure) did not.
/* THE BAR MAY NOT CHANGE HEIGHT WHILE IT IS BEING MEASURED, IN EITHER DIRECTION. The three
* classes below are taken off so the menu can be asked whether it fits on one row (fs-fit
* rule 1), and while they are off the bar's OWN box is free to answer any height its content
* currently needs — not only shorter. A `min-height` floor alone stops the shrink but not the
* grow: with the classes off and `fs-dense1`/`fs-dense2` stripped by `fitTabStrips()`, this pass
* measured the bar walking 230 -> 202 -> 164 -> 144 -> 123 -> 131 -> 123px against a settled
* 123px on owrt2512 at 767px — 107px of growth a floor never sees, on top of the shrink it does
* — each step landing between two of the poll's own separate section refreshes, so the browser
* paints in between and the reader is moved by exactly as much, on Chromium and Firefox as well
* as Safari (`tools/fit-quiet.mjs`, `../tmp/task-toplayout/pass-probe.mjs`). `min-height` alone
* was measured to `accc451`'s WebKit-only diagnosis instead — WebKit's own scroll anchoring
* looked like the whole story only because it is the one engine with no anchoring at all to hide
* this walk behind; Chromium and Firefox absorb it the same way they absorb any other layout
* change, which is not the same as not producing it.
*
* `min-height`, not `height`: the pass may legitimately need MORE room a moment later — that is
* what `fs-bar-stack` is for — and a floor lets it grow while refusing the shrink. It comes off
* before `publishBarHeight()`, which must measure the bar the reader actually gets. */
* So both `min-height` AND `height` are pinned to the SAME value for the whole decision — a hard
* pin, not a floor — because nothing this pass measures (`stripFitsOneRow()`'s `offsetTop`,
* `clusterFitsBrandRow()`'s widths) reads the BAR's own height; `overflow: visible`
* (`theme/20-shell.css`) means a row the pin is too short for still lays out and measures
* correctly, it only paints past the pinned box's edge, which is invisible for the one
* synchronous pass before the pin comes off. The pin is released only once the final class set
* is decided — after `fitCluster()`, before `publishBarHeight()` — which must measure the bar
* the reader actually gets. */
const pinned = bar ? Math.round(bar.getBoundingClientRect().height) : 0;
const hadMinH = bar ? bar.style.minHeight : '';
if (pinned > 0) bar.style.minHeight = pinned + 'px';
const hadH = bar ? bar.style.height : '';
if (pinned > 0) { bar.style.minHeight = pinned + 'px'; bar.style.height = pinned + 'px'; }
if (bar) bar.classList.remove('fs-bar-stack', CLASS_IND_COMPACT, 'fs-bar-actrow');
fitTabStrips();
@@ -316,7 +330,7 @@ function fitChrome() {
if (bar && (topBar || document.documentElement.hasAttribute('data-narrow')))
fitCluster(bar, menu);
if (pinned > 0) bar.style.minHeight = hadMinH;
if (pinned > 0) { bar.style.minHeight = hadMinH; bar.style.height = hadH; }
publishBarHeight(bar);
}
@@ -572,18 +586,32 @@ return baseclass.extend({
* data tables' */
fitChrome,
/* The width a page's content column has, without reading layout: the sidebar or rail eats a
* known amount of the window and the shell adds a known padding, all memoised for fitShell().
* Exported because a pass answering mid-scroll (fs-select's, for a table the poll just brought
* in) otherwise has only the window width, which in the sidebar layout is wrong by exactly the
* sidebar — at 800px the column is 520px, so a table judged to have room overflows.
/* The width a page's content column has: the sidebar or rail eats a known amount of the
* window and the shell adds a known padding, all memoised for fitShell(). Exported because a
* pass answering mid-scroll (fs-select's, for a table the poll just brought in) otherwise has
* only the window width, which in the sidebar layout is wrong by exactly the sidebar — at
* 800px the column is 520px, so a table judged to have room overflows.
*
* The arithmetic is columnWidth()'s; this only adds the page's current state. */
contentWidth() {
/* no layout read: the window width and gutter are whatever the last fitter measured, and
* the three attributes below are style. The bootstrap read serves a caller arriving
* before any fitter has run, which cannot happen mid-scroll. */
if (!_shellOuter) measureShell();
/* `_shellOuter` is refreshed only by measureShell(), which only fitShell() calls, and
* fitChrome() steps ASIDE FOR THE WHOLE SCROLL_IDLE WINDOW (400ms, fs-fit.js) whenever
* fit.scrolling() answers yes — including for a resize that lands mid-flick, since a
* resize is exactly what starts that window (fs-fit.js's resize observer feeds the same
* motion sampler `scrolling()` reads). A caller landing in that window, most of all
* fs-select's, got the width the PREVIOUS viewport had: at 568px settling to 390px, model
* stayed 568 for up to 220ms of the 400 (measured: −178px, exactly 568−390;
* ../tmp/task-vnstat/probe2.mjs, probe3.mjs; live-audit's `geometry|fs-content` finding on
* owrt2410, CI run 34364446910).
*
* So the window's own width is compared fresh on every call, not only when `_shellOuter`
* is still zero. `clientWidth` is the one read the old "no layout read" promise here was
* already conditional on: the bootstrap branch (no fitter has run yet) made this exact
* call through measureShell(). A plain `!==` compares against the cached width for free —
* nothing invalidated layout since the last read, so this costs nothing when nothing
* moved — and measureShell()'s own further reads (the resolved gutter) only run when the
* comparison says the width actually did. */
if (document.documentElement.clientWidth !== _shellOuter) measureShell();
const root = document.documentElement;
return columnWidth(shellGeometry(), {
outerW: _shellOuter,
@@ -27,7 +27,12 @@
* arming is exported and the module that clears the rule is the one that raises it. */
function armGate() {
if (!fittersEnabled()) return;
try { document.documentElement.dataset.fsFit = '1'; } catch (e) { /* no document, no gate */ }
/* WRITTEN AS THE LITERAL `dataset.fsFit`, never through a helper: tools/table-contract.mjs
* reads this file for exactly that spelling to prove the gate rule is still armed, and an
* indirection hides the write from it. The behaviour survives being factored out; the
* contract does not. */
try { document.documentElement.dataset.fsFit = '1'; }
catch (e) { /* no document, no flag to write */ }
}
const _fitters = [];
@@ -187,6 +192,51 @@ function deferMeasurement() { _deferred = true; }
let _movingUntil = 0;
let _lastOffset = null;
let _sampling = false;
/* THE THEME'S OWN CORRECTION WRITE IS A SCROLL EVENT TOO, and this sampler cannot otherwise tell it
* from the reader's: `lateDrift()` and `applyAnchor()` write `scrollTop` directly, the browser
* dispatches `scroll` for that the same as for a finger, and `noteMotion()` below used to treat
* either as SCROLL_IDLE (400ms) of fresh motion — which then blocks `holdFloor()`, `rememberRest()`
* and `applyAnchor()` itself for that whole window, since all three refuse while `scrolling()`. On
* REPEAT's own back-to-back refills of one section a correction's write routinely landed inside the
* 700ms gap the next refill starts in: measured live (`../tmp/task-refill2/probe.mjs`,
* owrt2512b/chromium/Overview), a correction's write at t=6530 opened a motion window to t=6930, the
* next refill's mutation arrived at t=6811 — INSIDE it — and both `holdFloor()` and `rememberRest()`
* were silently refused for that refill: the floor never picked up the new content's real height and
* `_rest` kept describing the position from before it, so the correction two ticks later measured a
* fabricated 59px drift against that stale reference and wrote a real one, the exact 47-60px "never
* corrected" shape the gate reports. Two such self-inflicted writes are two misses by `lateDrift()`'s
* own count, which is what tripped `_engineTrusted` false in the same run though the engine had
* anchored correctly throughout. The write's own resulting offset is remembered here and consumed by
* `sawOwnWrite()` below, held (not consumed on the first look) until the offset actually leaves that
* pixel, so both watchers agree; a reader who scrolls to a different pixel in the meantime is
* unaffected. */
let _ownWrite = null;
/* -> true if `y` is new motion the sampler must count; false if it still reads as this file's own
* last write settling. NOT single-shot — task refill2: `noteMotion()` (the `scroll` event) and
* `sampleMotion()`'s own frame loop both ask this of the SAME settling write when the sampler was
* already running before the write (an earlier, genuine motion still winding down), and a version
* that cleared `_ownWrite` on the first of the two to ask left the second — whichever it was — with
* nothing to recognise, reading the already-explained pixel as fresh motion and re-extending
* `_movingUntil` right back over the write's own settle. Measured live
* (`../tmp/task-refill2/probe.mjs`, owrt2512b/webkit/Overview @390 top, 3/3 reps): `scrolling()` never
* came back false at all between refills, `holdFloor()`/`rememberRest()` refused every one of them,
* and the reader drifted 59px on the second and third with no correction ever landing — the same
* shape a stale `_rest` produces, from a different cause. The marker now clears only once the offset
* moves to something ELSE, so however many places ask, the answer for THIS pixel stays consistent. */
function sawOwnWrite(y) {
if (_ownWrite === null) return true;
if (Math.abs(y - _ownWrite) < 1) return false;
_ownWrite = null;
return true;
}
/* The one place either correction may write the scroll position, so `_ownWrite` cannot go stale by a
* write skipping it. Reads the offset back rather than trusting the argument: a write near either
* end of the document is clamped by the browser, and the pixel that actually lands is the one the
* next `scroll` event will report. */
function writeOffset(sc, value) {
if (sc) sc.scrollTop = value; else window.scrollTo(0, value);
_ownWrite = sc ? sc.scrollTop : window.scrollY;
}
/* Which element scrolls, asked once per width rather than once per frame.
*
@@ -229,14 +279,34 @@ function scrolling() { return Date.now() < _movingUntil; }
function sampleMotion() {
const y = scrollTop();
if (_lastOffset === null || y !== _lastOffset) {
const own = !sawOwnWrite(y);
_lastOffset = y;
_movingUntil = Date.now() + SCROLL_IDLE;
if (!own) _movingUntil = Date.now() + SCROLL_IDLE;
}
if (scrolling()) { requestAnimationFrame(sampleMotion); return; }
_sampling = false;
/* THE BOX, NOT THE REFERENCE — second attempt, also measured live and also wrong: a check
* against `_rest.el`'s own rect never sees this fault, because `_rest` is exactly what gets
* RE-ESTABLISHED, at whatever the offset currently is, by the very next successful
* `rememberRest()` — which may already have run, on a FRESH reference picked at the fold of an
* already-wrong position, before this ever gets to check anything. A reference cannot catch a
* fault in the ground it is itself read off. See `settleDeferredFloor()` for what holds instead:
* the OFFSET's own response to the ONE write below, measured directly, the same way
* `lateDrift()`'s `compensated` already checks the offset against `grow` — just for a shrink
* `holdFloor()` is about to (or, silently, already did) apply rather than one a mutation just
* grew. Both taken before `holdFloor()` runs it down: a box already settled through the ordinary
* mutation path in the meantime shows no shrink here, and costs one `parseFloat` to learn that. */
const target = _deferredFloor;
_deferredFloor = null;
const floorBefore = (target && target.isConnected) ? (parseFloat(target.style.minHeight) || 0) : 0;
const offsetBefore = scrollTop();
/* the reader has stopped, so the floor and the reference both belong to where the page now
* stands */
holdFloor();
if (target && target.isConnected) {
const shrink = floorBefore - (parseFloat(target.style.minHeight) || 0);
if (shrink > 1) settleDeferredFloor(offsetBefore, shrink);
}
rememberRest();
/* the page has held still for SCROLL_IDLE: whatever was put off may run now */
if (_deferred) {
@@ -253,6 +323,7 @@ function sampleMotion() {
}
function noteMotion() {
if (!sawOwnWrite(scrollTop())) return;
_movingUntil = Date.now() + SCROLL_IDLE;
if (_sampling) return;
_sampling = true;
@@ -281,14 +352,23 @@ function noteUser() {
(function watchMotion() {
const opts = { passive: true, capture: true };
window.addEventListener('scroll', noteMotion, opts);
/* a gesture that IS the scroll: the reader is driving and the page is moving */
for (const name of [ 'wheel', 'touchstart', 'touchmove' ])
/* A gesture that IS the scroll, from the second event on: `touchmove` and `wheel` fire only once
* the page has already moved, so unlike `touchstart` they mean motion, not just presence.
* `scroll` (above) and momentum still start the sampler for whatever the first frame of a flick
* misses. */
for (const name of [ 'wheel', 'touchmove' ])
window.addEventListener(name, noteUser, opts);
/* Intent only. A scrollbar drag and a Page Down move the page and say so themselves, through
/* Intent only: says the reader is present, not that the page is moving. `touchstart` used to sit
* above and feed `noteMotion` too, so a stationary tap on a tab declared the page moving for
* SCROLL_IDLE (400ms) and gated `fitChrome()` with it — the freshly drawn tab strip painted at
* full padding and only shrank once the sampler saw the page still, ~400ms after the tap. Real
* motion is read from the scroll POSITION by `sampleMotion`, not the event: `touchmove` and
* `scroll` above, plus momentum, all still start it, so nothing that actually moves the page
* loses its guard. A scrollbar drag and a Page Down move the page and say so themselves, through
* `scroll`. Feeding them to `noteMotion` too would make `scrolling()` answer yes for 400ms after
* any click and every keystroke, which gates every layout-reading pass in this file: while
* typing into a form, 9 of 10 passes were skipped and landed in one burst afterwards. */
for (const name of [ 'mousedown', 'keydown' ])
for (const name of [ 'mousedown', 'keydown', 'touchstart' ])
window.addEventListener(name, noteIntent, opts);
})();
@@ -359,21 +439,105 @@ function watch(el) {
* It never fights the user: a page at the top has no offset to give back, and a drift under a pixel
* is rounding. */
/* Does the engine anchor at all? Chromium and Firefox do — measured with their anchoring
* suppressed, a 120px growth above the fold moves the reader 120px, and 0px with it on. An older
* WebKit does not, and a current one anchors but gets the COLLAPSE case wrong instead (lateDrift()
* below). Correcting the offset in an engine that also corrects it means two corrections and a
* page that jumps the other way, so this is asked of the platform rather than of a browser name —
* `overflow-anchor` is the property that turns the feature off, and an engine that does not know it
* does not have it. */
* suppressed, a 120px growth above the fold moves the reader 120px, and 0px with it on. So this is
* asked of the platform rather than of a browser name — `overflow-anchor` is the property that
* turns the feature off, and an engine that does not know it does not have it. Defended against an
* engine — or a stub, in a node-run test — with no `CSS` object at all; unreadable answers `true`
* (assumed handled rather than fought).
*
* Task wkanchor shipped a second question here, `ENGINE_MISANCHORS`
* (`-webkit-hyphenate-limit-before`), reasoning WebKit 26's own anchoring got a real correction
* wrong: +21-41px on the Overview, reader parked, real poll ticks, nothing above the fold growing
* by even a pixel. Task barpin found the real mover instead — `fitChrome()` (fs-chrome.js) pinned
* the bar against SHRINKING during its own measurement pass but not against GROWING, so the bar
* itself walked up to 107px taller than its settled height and back inside that one pass, on every
* engine; WebKit was never mis-anchoring, it was the one engine with no scroll anchoring of its own
* to absorb what the bar was actually doing. With `fitChrome()` pinned both ways, the same probe
* that measured +21-41px reads 0px on WebKit at 390/top with NO suppression at all
* (`../tmp/task-toplayout/top-probe.mjs --unsuppress`, 26s of real ticks) — `ENGINE_MISANCHORS` and
* the `data-fs-anchor-suppress` write it drove are gone with it; see `docs/anchoring.md`. */
const ENGINE_ANCHORS = (() => {
/* dev switch: `localStorage.fsEngineAnchor = 'off'` makes any engine take the non-anchoring
* path, which is otherwise only reachable on a machine with Safari on it */
try { if (localStorage.getItem('fsEngineAnchor') === 'off') return false; }
catch (e) { /* no storage, no switch */ }
try { return typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('overflow-anchor', 'auto'); }
catch (e) { return true; } /* unreadable: assume it is handled rather than fight it */
try { return typeof CSS !== 'undefined' && typeof CSS.supports === 'function'
? CSS.supports('overflow-anchor', 'auto') : true; }
catch (e) { return true; }
})();
/* Support for the property is not proof it is doing the job on THIS page: CI showed a real engine
* decline to anchor a container refill on two separate passes, `overflow-anchor` still reading
* `true` throughout — a case `ENGINE_ANCHORS` above cannot see, because it is asked once, at load,
* of the platform. `lateDrift()` below already computes the residual after every refill the theme
* did not itself correct, so the evidence is left to accumulate rather than guessed at up front:
* two residuals it actually had to write back — task latenet's four-way ablation measured that
* write landing 419-420ms after the refill — and the observer stops trusting this engine, moving to
* the anchorFor()/scheduleAnchor() path instead, measured 7-36ms on the same refill, UNTIL
* TRUST_RECOVERY_LIMIT below says otherwise — not for the rest of the session unconditionally: a
* fork with no way back paid the full 7-36ms path forever after two ticks a loaded runner or a
* momentary layout hiccup could produce just as easily as a standing fault. One residual is left as
* headroom for a single one-off rather than tripping on the first. Never a browser name, only a
* count: an engine that keeps the reference itself never reaches the write this counts — Chromium
* and Firefox measure 0 residuals today — so the switch cannot trip for them.
* `docs/anchoring.md`, "Who is responsible", carries the numbers. */
const LATE_MISS_LIMIT = 2;
let _lateMisses = 0;
let _engineTrusted = ENGINE_ANCHORS;
/* How much of the growth witness's own rounding a correctly-anchoring engine may leave unaccounted
* for before that gap reads as the engine declining rather than as the witness rounding — task
* missrule. Measured on a 32-36 row DHCP lease table at 390 wide, the one shape in the tree that
* rounds this large: 8px (compact), 12px (normal/large, webkit), 12.25px (large, firefox) — three
* points clustered tight against a 120px growth pad (`GROWTH`, tools/scroll-anchor.mjs), none of
* them a residual, all of them the engine having already done the whole correction
* (docs/anchoring.md, "The witness is not safe to write on its own"). 16 leaves 3.75px (30%) over
* the largest of the three — the same margin-over-the-worst-measured-cluster shape the gate's own
* `LATE_MS` uses — while staying far under half the pad (60px), so a genuine partial failure that
* leaves the reader in the middle of it is never read as rounding. */
const LATE_ROUND_TOLERANCE = 16;
/* Trust that was never allowed back — task trust. Once `_engineTrusted` went false the fork above
* stayed on the `anchorFor()`/`scheduleAnchor()` path for the rest of the session even where the
* two misses that tripped it were the engine having a bad ten seconds, not a standing fault.
*
* TWO SHAPES TRIED HERE WERE WRONG, BOTH MEASURED — worth keeping the failures, not only the fix,
* since a maintainer three lines below reasoning "just read the same drift `lateDrift()` reads" or
* "just compare the offset to the growth" is about to reproduce one of them.
*
* First shape: `applyAnchor()` already reads `ref.el`'s own rect against the remembered top before
* deciding whether to write — the same measurement `lateDrift()` uses to call a miss on the trusted
* path — so a hit was counted there whenever that drift read under a pixel. Live against a real,
* correctly-anchoring engine (`../tmp/task-trust/probe.mjs`, chromium/owrt2410, the Overview's own
* poll-sized section) that branch never ran at all: `anchorFor()`'s OWN offset read forces the
* layout the engine's scroll-anchoring resolves against, so by the time it asks "did the reader
* move", the engine has ALREADY moved the offset to absorb the growth — and `anchorFor()`, built for
* an engine that does none of that, reads any offset change that is not a downward clamp as the
* READER having scrolled and returns null. `scheduleAnchor()` then never runs, so `applyAnchor()`
* never sees the one tick that would prove the engine right — 0 hits recorded across 5 genuinely
* successful refills, measured directly (a debug build exporting `_lateHits` read 0 throughout).
*
* Second shape: read the offset itself instead, in the SAME microtask `grew` (below) is already
* read in, before `run()` can overwrite `_restAt` — `compensated = scrollTop() - _restAt` against
* `grew`, the identical comparison `lateDrift()` makes on the trusted path (`seen - ref.at`), just
* without a rAF plus `SCROLL_IDLE` to wait out. This one DOES see the engine work, but it is not
* strict enough: measured against a genuinely PARTIAL correction (an offset that moved by about the
* right amount overall), `compensated` matched `grew` within `LATE_ROUND_TOLERANCE` while the
* gate's own independent mark still sat 48px off, uncorrected — the container growing by roughly the
* right amount is not the same fact as the READER'S OWN reference holding, and task blindref already
* proved a container-based witness can agree with a bad tick.
*
* What holds: `_rest.el` itself, read directly rather than through `anchorFor()` — the identical
* reference and the identical comparison `lateDrift()` trusts on the OTHER path, just made here,
* before `run()`, instead of a rAF plus `SCROLL_IDLE` later, since the compensation is already done
* by the time anything in this callback reads geometry. A false negative here only delays recovery;
* this shape has no cheaper approximation that does not risk a false positive instead. Proven the
* same way as the two failures: the identical 5-refill run holds `_rest.el`'s drift under a pixel on
* every one, `_engineTrusted` returns `true` on the second, and the reader's own mark never moved
* (`moved: 0` throughout, both phases) — recovery costs no visible correction of its own, because
* there is nothing left for one to do. */
const TRUST_RECOVERY_LIMIT = 2;
let _lateHits = 0;
/* What the reader was looking at, captured while the page was still. `anchorRef()` runs from the
* mutation observer, i.e. after the DOM changed: right for the FITTERS, which have not run yet, and
* blind to the mutation itself. An anchoring engine covers that other half; where none does, the
@@ -384,6 +548,22 @@ let _rest = null;
* both scrollers on a client navigation and replays them on a Back, and neither is a clamp to
* undo. */
let _restAt = null, _restPage = null;
/* The floored box a mutation found `holdFloor()` refused for (`scrolling()` was true) — its own
* shrink or grow still happens eventually, once `holdFloor()` finally does run, and until now that
* later call was `sampleMotion()`'s own bare `holdFloor(); rememberRest();`, wired to neither
* `lateDrift()` nor `scheduleAnchor()` — task wkrefill. Stashed by the mutation callback below,
* cleared the moment any tick's `holdFloor()` stops being refused, consumed once by
* `sampleMotion()`/`settleDeferredFloor()` (the write, and why it checks the OFFSET rather than a
* reference element, are there). Measured live (`../tmp/task-wkrefill/run-probe2.mjs`,
* webkit/owrt2512b @390 top, normal): a growth-then-shrink refill landing entirely inside one
* motion window left `_restAt` 59px higher than it started — `mark`'s own PAGE position never
* changed (7441.15625px throughout) while its viewport position read 440 against a `before` of 499
* — because the shrink's `min-height` write (a real scroll-anchor invalidation, `holdFloor()`'s own
* citation) landed inside `sampleMotion()`'s unwired call, the engine gave back only 61px of the
* 120px it owed, and the 59px gap was adopted as truth by the very next `rememberRest()`,
* uncounted and uncorrected — the exact shape task refill2's `compensated` check already catches on
* the GROWTH side, missing here only because nothing measured the SHRINK side at all. */
let _deferredFloor = null;
function pageStamp() {
return (document.body && document.body.getAttribute('data-page')) || '';
}
@@ -393,8 +573,19 @@ function forgetRest() {
_restAt = null;
_restPage = null;
}
function rememberRest() {
if (scrolling()) return;
/* `force`: skip the `scrolling()` guard — for the one caller that just wrote the scroll offset
* itself and needs the reference to match THAT write, not whatever unrelated motion happens to have
* `_movingUntil` still in the future at that instant. `lateDrift()`'s own correction and the
* engine's real compensation for the SAME underlying mutation both settle around the same
* SCROLL_IDLE window, so the two routinely overlap by a few milliseconds — measured live
* (`../tmp/task-refill2/probe.mjs`, owrt2512b/chromium/Overview): an ordinary, unforced
* `rememberRest()` right after the write was refused on `scrolling()` still reading true from the
* engine's own, unrelated scroll event 1ms earlier, leaving `_rest` stale for the NEXT refill and
* reproducing the same fabricated drift this call exists to prevent. Safe here specifically because
* the write just performed is a single, deliberate one this file made, not a multi-step animation to
* read mid-flight — by the time this call happens, that write has already landed. */
function rememberRest(force) {
if (scrolling() && !force) return;
/* A page at the top has nothing to be put back to, so it does not pay for a reference: at
* offset 0 there is nothing to lose, and anchorRef()'s hit test plus rect costs 0.2ms typical,
* 6ms on a poll-dirtied WebKit layout. The offset is still remembered — one read, and
@@ -566,7 +757,7 @@ function anchorEnabled() {
* scrolls, not across a navigation, never more than a viewport. */
let _lateFrame = 0;
function lateDrift(ref) {
function lateDrift(ref, grow, floorShrink) {
/* the reference from BEFORE this tick, captured by the caller: one taken after the mutation
* describes the page as the mutation left it, so its drift is zero by construction */
if (_lateFrame || !ref) return;
@@ -607,22 +798,198 @@ function lateDrift(ref) {
if (!ref.sec || !ref.sec.isConnected || ref.secTop == null) return;
el = ref.sec; was = ref.secTop;
}
const drift = el.getBoundingClientRect().top - was;
let drift = el.getBoundingClientRect().top - was;
/* THE WITNESS CAN BE BLIND. `el`/`ref.sec` is whatever anchorRef() hit-tested at the fold
* on the LAST still page — it is not guaranteed to sit below the container this tick
* actually refilled, and an element's own top does not move when growth happens somewhere
* it is not connected to. Measured live: a compact-density reference at top -362 read 0px
* of drift on 13 of 13 refills while the reader sat 120px off (CI, webkit/owrtsnap @1440
* side, task blindref). `grow` — the mutation record's OWN target measured by the caller
* (observeContent()) against the height `data-fs-floor` pinned it at before this tick —
* cannot make that mistake: it IS what the refilled container actually grew by, not a
* guess at what moved.
*
* IT IS NOT A SAFE NUMBER TO WRITE ON ITS OWN, though — task detector, reverting the claim
* this comment used to make. `compensated` is what the OFFSET already did since the
* reference was taken; where it is exactly zero the engine never touched the offset at
* all, the same blindness `drift` above just had, and `grow` is the only witness that saw
* it — writing the whole growth back is correct and IS a miss: the engine did nothing.
* Where `compensated` is NOT zero the engine already moved the offset roughly by its own
* anchoring, and a SMALL gap between the two is not a residual to correct — it is this
* witness's own rounding on a many-row table re-laid at a narrow width: a 32-36 row DHCP
* lease table clamped by exactly 8px (compact), 12px (normal/large, webkit) or 12.25px
* (large, firefox) while `compensated` already matched `grow` to within that same amount,
* on BOTH engines, at every density and every layout tested (task detector, 21 CI
* findings, /admin/network/dhcp @390 — the overshoot equalled the clamp the gate itself
* reported in every one). Writing that gap is a second correction on top of one the engine
* already made; instrumented locally against the failing cell, withholding the write reads
* `swap moved 0px` on all 21 with no write logged, engine alone.
*
* BUT a gap that small is not the only shape this branch used to see, and task detector's
* own `>= 1` counted every one of them as a miss regardless of size — so on the SAME table,
* two ticks (10s) later, `_engineTrusted` went false while the engine was still anchoring
* perfectly (task missrule, `../tmp/task-anchor-audit/inventory.md` §2.2): the fast path
* then ran ALONGSIDE a live engine, the exact "two corrections throw the page the other
* way" `ENGINE_ANCHORS` above warns about. A miss must mean the engine did not do the job,
* not that this witness rounded — so only a gap ABOVE `LATE_ROUND_TOLERANCE` counts as one;
* within it, the engine did the job and lateDrift() returns having written nothing, same as
* the direct `drift < 1` case two lines below. Below the tolerance is not "no drift to
* report" — WITHIN it, this file already knows the engine is right, which `drift` alone
* (still whatever `anchorRef()`'s possibly-blind reference read) does not. Re-measured live
* against a real engine that never anchors this section at all (task missrule,
* ../tmp/task-missrule/probe.mjs, webkit and chromium, a table swapped three times running):
* `compensated` stays exactly 0 every tick, so this branch is never reached for that case —
* it counts the ordinary way, below, and `_engineTrusted` still goes false on the second
* one, switch working as built. */
if (Math.abs(drift) < 1 && grow > 1) {
const compensated = seen - ref.at;
if (Math.abs(compensated) < 1) drift = grow - compensated;
else if (Math.abs(grow - compensated) > LATE_ROUND_TOLERANCE) {
/* fresh distrust starts the recovery count at 0 too — a streak from a PREVIOUS
* spell of distrust proves nothing about this one */
if (++_lateMisses >= LATE_MISS_LIMIT) { _engineTrusted = false; _lateHits = 0; }
return;
}
/* else: within table-row rounding — drift is still whatever it was (< 1 per the guard
* above), so the plain `drift < 1` return two lines down is what fires, unwritten and
* uncounted: the engine did the job. */
}
if (Math.abs(drift) < 1) return; /* the engine put it back */
if (Math.abs(drift) > (window.innerHeight || 800)) return;
const sc = scroller();
const at = sc ? sc.scrollTop : window.scrollY;
if (sc) sc.scrollTop = at + drift; else window.scrollTo(0, at + drift);
/* The write moves the page by exactly the drift measured, which puts the reference back
* at the top it was remembered at, so `_rest.top` still holds and the next tick
* measures zero. Only `_restAt` changes, and the write may have been clamped short, so
* it is re-read rather than assumed; `rememberRest()` cannot do it, since the write
* starts the motion sampler and that function returns early while the page moves. */
_restAt = scrollTop();
writeOffset(sc, at + drift);
/* A FRESH, FORCED rememberRest(), not just `_restAt = scrollTop()` — task refill2. The
* write moves the page by exactly the drift measured, so `_rest.top` still holds FOR THIS
* TICK's own `el`, but `_rest` itself was taken by run() at the top of THIS callback,
* before the mutation had a settled height and before the engine had done any of its own
* compensating scroll for it — a snapshot mid-transition, not the page as it will stay.
* Left standing, the NEXT refill's own correction reads its drift against that stale,
* mid-transition top instead of the one this write just settled, and computes a fabricated
* number: on REPEAT's own back-to-back refills of one section this was the 59px "never
* corrected" drift itself, not merely late bookkeeping. `force` (`rememberRest()`'s own
* comment) because the engine's real, unrelated compensation for the SAME underlying
* mutation routinely settles inside the same SCROLL_IDLE window as this write and leaves
* `scrolling()` reading true by a handful of milliseconds — an unforced call here reproduced
* the identical fabricated drift on the very next refill (measured,
* `../tmp/task-refill2/probe.mjs`, owrt2512b/chromium/Overview: `moved 0, 0, -59` without
* `force`, `moved 0, 0, 0` with it, both `trusted true` throughout). */
rememberRest(true);
/* A DROP IN THIS SAME BOX'S OWN FLOOR IS NOT EVIDENCE ABOUT THE ENGINE — task refill2. Every
* `run()` clears and rewrites `r.target`'s `min-height`, and a WRITE to that property is a
* scroll-anchor invalidation in its own right (`holdFloor()`'s own citation), independent of
* how reliably this engine otherwise keeps a reference: a floored box whose content really
* did shrink pays this cost on every engine, every time, structurally — counting it toward
* `_lateMisses` measures the theme's own floor bookkeeping, not the engine, and two ordinary
* shrinks ten seconds apart used to be enough to distrust an engine that had anchored every
* real growth on the same section at 0px throughout (measured, `../tmp/task-refill2/
* probe.mjs`, owrt2512b/chromium/Overview: `trustedBefore true, trustedAfter false`, reader
* never moved). The correction still runs — P1/P2 do not care why the engine left a
* residual — only the bookkeeping is skipped. */
if (floorShrink > 1) return;
/* A DIRECT DRIFT INSIDE THE SAME TOLERANCE THE BLIND BRANCH ALREADY USES IS THE SAME
* ROUNDING, NOT A SECOND FAULT — task refill2. The blind branch above only ever sees this
* table's row-rounding when the engine's own compensation left `drift` under 1px; on the
* SAME table's GROWTH refill this measured a direct `drift` of exactly 1px against a 132px
* growth (`../tmp/task-refill2/probe.mjs`, owrt2512b/chromium, `/admin/network/dhcp @390`
* side and top) — one pixel over the `< 1` line above, so neither guard caught it, and a
* write this small still counted as a miss. `REPEAT`'s own three-refill window turns that
* into two: one from this 1px growth-side rounding, a second from the very next one, and
* `_engineTrusted` tripped false on an engine anchoring within a pixel every time. Read
* against the SAME LATE_ROUND_TOLERANCE the blind branch already measured this exact page
* and width against (8-12.25px) rather than a new number for what is the same table. */
if (Math.abs(drift) <= LATE_ROUND_TOLERANCE) return;
/* This engine did not keep the reference across a container refill, once more on this
* page — see LATE_MISS_LIMIT above for what happens once that has been measured twice. */
if (++_lateMisses >= LATE_MISS_LIMIT) { _engineTrusted = false; _lateHits = 0; }
}, SCROLL_IDLE);
});
}
/* ---- a floor `holdFloor()` could not clear at mutation time, cleared later with nobody watching ----
*
* `_deferredFloor`'s own comment has the fault; this is the fix, and it needs a THIRD frame slot,
* not either of the two `lateDrift()`/`scheduleAnchor()` already own. Measured live, first attempt:
* routing the same information through `lateDrift(ref, 0, floorShrink)` from `sampleMotion()`
* collided with the regular mutation's OWN pending call for the SAME tick — `seen` there is
* captured one rAF after the ORIGINAL (refused) mutation, well before `sampleMotion()` ever gets to
* clear the floor, so by the time this one tried to arm, `_lateFrame` was still occupied, and by
* the time the ORIGINAL one's own timeout fired, `holdFloor()`'s belated write had already moved
* the offset on its own — read by that timeout as `scrollTop() !== seen`, "the reader is still
* moving", and refused too. Two correct guards, aimed at two different questions, defeating each
* other on the one tick both fire for.
*
* NOT A REFERENCE ELEMENT'S OWN DRIFT — second attempt, also measured live and also wrong, and the
* one worth explaining because it looks safer than it is. Checking `_rest.el`'s (or `ref.sec`'s)
* rect the way `lateDrift()` does cannot see this fault BY CONSTRUCTION: `_rest` is exactly what
* gets RE-ESTABLISHED, at whatever the offset happens to be, by the very next successful
* `rememberRest()` — which runs a fresh `anchorRef()` hit test at the CURRENT fold, wherever that
* now falls. Once one has run on an already-59px-wrong offset, the reference it picks (measured
* live: an `H3` a NEW hit test landed on, in a position the fold only reaches because the offset is
* already wrong) shows zero drift for ever after — it was established AT the wrong position, not
* moved away from a right one. A witness cannot catch a fault in the ground it is itself read off.
*
* WHAT HOLDS: the OFFSET's own response to the ONE write below, measured directly against how much
* the box actually shrank — the same `compensated` vs `grow` comparison `lateDrift()`'s blind branch
* already makes for a GROWTH, just made here for a SHRINK `holdFloor()` is about to apply (or,
* silently, already did) rather than one a mutation just grew. Neither snapshot depends on `_rest`
* staying pure, so neither can be corrupted by the fault this exists to catch. */
/* NOT GATED ON `scrolling()` — third attempt, also measured live and also wrong, and the one
* `lateDrift()`'s own comment already warned this file about once: "an anchoring engine moves the
* offset ITSELF … so an offset that merely differs is the engine working, and refusing on that
* leaves the engine's own residual uncorrected." `holdFloor()`'s belated write is exactly such a
* change — a real `min-height` clear the engine reacts to by moving the offset — and that reaction
* dispatches its own `scroll` events, which is what this correction exists to read. Gating on
* `scrolling()` therefore refuses on the very motion it is trying to observe: measured live,
* `scrolling()` still read true one whole rAF after `holdFloor()` ran, on every one of three cells,
* because the reaction itself kept re-arming `_movingUntil`. `lateDrift()` does not make this
* mistake either — it checks `scrollTop() !== seen` (STILL, not "not moving"), two reads apart, the
* same check used here: two rAFs, not `SCROLL_IDLE` (400ms) — `REPEAT`'s own back-to-back refills
* leave under a second between this write's inputs being known and the NEXT refill's mutation
* landing, and a 400ms wait routinely lands its own check after that next refill has already
* restarted the motion sampler, checking a `now` the reader has already moved past. */
let _floorLateFrame = 0;
function settleDeferredFloor(offsetBefore, shrink) {
if (_floorLateFrame) return;
_floorLateFrame = requestAnimationFrame(() => {
const seen = scrollTop();
_floorLateFrame = requestAnimationFrame(() => {
_floorLateFrame = 0;
if (!anchorEnabled() || Date.now() < _userUntil) return;
if (_restPage !== pageStamp()) return;
if (scrollTop() !== seen) return; /* still settling, or the reader has moved */
/* A box that shrinks above the reader must lower the offset by the same amount for them
* to stay put — `wanted` is that target, not an estimate: `shrink` is what the box
* actually gave up, read fresh by the caller against its own pre-write height, the
* identical measurement `grow` already is for the growth side. */
const wanted = offsetBefore - shrink;
const gap = wanted - seen;
if (Math.abs(gap) <= LATE_ROUND_TOLERANCE) return; /* the engine already gave it back */
if (Math.abs(gap) > (window.innerHeight || 800)) return;
const sc = scroller();
const at = sc ? sc.scrollTop : window.scrollY;
writeOffset(sc, at + gap);
/* `_rest` ADJUSTED IN PLACE, NOT `rememberRest(true)` — fourth attempt, also measured
* live and also wrong: this write's own `scroll` event can still be in flight when this
* line runs, and `rememberRest(true)` calls `anchorRef()` regardless of `force` —
* `anchorRef()` has its OWN, unconditional `if (scrolling()) return null;` guard, so a
* write landing inside that window leaves `_rest` NULL rather than merely stale. Measured
* live (`../tmp/task-wkrefill/run-probe.mjs --cellonly owrt2410b@top`): the very next
* mutation's `lateDrift()` then refused with "no ref", `_rest` was re-established fresh
* off whatever the fold happened to be two ticks later, and the reader ended up 120px off
* in the OTHER direction — worse than doing nothing. `_rest.el` itself did not move; only
* the offset it was measured against did, by exactly `gap`, so its remembered screen
* position shifts by the same amount and nothing needs re-reading off the page. */
if (_rest) { _rest.top -= gap; if (_rest.sec) _rest.secTop -= gap; _rest.at = scrollTop(); }
_restAt = scrollTop();
/* NOT COUNTED TOWARD `_lateMisses` — same rule as `lateDrift()`'s own `floorShrink > 1`
* skip: this write answers for the theme's own bookkeeping catching up late, not for
* anything the engine declined to do, so it says nothing about whether the engine can be
* trusted on an ordinary tick. */
});
});
}
function scheduleAnchor(ref) {
if (!ref || !anchorEnabled()) return;
if (_anchorPending) return;
@@ -632,10 +999,14 @@ function scheduleAnchor(ref) {
_anchorFrame = 0;
const pending = _anchorPending;
_anchorPending = null;
applyAnchor(pending);
const wrote = applyAnchor(pending);
/* after the correction, never before: the reference must describe the page as the reader now
* sees it, or the next tick pays the same drift twice */
rememberRest();
* sees it, or the next tick pays the same drift twice. Forced only where a write just
* happened — task refill2's `force` on `rememberRest()` is for resyncing after THIS file's
* own write, not for skipping the guard when `applyAnchor()` did nothing (refused because the
* page was moving, at the top, or the reference was gone): forcing unconditionally would read
* a reference off a page mid-flick on exactly the refusal this guard exists for. */
rememberRest(wrote);
});
}
function applyAnchor(ref) {
@@ -657,22 +1028,28 @@ function applyAnchor(ref) {
* one case to sit out. */
if (ref.by != null) {
if (ref.by < 1) return;
if (sc) sc.scrollTop = at + ref.by;
else window.scrollTo(0, at + ref.by);
return;
writeOffset(sc, at + ref.by);
return true;
}
if (at <= 0) return;
if (!ref.el.isConnected) return;
const drift = ref.el.getBoundingClientRect().top - ref.top;
if (Math.abs(drift) < 1) return;
if (Math.abs(drift) < 1) return; /* nothing needed correcting here */
/* A definite write is a definite miss — task trust: any recovery streak counted so far said
* nothing about THIS tick, and this tick just proved the engine did not do the job on its own.
* (Recovery evidence itself is gathered earlier, in the mutation callback — see TRUST_RECOVERY
* _LIMIT above: this path never sees the tick that proves the engine right, because a reference
* the engine has already put back is exactly what makes anchorFor() return null instead of
* reaching here — measured live, `../tmp/task-trust/probe.mjs`.) */
_lateHits = 0;
/* A correction is a scroll the reader did not ask for, so an absurd one is a bug: a view that
* replaced its whole subtree can move a reference by thousands of pixels. One viewport and 200px
* is the most a single tick can honestly account for — where `innerHeight` is unreadable those
* 200px are the whole ceiling — plus whatever the engine is on record for having clamped away
* (`slack`, see anchorFor()). */
if (Math.abs(drift) > (window.innerHeight || 0) + 200 + (ref.slack || 0)) return;
if (sc) sc.scrollTop = at + drift;
else window.scrollTo(0, at + drift);
writeOffset(sc, at + drift);
return true;
}
/* Rule 2's mutation side. Deliberately not filtered by node type: a filter is a second place to
@@ -686,7 +1063,7 @@ function applyAnchor(ref) {
* constructor, and luci-base instantiates a class once, at the first require. */
function observeContent() {
if (_mo) return;
_mo = new MutationObserver(() => {
_mo = new MutationObserver((records) => {
/* The theme corrects only where the engine will not. Where it anchors, growth above the
* reader is the engine's job and the floor covers the collapse, so there is nothing left for
* a correction to do: one written here would read its reference in the same instant the poll
@@ -701,9 +1078,83 @@ function observeContent() {
* within the frame, so the immediate correction stays, measured against the reference from
* the last still page. */
const settled = _rest;
const ref = ENGINE_ANCHORS ? null : anchorFor();
/* `_engineTrusted`, not `ENGINE_ANCHORS`: the platform check above answers once, at load,
* whether the property exists — it cannot see an engine that has it but declines to use it
* on a given refill, which is what `lateDrift()`'s own residual count is for (LATE_MISS_LIMIT
* above). Once that has happened twice this session the fork moves here too, so the engine
* gets no third 420ms-late chance at a correction the fast path already does in 7-36ms. */
const trustEngine = _engineTrusted;
const ref = trustEngine ? null : anchorFor();
/* THE RECORD'S OWN TARGET, read before run() below rewrites its floor for the NEXT tick — the
* only place this callback sees which container a poll actually refilled, as opposed to
* whichever element anchorRef() happened to hit-test at the fold, which need not be connected
* to THIS growth at all (task blindref). A record's target already wearing `data-fs-floor` IS
* the box holdFloor() pinned at its last settled height, so its height against that pin, taken
* NOW — the mutation already happened, so this is its final height; nothing here waits on the
* engine, which only ever moves the SCROLL POSITION, never an element's own size — is the
* growth lateDrift() needs, in pixels rather than a live reference to carry forward.
*
* FOUND EVERY TICK, TRUSTED OR NOT — task trust. Gating this to the trusted branch was fine
* while distrust was permanent, since nothing downstream of it would ever read the number
* again; recovering trust needs to tell a tick that tested the engine from one that tested
* nothing on the distrusted path too, in the recovery check right below, so the read can no
* longer be skipped there. One extra `records.find` and an `offsetHeight` per distrusted
* tick — the one path that used to pay nothing here at all. */
const r = records.find((m) => m.type === 'childList' && m.target.hasAttribute('data-fs-floor'));
/* a box freshly wearing its FIRST floor has no "before" to measure against — parseFloat
* of an unset `min-height` is NaN, `|| 0` reads as "no growth" rather than false growth
* the size of the whole box */
const before = r && (parseFloat(r.target.style.minHeight) || 0);
const grew = before ? r.target.offsetHeight - before : 0;
/* RECOVERY EVIDENCE, task trust — read here and nowhere else; see TRUST_RECOVERY_LIMIT's own
* comment for why `applyAnchor()` cannot see it. THE SAME REFERENCE `lateDrift()` trusts on
* the other path (`_rest.el`'s own rect against the top it was remembered at), read before
* `run()` can overwrite it, instead of a rAF plus `SCROLL_IDLE` later — the compensation is
* already done by the time anything here reads geometry (`anchorFor()`'s own read, just
* above, forces the layout the engine resolves it in).
*
* NOT `compensated` vs `grew` (the OFFSET against the CONTAINER'S height, `lateDrift()`'s own
* comparison for the case its element-based drift is blind) — measured live and wrong here:
* `../tmp/task-trust/probe.mjs` against a genuinely partial correction (an offset that moved
* ROUGHLY the growth pad's own size) read `compensated≈grew` and recovered trust while the
* probe's own independent mark still sat 48px off, uncorrected. The container growing by
* about the right amount is not the same fact as THIS reference holding, and recovery needs
* the stricter of the two: a false negative here only delays recovery, a false positive
* un-distrusts an engine that is still getting it wrong. */
if (!trustEngine && grew > 1 && _rest && _rest.el.isConnected && Date.now() >= _userUntil
&& !scrolling() && _restPage === pageStamp()
&& Math.abs(_rest.el.getBoundingClientRect().top - _rest.top) < 1
&& ++_lateHits >= TRUST_RECOVERY_LIMIT) {
_engineTrusted = true;
_lateMisses = _lateHits = 0;
}
/* THE SAME BOX'S FLOOR, BEFORE AND AFTER THIS run() — task refill2. `holdFloor()` inside
* run() clears and rewrites `r.target`'s own `min-height` every tick, which is a scroll-
* anchor-invalidating style write on its own account (css-scroll-anchoring-1 §2.2.2,
* `holdFloor()`'s own comment) — so a floored box whose CONTENT genuinely shrinks (a poll's
* data losing rows, or in this callback the mutation record ITSELF being a removal) drops the
* engine's own anchoring for that write no matter how reliable the engine otherwise is. That
* residual reads to `lateDrift()` exactly like an engine that declined a real refill — the
* `grow` witness above is already 0 for it (`before` still equals the box's own pre-shrink
* offsetHeight at the point `grew` was read, min-height not yet cleared) — and every one of
* them used to count as a miss: measured live (`../tmp/task-refill2/probe.mjs`,
* owrt2512b/chromium/Overview, `REPEAT_TIMES` back-to-back refills of one section), two such
* shrinks ten seconds apart tripped `_engineTrusted` false on an engine that had anchored
* every real growth on the same section at 0px throughout. Read AFTER run(), which is what
* actually clears and rewrites it for this tick. */
/* STASHED BEFORE run(), against the identical `scrolling()` its own `holdFloor()` call is
* about to check: a floored box `holdFloor()` is refused for right now still shrinks or grows
* eventually — see `_deferredFloor`'s own comment for where and why nothing used to notice.
* THE BOX ITSELF, not a height or a reference: `settleDeferredFloor()` re-reads the height
* fresh at consumption time, so an intervening successful sweep of the SAME box (this tick's
* own `run()`, or another mutation's) leaves nothing stale behind — cleared below the moment
* any tick's `holdFloor()` actually runs, for the identical reason. */
const wasScrolling = scrolling();
if (r && before && wasScrolling) _deferredFloor = r.target;
run();
if (ENGINE_ANCHORS) lateDrift(settled);
if (!wasScrolling) _deferredFloor = null;
const floorShrink = (r && before) ? Math.max(0, before - (parseFloat(r.target.style.minHeight) || 0)) : 0;
if (trustEngine) lateDrift(settled, grew, floorShrink);
else scheduleAnchor(ref);
});
const hosts = [ document.getElementById('view') || document.body, document.getElementById('modal_overlay') ]
@@ -725,20 +1176,35 @@ function observeContent() {
_moFlag = new MutationObserver(run);
_moFlag.observe(document.body, { attributes: true, attributeFilter: [ 'class' ] });
/* A TAB SWITCH MUTATES NO NODE. ui.tabs writes `data-tab-active` on the panes, so the
* {childList} registration above never wakes and the floor the pane wore while it was open
* stays on it — and `min-height` beats the `height: 0` an inactive pane is collapsed with
* (theme/30-tables.css), so that floor IS blank page above the tab the reader just opened.
* Measured on 25.12, /admin/network/network, Interfaces -> Devices: 1299px left standing, the
* document at 2647px against 1720 and the content the reader came for 1559px down, still there
* 13 s later on a page whose poll never mutates #view (tools/floor-contract.mjs, issue #75).
/* A TAB SWITCH — OR A DISCLOSURE CLOSING, OR A depends() ROW HIDING — MUTATES NO NODE. ui.tabs
* writes `data-tab-active` on the panes and fs-appearance.js's foldable() closes by writing
* `hidden`/`aria-expanded` only, so the `{childList}` registration above never wakes for either
* and the floor taken while the content was open/visible just stays — `min-height` beats the
* `height: 0` a hidden pane collapses with (theme/30-tables.css), so that floor IS blank page.
* Measured on 25.12, /admin/network/network, Interfaces -> Devices: a tab switch left 1299px
* standing, the document at 2647px against 1720, still there 13s later on a page whose poll
* never mutates #view (tools/floor-contract.mjs, issue #75); the disclosure shape measured
* 731/1485/1485 (open/close/still-1485) on /admin/system/footstrap before this observer
* covered it, 731/1485/731 with it (docs/anchoring.md).
*
* STOCK LuCI HIDES A ROW THE SAME WAY, WIDER: form.js's setActive() — what every `depends()`
* calls — toggles the CLASS `hidden` on the `[data-field]` element, not the attribute. `class`
* cannot join `data-tab-active`/`hidden`/`aria-expanded` in this filter unguarded — the poll
* rewrites row classes on every tick, and an unfiltered `class` watch would call run(), a
* forced layout, on every one of them (see _moFlag's own comment) — so a `class` record only
* counts where the mutated element itself carries `data-field`, a property check with no
* forced layout, once per delivered record rather than once per poll tick. Measured: System ->
* System -> Time Synchronization, unticking "Enable NTP client", left 258px of empty ground
* before this filter existed and 0px with it (../tmp/task-spoilerfloor/probe3.mjs).
*
* A THIRD observer for the reason the second one exists — observe() replaces the options of a
* registration for the same node. The filter keeps it to the one attribute: `subtree: true` on
* `class` would wake run() on every row the poll rewrites. */
_moTabs = new MutationObserver(run);
* registration for the same node; ONE registration per host covers all four attributes since
* none of this needs `subtree: true` on a different scope than `data-tab-active` already has. */
_moTabs = new MutationObserver((records) =>
records.some((r) => r.attributeName !== 'class' || r.target.dataset.field) && run());
for (const host of hosts)
_moTabs.observe(host, { attributes: true, attributeFilter: [ 'data-tab-active' ], subtree: true });
_moTabs.observe(host, { attributes: true,
attributeFilter: [ 'data-tab-active', 'hidden', 'aria-expanded', 'class' ], subtree: true });
}
@@ -779,6 +1245,15 @@ return baseclass.extend({
* here at all". */
restAt: () => _restAt,
/* -> whether this session still lets the engine's own scroll anchoring correct a refill, or has
* moved to the anchorFor()/scheduleAnchor() fallback instead (LATE_MISS_LIMIT above) — until
* TRUST_RECOVERY_LIMIT measured refills in a row put it back (task trust). Unmarked for the same
* reason `restAt` is: a browser sweep against the INSTALLED package needs it, and a probe marker
* is what packaging strips. Task missrule — SWAP closes after one refill, so nothing before this
* could see the flag go false on the SECOND one; this is what a several-refill case reads instead
* of inferring it from timing. */
engineTrusted: () => _engineTrusted,
/* "the offset is mine now, forget what you remembered": called by fs-router when it resets both
* scrollers for an incoming page. The router resets synchronously and stamps `body[data-page]`
* an await later, so in between a poll tick from the OUTGOING page satisfies every term of "the
@@ -419,14 +419,53 @@ function restoreScroll(pos, gen) {
* asynchronously, so a flag around the write would already be false — the position last written
* is remembered instead, and a scroll landing exactly there is ours.
*
* Passive listeners: this must never sit in front of the scroll it watches for. */
* Passive listeners: this must never sit in front of the scroll it watches for.
*
* AND ONLY ON AN AXIS THIS `pos` ACTUALLY CARRIES. One `cancelled` flag serves both scrollers, so
* an event on the axis this layout does NOT use — a stray write to the other scroller, from
* anywhere in the document, while THIS one is still waiting for its content to grow tall enough —
* used to read as "the reader scrolled" and cancel the whole restore, killing the axis that WAS
* legitimately pending over one that was never being restored at all. Reproduced without a real
* router (`../tmp/task-back/repro2.mjs`): a bare `window.scrollTo(0, 111)` from an unrelated
* script while the sidebar layout's `#maincontent` restore was still pending left the reader at 0
* for the rest of the 5 s window instead of the parked 3000; scoping the check to the axis `pos`
* carries fixes it, samples unchanged (3000).
*
* A SAME-axis false alarm survives that fix: the browser's OWN traversal restore lands on the
* scroller BEFORE this handler swaps `#view` (see the comment above the function), and the swap
* that follows briefly leaves the incoming page shorter than the saved offset — `commitStage()`
* moves the outgoing page's nodes out before the incoming page's have finished growing under
* their own RPCs. The engine then clamps the scroller BACK to whatever height exists NOW, firing
* an ordinary `scroll` event that looks exactly like a reader's, and it lands before this tick has
* ever written anything (`wroteWin`/`wroteMain` still -1), so the "our own write coming back"
* check above cannot catch it either. Measured live (owrt2512b @1440, `/admin/status/overview` <-
* package-manager, Back): the UA restores `window.scrollY` to the parked 2684 in the same tick
* `popstate` fires, `commitStage()` leaves the document ~900px tall for one frame, and the next
* native `scroll` event reports `y=0` a whole 5 s before this function's own deadline — cancelling
* a restore that had not yet had the height to attempt. A clamp cannot land anywhere but the
* scroller's OWN current ceiling, and the reader cannot have scrolled PAST a height that does not
* exist yet, so a scroll landing exactly there while that ceiling is still short of the saved
* offset is the engine settling, not input — every real gesture that could produce it (a
* scrollbar drag past the same limit, in particular) already flows through the direct
* wheel/touchstart/keydown listeners below regardless of what onScroll decides. */
let cancelled = false, wroteWin = -1, wroteMain = -1;
const stop = () => { cancelled = true; off(); };
const onScroll = (ev) => {
const t = ev.target;
const now = (t === document || t === document.documentElement || t === document.body)
? Math.round(window.scrollY) : (t && t.scrollTop);
if (now === wroteWin || now === wroteMain) return; /* our own write coming back */
const isWin = (t === document || t === document.documentElement || t === document.body);
if (isWin) {
if (!pos.win) return;
if (Math.round(window.scrollY) === wroteWin) return; /* our own write coming back */
const de = document.documentElement;
const ceiling = Math.max(0, de.scrollHeight - de.clientHeight);
if (ceiling < pos.win && Math.round(window.scrollY) === ceiling) return; /* the page settling */
}
else {
if (!pos.main) return;
if (t && t.scrollTop === wroteMain) return; /* our own write coming back */
const ceiling = t ? Math.max(0, t.scrollHeight - t.clientHeight) : 0;
if (ceiling < pos.main && t && t.scrollTop === ceiling) return; /* the page settling */
}
stop();
};
/* the keys that scroll, and only those: typing in a field must not cancel anything */
@@ -607,6 +646,15 @@ function seed() {
/* the served page's entry needs an id too, or the first Back TO it has nothing to look up */
adoptEntry();
/* Page-scoped CSS keys off `#view[data-page]` and `.fs-content[data-page]`, not `body`
* (commitStage) — but the server stamps only `body`, so a document that never took a client
* navigation would otherwise have neither. Copied, not recomputed: the server's value is
* already the resolved dispatch path (header.ut, `ctx.path`), and re-deriving it here from the
* tree could disagree with what the served markup actually carries. */
const curPage = document.body ? (document.body.getAttribute('data-page') || '') : '';
const contentHost = document.querySelector('.fs-content');
if (contentHost) contentHost.setAttribute('data-page', curPage);
/* The document's own first render is the first link in the chain. A navigation waits for the
* previous render because a LuCI view chain resolves `#view` at paint time and would otherwise
* paint into the newer navigation's stage — and the first chain, `view.ut`'s inline
@@ -619,7 +667,10 @@ function seed() {
* keeps a document whose first view never renders from turning every later click into a
* rejected promise. */
const vp = document.getElementById('view');
if (vp) _inflight = renderedIn(vp).catch(() => {});
if (vp) {
vp.setAttribute('data-page', curPage);
_inflight = renderedIn(vp).catch(() => {});
}
}
/* ---- the incoming page is rendered off screen and swapped in when it is ready ----
@@ -711,6 +762,18 @@ function commitStage(stage, contentHost) {
sheets.scopeToCurrentPage();
sweepAround(contentHost);
const live = liveView(contentHost, stage);
/* …and the page-scoped CSS identity moves forward with it. `stage.view` carries the incoming
* page's name from navigate() (spared there the same way the sheets above were); this is the
* commit that finally moves it onto the element the reader is about to see and onto
* `.fs-content`, which the same CSS uses for content that sits beside `#view` rather than
* inside it (the Overview's stray `<h2 name="content">`, styles/pages/20-overview.css). Until
* this line the LIVE `#view` and `.fs-content` still carried the OUTGOING page's name, so its
* own page-scoped rules kept matching for the whole staging window. */
const page = stage.view.getAttribute('data-page');
if (page != null) {
live.setAttribute('data-page', page);
if (contentHost) contentHost.setAttribute('data-page', page);
}
const nodes = Array.from(stage.view.childNodes);
const dom = window.L ? window.L.dom : null;
if (live && dom && typeof dom.content === 'function')
@@ -753,9 +816,9 @@ function liveView(contentHost, stage) {
/* Clear what the outgoing page left as a SIBLING of #view inside .fs-content: dom.content()
* replaces only #view's own children, so anything a page emitted next to it rides along — the
* Overview template's `<h2 name="content">Status</h2>` is hidden by a `body[data-page=…]` rule and
* would show on every later page. Keep only the chrome that legitimately outlives a page (tabs,
* server notices, <noscript>) and the stage.
* Overview template's `<h2 name="content">Status</h2>` is hidden by a `.fs-content[data-page=…]`
* rule (styles/pages/20-overview.css) and would show on every later page. Keep only the chrome
* that legitimately outlives a page (tabs, server notices, <noscript>) and the stage.
*
* …and the RUNTIME notifications, which live one level up: `ui.addNotification()` inserts into
* #maincontent while the sweep above reaches only children of .fs-content. A full load clears them,
@@ -942,19 +1005,21 @@ function navigate(pathname, push, kbd) {
* on the layout — in the sidebar layout the window does not scroll, `.fs-shell` being 100dvh
* with `.fs-main` owning overflow-y (issue #7) — and scrollTo on the other is a no-op.
*
* The WRITE is at commitStage now, not here — see there. `_rest` is forgotten here regardless:
* it is the reference fs-fit tells a reader-caused scroll from an engine's clamp with, and the
* reader is committed to leaving this page from this point on, so a mutation the outgoing page's
* own poller makes during the staging window (before clearViewIntervals, further down this
* chain) must not be read against a reference that belongs to a page about to go away.
*
* A popstate replay resets nothing: both scrollers are restored there from _scrollMem.
* scrollRestoration stays 'auto' — the UA's own attempt lands before the swap and is undone by
* it, so it neither helps nor hurts, while 'manual' would take away the genuine full load. */
if (push) {
/* before the two writes: fs-fit keeps the offset the reader was last still at, to tell an
* engine's clamp from a reader who moved. This reset is neither, and it lands a whole
* require ahead of the `data-page` stamp fs-fit would notice it by, so a poll tick from the
* page being left would read it as a clamp. */
fit.forgetRest();
window.scrollTo(0, 0);
const sc = document.getElementById('maincontent');
if (sc) sc.scrollTo(0, 0);
}
* it, so it neither helps nor hurts, while 'manual' would take away the genuine full load.
*
* `forgetRest()` was gated on `push` despite the comment above already claiming "regardless" — a
* Back replay is leaving this page exactly as much as a click does, so the outgoing page's stale
* anchoring reference survived a popstate and could still be read against the page being
* restored into once fs-fit's own mutation observer next fires on it. */
fit.forgetRest();
/* ---- what a full load does for a keyboard/screen-reader user, and the SPA does not ----
* renderChrome() has just emptied #topmenu, so the <a> the user activated with Enter no longer
@@ -966,7 +1031,7 @@ function navigate(pathname, push, kbd) {
* rather than repeats the live region below. A pointer activation — and a popstate replay,
* whose modality is unknowable — keeps the wrapper focus, since focusing the skip link there
* would flash its overlay on every mouse click. preventScroll, because the scroll position is
* decided just above. */
* the OUTGOING page's own — untouched until commitStage — and must stay that way here too. */
const skip = kbd ? document.querySelector('.fs-skip') : null;
const main = skip || document.getElementById('maincontent');
if (main) main.focus({ preventScroll: true });
@@ -1041,16 +1106,18 @@ function navigate(pathname, push, kbd) {
const uciWarm = flushUciCache();
/* Keep <body data-page> in sync with the route: the server stamps the dispatch path on every
* full load and page-scoped CSS keys off it. `rsegs` is the resolved leaf, so a firstchild
* URL yields the same value however it is reached; without the re-stamp the incoming page
* keeps the previous page's name and its scoped styles silently do not apply.
* full load, and fs-chrome/fs-fit/fs-overview/menu-footstrap-common read it off `body` as the
* ROUTE'S identity — a cache-invalidation key and a "did the page change" flag, never a CSS
* scope. `rsegs` is the resolved leaf, so a firstchild URL yields the same value however it
* is reached. It sits before the staged render because those modules' own fitting and module
* loading must react to the incoming route before that render runs, same as always.
*
* It must sit before the staged render, because a view rendering under the wrong value
* measures itself through the wrong rules and the fitters run inside the stage. The cost is
* that the OUTGOING page, still on screen until the swap, wears the incoming page's name for
* the staging window — visible today only as the Overview's stray heading, which the sweep
* at the swap removes anyway. Moving the stamp later would trade that for a wrongly measured
* incoming page. */
* Page-scoped CSS no longer keys off `body[data-page]` — see the #view/`.fs-content` stamps
* below, which is where that identity now actually lives. Measured before the split: this
* write alone stopped 33 rules in styles/pages/20-overview.css from matching the OUTGOING
* page for the whole staging window (1407 ms on a cold require), growing the document 211px
* and moving the reader 140px — corrected here, not "visible today only as the Overview's
* stray heading" as this comment used to claim. docs/spa-router.md, "The staging window". */
document.body.setAttribute('data-page', rsegs.join('-'));
/* …and hand the new page to fs-sheets, which darkens every foreign sheet belonging to a
@@ -1065,6 +1132,13 @@ function navigate(pathname, push, kbd) {
sheets.scopeToCurrentPage(rsegs, leaving);
const stage = stageView(contentHost);
/* The stage's OWN identity, so the incoming render measures itself under its own page-scoped
* CSS (styles/pages/*.css keys off `#view[data-page]` now, not `body[data-page]`) while it is
* still hidden. The LIVE `#view` and `.fs-content` are not touched here — they keep the
* OUTGOING page's value, set at that page's own commitStage, until this navigation reaches
* its own commitStage and moves it forward. Same two-phase shape fs-sheets uses above:
* the incoming half is spared for the render, the outgoing half is swept at the swap. */
stage.view.setAttribute('data-page', rsegs.join('-'));
const painted = renderedIn(stage.view);
_seen.add(className);
/* Name the owner for the length of this require, and only when the module has yet to be
@@ -1093,6 +1167,20 @@ function navigate(pathname, push, kbd) {
* leave the live page to the newer navigation */
if (gen !== _navGen) { dropStage(stage); return; }
commitStage(stage, contentHost);
/* Reset both scrollers to the top HERE, in the same synchronous turn as the content
* swap above: a full load starts a new page at the top, and doing it here — rather
* than at the click, where it used to sit — means the reader keeps reading the
* OUTGOING page from wherever they were for the whole staging window instead of
* being thrown to its top the moment they click. Measured with the require held open
* (1.2 s, ../tmp/task-navflash/navflash-slow.mjs): `y` used to hit 0 within 12 ms of
* the click and stay there through the swap; moved here it stays at the reader's own
* offset for the whole window and reaches 0 in the same frame the new page appears.
* docs/anchoring.md, "The scroll reset". */
if (push) {
window.scrollTo(0, 0);
const sc = document.getElementById('maincontent');
if (sc) sc.scrollTo(0, 0);
}
/* now, and only now, is there one height to read: the incoming page's */
if (restoreTo) restoreScroll(restoreTo, gen);
})