The mid-history correction's momentum-skip gate keyed on the raw
`scrollTop` delta since baseline. On WebKit, momentum is coalesced into
`scrollTop` on the compositor's own async clock, so a frame that PAINTED
still can read a large raw delta and wrongly skip a genuine above-anchor
reflow — the reversal survivor bin (the 204px-class lurch of #1662).
Rekey the gate onto the RENDERED scroll — the painted wheel motion this
frame — `renderedScroll = aboveShift - ΔtopOffset`: the anchor's painted
on-screen move with the reflow's own push removed, both terms read from
the painted DOM (getBoundingClientRect), not the possibly-ahead
`scrollTop`. A rendered-still frame now reads ~0 and the reflow is
corrected instead of dropped.
Because the gate now needs `aboveShift` to decide, the band walk runs
before the gate. To keep "under-correcting is invisible; the next quiet
frame catches up," `sumAboveAnchorShift` no longer refreshes the height
cache inline — it STAGES the refresh and returns `{ aboveShift, commit }`.
The caller applies `commit()` only past the gate, so a momentum skip
leaves the cache for the next frame; a kept correction refreshes it as
one indivisible pass (the "second observer no-ops" invariant is intact).
Arbiter contract, fresh w4a-gate-1 build, both engines:
- raf-correction HARD gate: Chromium 0 reversals, rAF fires 0 / RO 21
(RO sole writer); WebKit 2 reversals max 14.5px (<=34 quantum floor),
rAF fires 24 / RO no-op (residual 0.0). Ratified invariants hold.
- slow-scroll: 0 reversals both engines (felt low-velocity regime clean).
- fast-classify DECOMPOSITION self-test (WebKit): pure-reflow attempts
median |signedShift|=18.5 but median |renderedScroll|=0.0 — the reflow
does NOT leak into the gated quantity, and 19/28 fire. Pure-scroll
|renderedScroll| runs up to 28. The decomposition is proven, not
asserted. Guarded WebKit-only: on Chromium the rAF is the passive loser
observer (RO corrects first), so renderedScroll there tracks signedShift
by construction — characterized, not asserted.
KNOWN RESIDUAL (surfaced, not hidden): 2 WebKit survivors remain, but
they are a DIFFERENT class — signedShift=0.0 (the band walk sees no
above-anchor reflow) while the row still lurches 14.5px. The momentum-gate
survivors the rekey targeted are gone; these are cross-check/null-target
skips where the reflow is invisible to the band walk. Separate diagnosis.
Diagnostic-only `renderedScroll` is threaded through the shared result
type + emitted on the rAF probe so the fixture can prove the
decomposition; stripped/gated before merge per the file-size exit
condition. Raises the useAnchoredScroll.ts size exception 1180->1210 to
cover the arm (biome reflows the 4-prop returns); flagged in the override
comment for Eva as heavy-for-a-diagnostic.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
The reading-anchor hook is the sole cross-engine scroll writer and the
survivor of the GUI-latency sweep's upscroll-reversal chase. The W4a
instrumentation legitimately inflated it past 1140: the signed-shift
grow/shrink probe (classifier arm) and the renderedScroll gate-clock rekey
(w4a-gate-1 arm), both carrying load-bearing provenance inline.
Per Eva's ruling, take the scoped override NOW and hold the structural
split until the arms in flight against this file settle — splitting a hook
whose provenance comments are themselves load-bearing (the anchor-margin
lesson lives in it) mid-chase is how a guard gets stripped. Exit condition
recorded at the override site: once #1662's fate is decided, the diagnostic
emits get stripped or gated and the mid-history corrector + band walk split
into a sibling module.
This also retires the --no-verify size bypass the classifier commit
(9df6d92e, 1156 lines) shipped under: the limit now covers it honestly, and
Sami independently flagged the same blocker on the branch.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Realizing upscroll made the reading row jump down then snap back — a
reversal frame pair, most visible on WebKit where the ResizeObserver for
a content-visibility realization delivers one frame LATE (paint N, RO
N+1). This lands design-of-record (c): a single ungated RO mid-history
corrector plus a per-rAF sampler, coordinating ONLY through a shared
row-height cache — no engine branch. The on-time observer per engine is
the sole writer:
- Chromium: the RO delivers pre-paint, so the ungated RO corrects at N
(by the anchor's own measured drift) and refreshes the cache; the
rAF then sees residual ~0 and no-ops.
- WebKit: the RO is late, so the rAF (whose synchronous
getBoundingClientRect forces this frame's layout) corrects at N; the
late RO no-ops against the rAF-refreshed cache.
The RO path is deliberately UNGATED by the rAF path's observed/above
agreement cross-check: that gate suppresses the rAF band walk's
straddler-miscount fabrication, a risk the RO does not have (its entries
are ground truth for which rows resized). Applying the gate to the RO
strangled the on-time Chromium observer and regressed it to 16
reversals. Straddler safety on the RO path comes instead from the
safe-margin reading anchor (snapshotReadingAnchor +
READING_ANCHOR_SAFE_MARGIN_PX), which sits a notch below the realization
band so a row realizing across the fold is above the anchor and summed
into the drift correctly — documented as a load-bearing invariant at the
site so a later refactor cannot strip it thinking the `changed` trigger
covers straddlers (it does not; the margin does).
Own red/green E2E fixture (upscroll-raf-correction.perf.ts), verified on
both Playwright engines against a stamped build:
Chromium 0 reversals max 0.0px RO 21 rAF 0
WebKit 2 reversals max 14.5px rAF 24 RO 0 (residual 0.0)
Asserts are a layered stack so a vacuous pass cannot slip through: a
build-stamp stale-`dist` guard, LIVENESS (some correction fired),
PRIMARY correctness (reversals <= 4, maxReversalPx <= 34, engine-
agnostic), and per-engine MECHANISM in both directions (chromium
roFires>0 && rafFires===0; webkit rafFires>0 && maxRoResidual<=0.5).
Layering self-test confirmed: disabling the ungated-RO write flips
PRIMARY red at 16 while LIVENESS stays green.
The hook is the sole cross-engine reading-anchor scroll writer and its
correctness lives in the per-site provenance comments, so it crossed the
1000-line file-size limit (888 at HEAD); added a documented, narrowly-
scoped override queued to split the mid-history corrector into a sibling.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>