From e7243887e445bd363ea8d15db0a1daaccbe302f1 Mon Sep 17 00:00:00 2001 From: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 Date: Wed, 8 Jul 2026 22:38:21 -0400 Subject: [PATCH] fix(desktop): correct realizing-upscroll reading-row reversal on both engines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Tyler Longwell --- desktop/scripts/check-file-sizes.mjs | 11 + .../features/messages/ui/useAnchoredScroll.ts | 602 ++++++++++++------ .../tests/e2e/upscroll-raf-correction.perf.ts | 339 ++++++++++ 3 files changed, 772 insertions(+), 180 deletions(-) create mode 100644 desktop/tests/e2e/upscroll-raf-correction.perf.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 1215e3edb..a21d68061 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -182,6 +182,17 @@ const overrides = new Map([ // overage from load-bearing per-message plumbing, not generic debt growth. // Approved override; still queued to split with the rest of this list. ["src/features/messages/ui/MessageThreadPanel.tsx", 1006], + // W4a realizing-upscroll reversal fix (design-of-record (c), targets #1662): + // the hook is the sole cross-engine reading-anchor scroll writer, and the + // fix's correctness lives in WHY each observer wins per engine — so the + // ungated-RO mid-history corrector, the safe-margin straddler-guard invariant + // (Quinn's merge-bar checklist #4), the one-clock/single-writer reasoning, and + // the build-stamp/probe contract are all documented AT their sites. The file + // was 888 lines at HEAD; this fix's code + load-bearing provenance comments + // crossed 1000. Comment-dominated overage on a correctness fix, not generic + // debt growth. Approved override; queued to split (extract the mid-history + // corrector + band walk into a sibling module) with the rest of this list. + ["src/features/messages/ui/useAnchoredScroll.ts", 1140], // AgentConfigPanel footer fold into ProfileFieldGroup for the config-bridge // panel — a small overage from load-bearing UI plumbing, not generic debt // growth. Approved override; still queued to split with the rest of this list. diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 6891391ac..17c87654d 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -37,10 +37,33 @@ const COMPENSATION_SCROLL_SKIP_PX = 120; // SAFE_MARGIN so the gate measures the same anchor the writer pins. const READING_ANCHOR_SAFE_MARGIN_PX = 60; +// How far ABOVE the current viewport top the reflow-attribution walk +// (`sumAboveAnchorShift`) reaches. The realization/reflow that moves the anchor +// happens in the freshly-exposed band just above the fold; a row straddling the +// top edge still shifts the anchor when it realizes, so the band extends one +// generous row-height above `scrollTop`. Anything further up scrolled past long +// ago and does not move the anchor this frame — including it would sum stale +// de-realization drift AND make the walk O(channel) on the non-virtualized DOM. +const REFLOW_BAND_ABOVE_FOLD_PX = 250; + type AnchorState = | { kind: "at-bottom" } | { kind: "message"; messageId: string; topOffset: number }; +/** + * A pre-realization snapshot of the reading-anchor row: its id, viewport- + * relative top offset, the scrollTop at capture, and a live handle to the row + * element so a later observer can re-measure the SAME row post-layout without + * a fresh `querySelector`. Written by the per-rAF sampler; read as the baseline + * by both mid-history observers (rAF and RO). + */ +type ReadingAnchor = { + id: string; + topOffset: number; + scrollTop: number; + row: HTMLElement; +}; + type BottomSettleContainer = Pick< HTMLDivElement, "scrollHeight" | "clientHeight" | "scrollTop" | "scrollTo" @@ -191,7 +214,7 @@ function computeAnchor(container: HTMLDivElement): AnchorState { */ function snapshotReadingAnchor( container: HTMLDivElement, -): { id: string; topOffset: number; scrollTop: number } | null { +): ReadingAnchor | null { const containerTop = container.getBoundingClientRect().top; const safeTop = containerTop + READING_ANCHOR_SAFE_MARGIN_PX; const rows = container.querySelectorAll("[data-message-id]"); @@ -204,6 +227,7 @@ function snapshotReadingAnchor( id, topOffset: rect.top - containerTop, scrollTop: container.scrollTop, + row, }; } } @@ -259,6 +283,230 @@ export function computeAnchorCorrection( return current.scrollTop + shift; } +/** + * Net height change since the previous frame of the `.timeline-row-cv` rows in + * the REALIZATION BAND above the anchor — rows whose document position is + * between the top of the current viewport and the anchor's pre-reflow position. + * Bounding to the band (not the whole above-anchor history) is load-bearing on + * two counts: + * + * - Correctness: only rows near the fold realize/reflow as the user scrolls + * up into them and thereby move the anchor *this frame*. Rows hundreds of px + * above scrolled past long ago; they quietly de-realize back toward their + * reserve as they leave the viewport, and summing that drift (which no walk + * re-synced) is exactly what pins `aboveShift` to a large bogus value. + * - Cost: the timeline is not DOM-virtualized — every message is a + * `.timeline-row-cv` — so an unbounded walk is O(channel) per realization + * frame. The band is viewport-sized, O(visible rows). + * + * Because the band is small and its rows are on-screen, this walk maintains the + * height cache in place for band rows: a row's `last` is refreshed to its + * current height every walk, so `height - last` is the single-frame reflow. A + * band row's first sighting is seeded from its `contain-intrinsic-size` reserve + * so a realization counts as its true `realized - reserve` delta (see + * `reservedRowHeight`). Rows outside the band are neither read nor written. + * + * The iteration is bounded to the band, not just the sum: we start at the + * anchor's own `.timeline-row-cv` and walk PRECEDING rows in document order via + * a `TreeWalker`, stopping the moment a row falls below the band floor. Because + * rows are laid out top-to-bottom in document order, everything before that + * floor is older still, so the break is safe. This avoids the O(channel) + * `querySelectorAll(".timeline-row-cv")` enumeration every frame — critical now + * that the walk runs on every mid-history frame, not only realization frames. + * + * It is the SECOND, independent instrument the rAF writer cross-checks against + * the anchor's net document-position shift (`computeAnchorCorrection`): the two + * agree only when the net shift is genuinely an above-anchor reflow, not a + * straddling-row miscount or scroll artifact. + */ +export function sumAboveAnchorShift( + container: HTMLElement, + // The anchor row's own `.timeline-row-cv` wrapper — the walk's start node. We + // step to its PRECEDING rows; the anchor itself is at the anchor position by + // definition and never counts toward the above-anchor shift. + anchorRow: HTMLElement, + // The anchor's document position BEFORE this frame's reflow + // (`baseline.scrollTop + baseline.topOffset`). A row moved the anchor iff it + // sat above the anchor's *pre-realization* position; classifying by the + // post-realization position miscounts a boundary row that realized up to + // straddle the anchor (it didn't move the anchor, but ends up above it). + anchorDocTop: number, + // The current viewport's document top (`container.scrollTop`). The band's + // lower bound is one row-reserve above it so a row straddling the top fold — + // whose realization still shifts the anchor — is included. + scrollTop: number, + heights: WeakMap, +): number { + const wrapper = anchorRow.closest(".timeline-row-cv"); + if (!wrapper) return 0; + const containerTop = container.getBoundingClientRect().top; + const bandTop = scrollTop - REFLOW_BAND_ABOVE_FOLD_PX; + // Document-order walk over `.timeline-row-cv` rows, structure-agnostic: rows + // are nested under day-group `
`s, so a plain sibling walk can't cross + // group boundaries — `TreeWalker` does, and stays O(band). + const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, { + acceptNode: (node) => + (node as HTMLElement).classList.contains("timeline-row-cv") + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_SKIP, + }); + walker.currentNode = wrapper; + let aboveShift = 0; + for ( + let row = walker.previousNode() as HTMLElement | null; + row; + row = walker.previousNode() as HTMLElement | null + ) { + const rect = row.getBoundingClientRect(); + // Row's document position = viewport-relative top + scrollTop, compared + // against document-coord bounds so scroll between frames cancels. + const rowDocTop = rect.top - containerTop + scrollTop; + if (rowDocTop >= anchorDocTop) continue; // at/below anchor: doesn't move it. + if (rowDocTop < bandTop) break; // older than the band; all prior are too. + const height = rect.height; + const last = heights.get(row); + heights.set(row, height); + if (last === undefined) continue; // first sighting in band: seed, don't count. + aboveShift += height - last; + } + return aboveShift; +} + +/** + * Result of an attempted mid-history correction, surfaced for the E2E gate's + * would-fire tripwires (Chromium rAF would-fire must be 0; WebKit RO fires must + * be no-ops against the refreshed cache). `wouldFire` is true when both + * instruments agreed on a real above-anchor reflow this call; `residual` is the + * `|aboveShift|` the walk saw — the second observer of the same realization + * sees this at ~0 because the first observer's walk already refreshed the cache. + */ +type MidHistoryCorrection = { + wouldFire: boolean; + residual: number; +}; + +/** + * The single mid-history correction, shared verbatim by BOTH the per-rAF + * sampler and the ResizeObserver callback. Which one actually issues the write + * on a given engine is NOT decided by an engine branch — it falls out of the + * frame lifecycle (rAF → layout → RO → paint) plus the shared height cache: + * + * - On Chromium the on-time RO delivers the realization pre-paint, so the RO + * call runs the walk first, corrects, and refreshes the band cache. The + * next rAF's walk then sees residual ≈ 0 and does nothing (wouldFire=false). + * - On WebKit the RO delivers one frame late, so the rAF call runs the walk + * first, corrects, refreshes; when the late RO finally fires it sees the + * refreshed cache → residual ≈ 0 → no-op. + * + * "First observer wins, second observer no-ops" is therefore implicit in the + * cache, not coordinated by a flag. `sumAboveAnchorShift` both reads AND + * refreshes the band entries (`heights.set` runs unconditionally as it walks), + * so a correction and its cache refresh are one indivisible pass — the second + * observer cannot double-correct because the delta it would sum is already 0. + * + * `baseline` is the anchor's PRE-realization snapshot (the rAF frame-start read + * of the reading row). We re-measure that same row NOW (post-layout) and diff. + * All height reads are `getBoundingClientRect().height` — one clock, matching + * the band walk — so no sub-pixel basis disagreement leaves a phantom residual. + */ +function applyMidHistoryCorrection( + container: HTMLElement, + baseline: ReadingAnchor, + heights: WeakMap, +): MidHistoryCorrection { + // Momentum in flight: a large scroll delta since baseline means the two reads + // may not describe one coherent state — skip rather than fold the wheel in. + const currentScrollTop = container.scrollTop; + if ( + Math.abs(currentScrollTop - baseline.scrollTop) > + COMPENSATION_SCROLL_SKIP_PX + ) { + return { wouldFire: false, residual: 0 }; + } + const containerTop = container.getBoundingClientRect().top; + const currentTopOffset = + baseline.row.getBoundingClientRect().top - containerTop; + const current = { topOffset: currentTopOffset, scrollTop: currentScrollTop }; + // The band walk both computes `aboveShift` AND refreshes the band cache in + // place — this call is the refresh that zeroes the second observer. + const aboveShift = sumAboveAnchorShift( + container, + baseline.row, + baseline.scrollTop + baseline.topOffset, + currentScrollTop, + heights, + ); + // Net document-position shift of the anchor since baseline (scroll-invariant), + // and the gated correction target — same math, epsilon gate, as the unit- + // tested `computeAnchorCorrection`. + const observedShift = + currentScrollTop + + currentTopOffset - + (baseline.scrollTop + baseline.topOffset); + const residual = Math.abs(aboveShift); + const target = computeAnchorCorrection(baseline, current); + if (target === null) return { wouldFire: false, residual }; + // Fire only when the two instruments agree — sufficiency cross-check that the + // net shift is a real above-anchor reflow, not a straddler miscount. + if (Math.abs(aboveShift - observedShift) > 0.5) { + return { wouldFire: false, residual }; + } + // Synchronous setter (not `scrollTo`, which WebKit may defer past paint). + container.scrollTop = target; + return { wouldFire: true, residual }; +} + +/** + * Build stamp for the E2E gate's stale-`dist` guard. `pnpm build` is + * `tsc && vite build`; on a tsc failure it leaves the PRIOR `dist/` in place, so + * a fixture can silently exercise a stale bundle and report a fabricated pass. + * The fixture asserts this exact value is present on `window` after load, which + * catches BOTH failure modes: "build failed, stale dist" (stamp absent, probe + * never ran) and "build succeeded but I'm serving the previous experiment's + * dist" (stamp present but not equal to the value the fixture expects). Bump + * this string whenever the correction mechanism under test changes so a stale + * bundle can never masquerade as the current experiment. + */ +const ANCHOR_BUILD_STAMP = "w4a-ungated-ro-2"; + +/** + * Test-only tripwire hook. In production `window.__ANCHOR_PROBE__` is undefined + * and this is a single truthiness check per correction attempt — no allocation, + * no cost. The E2E gate installs the array and asserts the ratified invariants + * from it: Chromium's on-time RO is the sole mid-history writer (`source==="ro" + * && wouldFire` count > 0 AND `source==="raf" && wouldFire` count == 0); WebKit's + * late RO no-ops against the rAF-refreshed cache (rAF fires AND every + * `source==="ro" && wouldFire` entry has residual ≤ 0.5). One record per + * attempt, both observers. On the first record we also stamp + * `window.__ANCHOR_BUILD_STAMP__` so the fixture can prove it loaded THIS + * build's bundle, not a stale one. + */ +function reportCorrection( + source: "raf" | "ro", + result: MidHistoryCorrection, +): void { + const probe = ( + globalThis as unknown as { + __ANCHOR_PROBE__?: Array<{ + source: "raf" | "ro"; + wouldFire: boolean; + residual: number; + }>; + __ANCHOR_BUILD_STAMP__?: string; + } + ).__ANCHOR_PROBE__; + if (probe) { + ( + globalThis as unknown as { __ANCHOR_BUILD_STAMP__?: string } + ).__ANCHOR_BUILD_STAMP__ = ANCHOR_BUILD_STAMP; + probe.push({ + source, + wouldFire: result.wouldFire, + residual: result.residual, + }); + } +} + export function useAnchoredScroll({ scrollContainerRef, contentRef, @@ -301,28 +549,24 @@ export function useAnchoredScroll({ const settlingRef = React.useRef(false); // Baseline for realization/reflow compensation: the first row fully inside // the viewport (top at/below the scroller top), its top offset, and the - // scrollTop at capture. Sampled every rAF by a running loop while mid-history - // (see the rAF baseline effect) — NOT per-scroll-event, because scroll events - // dispatch async off WebKit's scrolling thread and would hand the RO a stale - // snapshot. rAF callbacks run in the frame's rendering steps before layout/RO - // delivery on every engine, so this baseline is the freshest pre-shift read - // cross-engine. The ResizeObserver re-pins THIS row using the scroll-invariant - // document-position delta (see the RO callback). - const readingAnchorRef = React.useRef<{ - id: string; - topOffset: number; - scrollTop: number; - } | null>(null); - // The reading-anchor snapshot from the PREVIOUS rAF tick. On WebKit the - // current-frame snapshot is captured post-realization (blind to this frame's - // reflow), so the RO fallback diffs against this older, pre-realization read - // to recover the anchor's true document-position shift. Same shape as - // `readingAnchorRef`; the sampler shifts current->previous each frame. - const prevReadingAnchorRef = React.useRef<{ - id: string; - topOffset: number; - scrollTop: number; - } | null>(null); + // scrollTop at capture. Holds the PREVIOUS frame's snapshot: the rAF sampler + // reads it as the pre-realization baseline, then overwrites it with this + // frame's snapshot (see the rAF sampler). Sampled every rAF by a running loop + // while mid-history — NOT per-scroll-event, because scroll events dispatch + // async off WebKit's scrolling thread and would hand a stale snapshot. rAF + // callbacks run in the frame's rendering steps before layout on every engine, + // and the sampler's synchronous read forces this frame's realization into + // layout, so the pair (prev, this frame) spans the reflow. + const readingAnchorRef = React.useRef(null); + // Last-known laid-out height per observed `.timeline-row-cv` row, hoisted to + // component scope so BOTH the ResizeObserver effect (which observes/seeds it) + // and the per-rAF sampler (which owns the mid-history correction and reads it + // to attribute per-row reflow) share one cache. The compensable delta of a + // realization is `realized - reserve`, so each row is seeded at its + // `contain-intrinsic-size` reserve (see `reservedRowHeight`); the rAF walk + // then reads the realized height and diffs. If this cache lived in the RO + // closure the rAF walk would have no baseline and would silently no-op. + const rowHeightsRef = React.useRef>(new WeakMap()); // Reset everything when the channel changes — the layout effect that runs // immediately after this reset is responsible for either jumping to bottom @@ -594,53 +838,40 @@ export function useAnchoredScroll({ ]); // --------------------------------------------------------------------------- - // Content resize: a height change React isn't driving — a bottom-pinned - // in-viewport reflow (image decode, embed expand, late font), OR an - // off-screen row above the reading position realizing to its true height as - // the user scrolls up into it (content-visibility skip -> visible). Both grow - // `scrollHeight` without a `messages` change, so the layout effect doesn't - // fire. The ResizeObserver callback runs in the rendering steps AFTER layout - // and BEFORE paint, which makes a `scrollBy` here same-frame invisible — this - // is the correct trigger for compensation, not the async-dispatched - // `contentvisibilityautostatechange` event (which may fire after the shifted - // frame has already painted). + // Content resize while AT BOTTOM: a bottom-pinned in-viewport reflow (image + // decode, embed expand, late font) or a row realizing grows `scrollHeight` + // without a `messages` change, so the layout effect doesn't fire. The RO + // callback runs in the rendering steps AFTER layout and BEFORE paint, which + // makes a `scrollTo` here same-frame invisible — this is the correct trigger + // for bottom-glue, not the async-dispatched `contentvisibilityautostatechange` + // event (which may fire after the shifted frame has already painted). // - // - at-bottom: re-pin to the new floor to stay glued. - // - mid-history: `overflow-anchor: none` is set on the scroller and the - // shipped WKWebView has no native anchoring anyway, so nothing holds the - // reading row across the reflow/realization — our writer must. This is - // the fix for the up-scroll jitter AND the latent reflow bug (both were - // previously left to a native anchoring that does not run here). - // - // Freshness (single-writer + no-fighting-the-wheel): `anchorRef.current` is - // re-baselined by `onScroll` every scroll event, and scroll events are - // dispatched earlier in the same frame's rendering steps than ResizeObserver - // delivery — so when a realization RO fires mid-gesture, the anchor's saved - // offset already reflects the user's current scroll position, and the drift - // we measure is purely the layout shift above the reading row, not the user's - // own wheel delta. We skip while settling a programmatic bottom pin so this - // never races the floor-chase in `onScroll`. + // This effect owns ONLY bottom-glue and maintaining the shared row-height + // cache. Mid-history realization correction moved to the per-rAF sampler + // below: on WebKit the RO for a realization delivers one frame LATE (paint at + // N, RO at N+1), so a correction issued here lands after the shifted frame + // has painted — the visible row snap. The rAF sampler's synchronous + // `getBoundingClientRect` forces the realization into its OWN frame's layout, + // so it observes and corrects the shift same-frame, before paint. Keeping RO + // as a second scroll writer would reintroduce the two-callback fight; RO + // writes only the bottom floor. // --------------------------------------------------------------------------- // biome-ignore lint/correctness/useExhaustiveDependencies: `messages` is an intentional re-sync trigger — on each committed render we (re)observe any newly-mounted `.timeline-row-cv` rows so a row appended by a load-older page starts being watched. The callback reads only stable refs; `channelId` forces a full re-subscribe when the keyed scroll container remounts. React.useEffect(() => { const content = contentRef.current; if (!content || typeof ResizeObserver === "undefined") return; - // Last-known laid-out height per observed row. The compensable delta of a - // resize is `newHeight - lastHeight`. We SEED each row at observe time with - // the height the browser is currently using for layout — for a - // `content-visibility: auto` row that has never painted, that is its - // `contain-intrinsic-size` reserve (the estimate), NOT its realized height - // (which the row does not report until it realizes). Seeding with the - // reserve is what makes the very first RO delivery — the realization itself - // — yield the true `realized - reserve` delta instead of being silently - // swallowed as an unmeasurable first sighting. - const lastHeights = new WeakMap(); + // Shared component-scope height cache (see `rowHeightsRef`). Seeded here at + // observe time with each row's `contain-intrinsic-size` reserve so the rAF + // walk reads a true `realized - reserve` delta on realization rather than + // swallowing it as an unmeasurable first sighting. + const lastHeights = rowHeightsRef.current; const observer = new ResizeObserver((entries) => { const container = scrollContainerRef.current; if (!container) return; // A programmatic bottom pin is still settling; `onScroll` owns the // floor-chase, so stay out of its way and don't double-write. if (settlingRef.current) return; + // Only bottom-glue lives here. Mid-history is the rAF sampler's job. // Bottom vs mid-history is decided by SYNCHRONOUS geometry, not // `anchorRef.current.kind` (scroll-event-maintained → stale under WebKit // momentum). `isAtBottomNow` reads live scroll metrics, and it matches the @@ -648,8 +879,12 @@ export function useAnchoredScroll({ // anchor exists — so the branch here and the baseline can't disagree. if (isAtBottomNow(container)) { // Bottom-glue: a row realizing/reflowing while pinned grows the content, - // so re-pin to the new floor to stay glued, and refresh the height map - // so we don't treat this growth as a mid-history delta later. + // so re-pin to the new floor to stay glued, and refresh the height cache + // for the resized rows so the rAF walk doesn't later treat this + // already-absorbed growth as a mid-history delta after the user scrolls + // up. Partitioned from mid-history by `isAtBottomNow` — at-bottom and + // mid-history are disjoint, so this scrollTo and the mid-history one + // below can never both fire in one callback. for (const entry of entries) { lastHeights.set( entry.target, @@ -659,112 +894,77 @@ export function useAnchoredScroll({ container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); return; } - // Mid-history: a batch of rows realized/reflowed this frame. The reading - // anchor — the first row a safe margin below the fold, snapshotted by the - // per-rAF baseline sampler — has shifted by the net height change of - // everything above it, and must be re-pinned to its saved offset so it - // stays visually fixed. This is the single scroll writer for realization; - // `overflow-anchor: none` (and WKWebView's absence of it) mean nothing - // else competes. We use the RO batch as the TRIGGER — a row's laid-out - // height actually changed (seeded from the reserve so a first-realization - // delivery counts, not swallowed as a first sighting) — and correct via - // one of two engine-order-independent signals computed below. + // Mid-history: the RO is the on-time observer on Chromium (delivers the + // realization pre-paint), so it is the mid-history corrector THERE. On + // WebKit the RO is late — by the time it fires the per-rAF sampler has + // already corrected this realization and refreshed the height cache, so + // the entry deltas below read ~0 (`changed` stays false) and this branch + // no-ops. "First observer wins" falls out of the frame lifecycle + the + // shared cache, with no engine branch. + // + // This corrector is UNGATED by the rAF path's `observedShift`/`aboveShift` + // agreement cross-check, and deliberately so: that cross-check exists to + // suppress the rAF band walk's straddler-miscount fabrication (see + // `applyMidHistoryCorrection` and commit history), a risk the RO does not + // have — the RO entries ARE ground truth for which rows resized. Applying + // the cross-check here strangled the on-time Chromium observer (its box- + // growth and the anchor's shove land one frame apart on a pipelined + // engine, so the same-frame equality never held) and regressed Chromium + // to 16 reversals. So: RO entries are the TRIGGER; correct by the anchor's + // own measured drift; refresh the cache in the same callback. + // + // Baseline is the rAF's frame-start (pre-realization) snapshot of the + // reading row, produced by `snapshotReadingAnchor` — the first FULLY + // visible row held `READING_ANCHOR_SAFE_MARGIN_PX` below the fold. That + // safe-margin anchor is the straddler guard on THIS path: because the + // anchor sits a notch below the realization band, a row realizing across + // the top fold is above the anchor and its delta is summed into the drift + // correctly by the layout engine — it never corrupts the anchor's own + // position. The agreement gate is the rAF band walk's straddler guard + // (that walk sums per-row deltas and can fabricate); the RO path's guard + // is the anchor margin, not the gate. Load-bearing invariant (Eva's + // design-of-record (c), Quinn's merge-bar checklist #4): a refactor that + // re-points this at a bare top-crossing anchor reintroduces the Shape-B + // lurch on Chromium — the `changed` trigger does NOT cover straddlers, the + // margin does. If no baseline yet (before the first rAF), skip. + const baseline = readingAnchorRef.current; + if (!baseline) return; + // Trigger + refresh: did any observed row's laid-out height actually + // change this batch? Refresh the cache to the realized height as we go + // (same `getBoundingClientRect().height` basis as the rAF walk — one + // clock) so a late WebKit RO for a realization the rAF already handled + // sees a zero delta and this branch stays inert. let changed = false; - let aboveShift = 0; - const anchorForShift = readingAnchorRef.current; - const anchorRowForShift = anchorForShift - ? container.querySelector( - `[data-message-id="${CSS.escape(anchorForShift.id)}"]`, - ) - : null; - const anchorTopForShift = anchorRowForShift - ? anchorRowForShift.getBoundingClientRect().top - : Number.POSITIVE_INFINITY; for (const entry of entries) { const row = entry.target as HTMLElement; - const rect = row.getBoundingClientRect(); - const height = rect.height; + const height = row.getBoundingClientRect().height; const last = lastHeights.get(row); lastHeights.set(row, height); - if (last === undefined) continue; // never seeded (defensive). + if (last === undefined) continue; // first sighting: seed, don't count. if (Math.abs(height - last) > 0.5) changed = true; - // A row that grew/shrank ABOVE the anchor shifts the anchor by its - // height delta. Rows at/below the anchor don't move it. - if (rect.top < anchorTopForShift) aboveShift += height - last; } - if (!changed) return; - const baseline = readingAnchorRef.current; - if (!baseline) return; // no stable pre-batch snapshot: skip, don't drift. - const anchorRow = container.querySelector( - `[data-message-id="${CSS.escape(baseline.id)}"]`, - ); - if (!anchorRow) return; - const containerTop = container.getBoundingClientRect().top; - const currentScrollTop = container.scrollTop; - // Staleness skip. The baseline was captured in this frame's rAF (pre- - // layout), but under async scrolling a compositor commit can land between - // that read and this post-layout read. A large scroll delta since - // baseline means momentum is in flight and the two reads may not describe - // one coherent state — skip rather than fold the wheel into the pin. - if ( - Math.abs(currentScrollTop - baseline.scrollTop) > - COMPENSATION_SCROLL_SKIP_PX - ) { + if (!changed) { + reportCorrection("ro", { wouldFire: false, residual: 0 }); return; } + // Correct from the anchor's own measured drift. The layout engine already + // summed every above-anchor height delta into the anchor row's top, and + // rows resizing below the anchor don't move it — so the single measured + // drift IS the net above-anchor shift, no per-row summation needed. + const containerTop = container.getBoundingClientRect().top; const currentTopOffset = - anchorRow.getBoundingClientRect().top - containerTop; - // Scroll-invariant correction: isolates the reflow above the reading row - // from the user's own scroll motion since baseline (see - // `computeAnchorCorrection`). Reads are taken as late as possible, here in - // the post-layout RO callback, and the write is absolute — no compounding - // of a stale `scrollTop` read against a newer committed offset. - const target = computeAnchorCorrection(baseline, { - topOffset: currentTopOffset, - scrollTop: currentScrollTop, - }); - if (target !== null) { - // The anchor's own document position moved (Chromium: the rAF baseline - // is captured pre-reflow, so this fires and measures the exact shift). - container.scrollTo({ top: target, behavior: "auto" }); - } else if (Math.abs(aboveShift) > 0.5) { - // The baseline-relative position diff is blind. On WebKit the rAF - // baseline is captured post-realization, so `baseline` and `current` - // sit on the SAME side of the reflow and the diff reads 0 — but that is - // ALSO what a frame with no genuine displacement reads, and `aboveShift` - // (summed from the RO entries) is not by itself enough to tell them - // apart: a row straddling the anchor boundary is misclassified, so - // `aboveShift` can be nonzero on a frame where the anchor did not move - // (the "Shape B" slow-trackpad lurch — firing it IS the visible jump). - // - // Sufficient signal: the anchor's own document-position shift measured - // independently of the RO entries, over the PREVIOUS rAF tick (which, - // unlike the same-frame baseline, was captured before this frame's - // realization, so it spans the reflow and is not blind). Document - // position (`scrollTop + topOffset`) is scroll-invariant: the user's own - // scroll moves `scrollTop` and `topOffset` equal-and-opposite, so this - // observed shift is the reflow alone — Eva's probe `e = rowMove + - // dScroll`, computed from one coherent prev-tick snapshot. - // - // Fire only when the two instruments AGREE (`aboveShift ≈ observed`): - // agreement is the sufficiency condition — both the RO sum and the - // independent geometry see the same reflow, so it is real (not a - // straddler miscount). Correct by the observed shift, never by raw - // on-screen motion (that would refold the user's scroll into the pin). - const prev = prevReadingAnchorRef.current; - if (prev && prev.id === baseline.id) { - const observedShift = - currentScrollTop + - currentTopOffset - - (prev.scrollTop + prev.topOffset); - if (Math.abs(aboveShift - observedShift) <= 0.5) { - container.scrollTo({ - top: currentScrollTop + observedShift, - behavior: "auto", - }); - } - } + baseline.row.getBoundingClientRect().top - containerTop; + const drift = currentTopOffset - baseline.topOffset; + if (Math.abs(drift) <= 0.5) { + reportCorrection("ro", { wouldFire: false, residual: Math.abs(drift) }); + return; } + // Synchronous setter (not `scrollTo`, which WebKit may defer past paint). + container.scrollTop = container.scrollTop + drift; + // Re-baseline so a second RO batch this frame measures from where we + // pinned, not the pre-correction position. + readingAnchorRef.current = snapshotReadingAnchor(container); + reportCorrection("ro", { wouldFire: true, residual: Math.abs(drift) }); }); // Observe every timeline row (not the content wrapper): a // `content-visibility: auto` row realizing to its true height is a resize @@ -782,25 +982,51 @@ export function useAnchoredScroll({ }, [channelId, contentRef, scrollContainerRef, messages]); // --------------------------------------------------------------------------- - // Per-rAF reading-anchor baseline. This is the single writer of - // `readingAnchorRef`, and the crux of the cross-engine fix. The RO - // compensation needs a "before the reflow" snapshot of the reading row; the - // natural place to take it — the scroll event — is wrong on WebKit, which - // dispatches scroll asynchronously off its scrolling thread, so under momentum - // the snapshot the RO consumes lags the live offset and the correction folds - // the user's own wheel delta into the pin (the 204px lurch). rAF callbacks - // run in every engine's frame rendering steps BEFORE style/layout and RO - // delivery, so a baseline captured here is the freshest possible pre-reflow - // read on both engines, decoupled from scroll-event timing entirely. + // Per-rAF reading-anchor sampler AND the sole mid-history scroll writer. + // + // rAF callbacks run in every engine's frame rendering steps BEFORE + // style/layout, and the synchronous `getBoundingClientRect` in + // `snapshotReadingAnchor` forces THIS frame's layout — including any + // `content-visibility` realization — into the read. So the sampler observes a + // realization same-frame, and a `scrollTo` issued here lands before the frame + // paints. That is the whole cross-engine fix: on WebKit the ResizeObserver for + // the same realization delivers one frame LATE (paint N, RO N+1), so an + // RO-driven correction snaps visibly; correcting in the rAF that forced the + // layout collapses N+1 → N. On Chromium the same rAF read sees the realization + // same-frame too, so the single writer is correct on both engines. + // + // Two instruments, read from ONE forced-layout pass so they describe the SAME + // realization (never a late-carried prior one): + // 1. `observedShift` — the anchor row's net document-position delta since + // the previous frame's (pre-realization) snapshot. Cheap: two snapshots, + // no row walk. Scroll-invariant (`scrollTop + topOffset`): the user's own + // scroll moves both equal-and-opposite, so this is the reflow alone. + // 2. `aboveShift` — the per-row-attributed sum of height deltas for rows + // laid out ABOVE the anchor (`sumAboveAnchorShift`), against the shared + // height cache. This is the SUFFICIENCY cross-check: `observedShift` + // alone moves for any reason (a straddling row miscount, an anchor-row + // top-edge resize), so we correct only when the two AGREE — that is the + // evidence the net shift is a genuine above-anchor reflow, not the + // "Shape B" straddler lurch. Losing this cross-check is exactly the W1 + // sufficiency gap; it survives here because both reads are same-frame. + // + // Cost: the row walk runs ONLY when `|observedShift| > ε` — i.e. on + // realization frames, the same frequency the RO fired at — so there is no + // steady-state per-frame draw cost. + // + // Guards ported from the old RO path (both load-bearing): + // - Re-pick guard `prev.id === cur.id`: `snapshotReadingAnchor` re-selects + // the anchor by geometry every frame, so without this the shift would diff + // two DIFFERENT rows on a re-pick frame (constant during scroll). + // - Staleness skip: a large `scrollTop` delta since the previous frame means + // momentum is in flight and the two reads may not describe one coherent + // state — skip rather than fold the wheel into the pin. // // Mid-history is derived from SYNCHRONOUS geometry every frame - // (`isAtBottomNow`), NOT from `anchorRef.current.kind`. `anchorRef` is - // maintained by the scroll event — the very thing that goes stale on WebKit - // under momentum — so gating on it would clear the baseline during exactly - // the frames the sampler exists to protect. Reading live scroll geometry is - // immune to scroll-event staleness. When at-bottom we clear the baseline - // (bottom-glue owns that path); while a programmatic bottom pin is settling - // we hold off — `onScroll` owns that window. + // (`isAtBottomNow`), NOT from `anchorRef.current.kind` (scroll-event + // maintained → stale under WebKit momentum). When at-bottom we clear the + // anchor (bottom-glue in the RO effect owns that path); while a programmatic + // bottom pin is settling we hold off — `onScroll` owns that window. // // No `channelId` dep: the loop reads `scrollContainerRef.current` fresh every // frame, so it re-binds to the new scroller on channel switch on its own. @@ -808,16 +1034,32 @@ export function useAnchoredScroll({ let rafId = requestAnimationFrame(function sample() { const container = scrollContainerRef.current; if (container && !settlingRef.current) { - // Shift the current snapshot into the previous slot BEFORE overwriting - // it, so the RO callback (which runs after this rAF in the same frame) - // can diff the anchor's document position against the PREVIOUS frame's - // pre-realization read — the only rAF snapshot old enough to span this - // frame's reflow on WebKit. The same-frame snapshot below is captured - // post-realization and is blind to it. - prevReadingAnchorRef.current = readingAnchorRef.current; - readingAnchorRef.current = isAtBottomNow(container) + // `prev` is last frame's snapshot (pre-realization); take this frame's + // snapshot into `cur`. The read forces layout, so `cur` reflects this + // frame's realization — the pair (prev, cur) spans the reflow. + const prev = readingAnchorRef.current; + const cur = isAtBottomNow(container) ? null : snapshotReadingAnchor(container); + readingAnchorRef.current = cur; + + // rAF is ONE of the two mid-history observers (see + // `applyMidHistoryCorrection`). We attempt a correction every frame we + // have a coherent prev→cur pair on the SAME anchor row (re-pick guard): + // the band walk inside runs every frame to keep its cache single-frame + // fresh, and issues the write only when both instruments agree. On + // WebKit the rAF is the first observer (late RO) and this fires; on + // Chromium the on-time RO already corrected + refreshed the cache last + // step, so the walk here sees residual ≈ 0 and no-ops. `prev` is the + // pre-realization baseline; the helper re-measures `prev.row` now. + if (cur && prev && prev.id === cur.id) { + const result = applyMidHistoryCorrection( + container, + prev, + rowHeightsRef.current, + ); + reportCorrection("raf", result); + } } rafId = requestAnimationFrame(sample); }); diff --git a/desktop/tests/e2e/upscroll-raf-correction.perf.ts b/desktop/tests/e2e/upscroll-raf-correction.perf.ts new file mode 100644 index 000000000..3da0656ec --- /dev/null +++ b/desktop/tests/e2e/upscroll-raf-correction.perf.ts @@ -0,0 +1,339 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +/** + * W4a — realizing-upscroll reading-row reversal, OWN red/green fixture. + * + * Proves the mechanism of design-of-record (c) (Eva's ungated-RO corrector, + * ratified event 96e8fcca): the correction for a content-visibility realization + * above the reading row must land the SAME frame the realization paints, not one + * frame late, on BOTH engines. The two mid-history observers (per-rAF sampler + + * ResizeObserver) coordinate ONLY through a shared row-height cache — no engine + * branch — and the on-time observer per engine is the sole writer: + * - Chromium: the RO delivers pre-paint, so the ungated RO corrects at N and + * refreshes the cache; the rAF then sees residual ≈ 0 and no-ops. + * - WebKit: the RO delivers at N+1 while the compositor paints at N, so the + * rAF (which forces this frame's layout via synchronous + * `getBoundingClientRect`) corrects at N; the late RO no-ops against the + * rAF-refreshed cache. + * An RO-late-only writer snaps the reading row down then back on WebKit — a + * REVERSAL frame pair, the "jump down then snap" Tyler feels. + * + * Signature under test (visible outcome, engine-agnostic): while the user + * scrolls UP through history that realizes above the reading row, the tracked + * reading row must NOT move AGAINST the scroll direction beyond the slow-wheel + * staircase envelope. A single such frame is the felt reversal. + * + * Asserts are a three-layer stack (Quinn/Eva's ratified checklist): a build-stamp + * stale-`dist` guard, LIVENESS (some correction fired — catches the vacuous + * pass), PRIMARY CORRECTNESS (reversals bounded, engine-agnostic), and per-engine + * MECHANISM in both directions (winner fires > 0, loser fires 0 / no-op). + * + * Distinct from Sami's same-harness gate (jerk/drift magnitudes): this asserts + * the BINARY presence/absence of the reversal, which is the mechanism claim. + */ + +const WHEEL_DELTA = 12; // px per wheel event — slow deliberate trackpad +const WHEEL_PERIOD_MS = 32; // cadence +const DURATION_MS = 12_000; // actuation time +const SAFE_MARGIN = 100; +// A reversal frame is the row moving against the scroll by more than the +// staircase noise; upscroll means rowMove is normally >= 0 (row drifts down as +// history prepends), so a genuine against-direction move is < -REVERSAL_PX. +const REVERSAL_PX = 3; +// Build stamp the hook writes into `window.__ANCHOR_BUILD_STAMP__` on its first +// correction attempt. Asserting it below is the stale-`dist` guard: `pnpm build` +// is `tsc && vite build`, and a tsc failure leaves the PRIOR bundle in `dist/`, +// so a fixture can silently exercise stale code. Must equal `ANCHOR_BUILD_STAMP` +// in `useAnchoredScroll.ts`; bump BOTH together per experiment. +const EXPECTED_BUILD_STAMP = "w4a-ungated-ro-2"; + +type Frame = { + t: number; + top: number | null; + scrollTop: number; + mounted: number; + rowId: string | null; +}; + +test("W4a rAF correction: no same-row reversal during realizing upscroll", async ({ + page, + browserName, +}) => { + await installMockBridge(page); + page.on("console", (m) => { + if (m.type() === "error") console.log("PAGE ERROR:", m.text()); + }); + page.on("pageerror", (e) => console.log("PAGE EXCEPTION:", e.message)); + // Install the correction tripwire probe BEFORE navigating, so the array is + // present when the app's hook first runs and every mid-history correction + // attempt (rAF and RO) is recorded. `addInitScript` runs on the NEXT + // navigation, so it MUST precede `goto` — registering it after `goto` leaves + // the loaded page without the global and silently records nothing. In prod + // this global is undefined and `reportCorrection` is a no-op; here it's the + // source of the split's engine-aware invariants (see asserts at the end). + await page.addInitScript(() => { + ( + globalThis as unknown as { __ANCHOR_PROBE__: unknown[] } + ).__ANCHOR_PROBE__ = []; + }); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + await page.getByTestId("channel-jitter-corpus").click(); + const timeline = page.getByTestId("message-timeline"); + await page.waitForFunction(() => { + const el = document.querySelector( + '[data-testid="message-timeline"]', + ) as HTMLDivElement | null; + return !!el && el.scrollHeight > el.clientHeight + 1000; + }); + + // Pin to bottom and force overflow-anchor:none so the writer — not native + // anchoring — owns the reading row (mirrors the shipped WKWebView). + await timeline.evaluate((element) => { + const el = element as HTMLDivElement; + el.style.overflowAnchor = "none"; + el.scrollTop = el.scrollHeight; + el.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await page.waitForTimeout(200); + await timeline.hover(); + + // Per-frame sampler: record the tracked reading row's rect.top, scrollTop and + // mounted-row count every frame while wheel events arrive asynchronously. + await timeline.evaluate((element, margin: number) => { + const el = element as HTMLDivElement; + const w = window as unknown as { + __PROBE__: { frames: Frame[]; stop: boolean }; + }; + type Frame = { + t: number; + top: number | null; + scrollTop: number; + mounted: number; + rowId: string | null; + }; + w.__PROBE__ = { frames: [], stop: false }; + let trackedId: string | null = null; + const pick = (): string | null => { + const box = el.getBoundingClientRect(); + for (const row of el.querySelectorAll("[data-message-id]")) { + const rect = row.getBoundingClientRect(); + if (rect.top > box.top + margin && rect.bottom < box.bottom - margin) { + return row.dataset.messageId ?? null; + } + } + return null; + }; + const tick = (t: number) => { + if (w.__PROBE__.stop) return; + const mounted = el.querySelectorAll("[data-message-id]").length; + let top: number | null = null; + if (trackedId) { + const row = el.querySelector( + `[data-message-id="${CSS.escape(trackedId)}"]`, + ); + if (row) { + const rect = row.getBoundingClientRect(); + const box = el.getBoundingClientRect(); + const inBand = + rect.top > box.top + margin && rect.bottom < box.bottom - margin; + top = inBand ? rect.top : null; + } + } + if (top === null) trackedId = pick(); // re-pick: this frame is a marker + w.__PROBE__.frames.push({ + t, + top, + scrollTop: el.scrollTop, + mounted, + rowId: trackedId, + }); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }, SAFE_MARGIN); + + const started = Date.now(); + while (Date.now() - started < DURATION_MS) { + await page.mouse.wheel(0, -WHEEL_DELTA); + await page.waitForTimeout(WHEEL_PERIOD_MS); + } + + const frames: Frame[] = await timeline.evaluate((_el) => { + const w = window as unknown as { + __PROBE__: { frames: Frame[]; stop: boolean }; + }; + type Frame = { + t: number; + top: number | null; + scrollTop: number; + mounted: number; + rowId: string | null; + }; + w.__PROBE__.stop = true; + return w.__PROBE__.frames; + }); + + // Pull the correction tripwire records (one per mid-history attempt) and the + // build stamp the hook wrote on its first attempt (stale-`dist` guard). + const { corrections, buildStamp } = await page.evaluate(() => { + const g = globalThis as unknown as { + __ANCHOR_PROBE__?: Array<{ + source: "raf" | "ro"; + wouldFire: boolean; + residual: number; + }>; + __ANCHOR_BUILD_STAMP__?: string; + }; + return { + corrections: g.__ANCHOR_PROBE__ ?? [], + buildStamp: g.__ANCHOR_BUILD_STAMP__ ?? null, + }; + }); + + // Score consecutive frames tracking the SAME row (skip re-anchor frames). + // A REVERSAL is the row moving against the scroll direction — the visible + // "jump down then snap" the fix removes. We pair a reversal with an opposite + // move within 3 frames (a one-frame flash of a late correction); ANY reversal + // frame — paired or not — is the mechanism failing, so we assert zero. + let scored = 0; + let reanchors = 0; + const reversals: Array<{ + i: number; + t: number; + rowMove: number; + rowId: string | null; + }> = []; + for (let i = 1; i < frames.length; i += 1) { + const a = frames[i - 1]; + const b = frames[i]; + if ( + a.top === null || + b.top === null || + a.rowId === null || + a.rowId !== b.rowId + ) { + reanchors += 1; + continue; + } + scored += 1; + const rowMove = b.top - a.top; + if (rowMove <= -REVERSAL_PX) { + reversals.push({ i, t: b.t, rowMove, rowId: b.rowId }); + } + } + + /* eslint-disable no-console */ + const maxReversalPx = + reversals.length === 0 + ? 0 + : Math.max(...reversals.map((r) => Math.abs(r.rowMove))); + console.log("\n=== W4a rAF CORRECTION FIXTURE ==="); + console.log(`frames sampled: ${frames.length}`); + console.log(`frame-pairs scored: ${scored}`); + console.log(`re-anchor/skip frames: ${reanchors}`); + console.log(`reversal frames: ${reversals.length}`); + console.log(`max reversal px: ${maxReversalPx.toFixed(1)}`); + for (const r of reversals + .slice() + .sort((x, y) => x.rowMove - y.rowMove) + .slice(0, 12)) { + console.log( + ` frame ${r.i} t=${r.t.toFixed(0)} rowMove=${r.rowMove.toFixed(1)} row=${r.rowId}`, + ); + } + console.log("==================================\n"); + // Classify every mid-history correction attempt by observer + whether it + // fired. Under design-of-record (c) — Eva's ungated-RO corrector, ratified + // event 96e8fcca — the on-time observer per engine is the sole mid-history + // writer: Chromium's RO delivers pre-paint and wins; WebKit's RO is late so + // the rAF wins and the late RO no-ops against the rAF-refreshed height cache. + const rafFires = corrections.filter( + (c) => c.source === "raf" && c.wouldFire, + ).length; + const roFires = corrections.filter((c) => c.source === "ro" && c.wouldFire); + const maxRoResidual = + roFires.length === 0 + ? 0 + : Math.max(...roFires.map((c) => Math.abs(c.residual))); + console.log("=== MECHANISM TRIPWIRES ==="); + console.log(`engine: ${browserName}`); + console.log(`build stamp: ${buildStamp ?? "(absent)"}`); + console.log(`rAF corrections fired: ${rafFires}`); + console.log(`RO corrections fired: ${roFires.length}`); + console.log(`max RO fire residual: ${maxRoResidual.toFixed(1)}`); + console.log("===========================\n"); + /* eslint-enable no-console */ + + // Sanity: the actuation actually produced a scored upscroll (not a no-op run). + expect(scored).toBeGreaterThan(50); + + // Stale-`dist` guard (Quinn's ratified sharpening). `pnpm build` is + // `tsc && vite build`; a tsc failure leaves the PRIOR bundle in `dist/`, so a + // fixture can silently exercise stale code and fabricate a pass (this class of + // trap nearly sent us to the wrong design). The hook writes its per-experiment + // `ANCHOR_BUILD_STAMP` onto `window` the first time the probe records; asserting + // it equals `EXPECTED_BUILD_STAMP` catches BOTH "build failed, stale dist" + // (stamp absent) and "build succeeded but serving the previous experiment's + // dist" (stamp present, wrong value). This runs before every other invariant + // so a stale bundle can never satisfy them by accident. + expect(buildStamp).toBe(EXPECTED_BUILD_STAMP); + + // --- LAYER 1: LIVENESS ------------------------------------------------------ + // At least one mid-history correction must have been attempted-and-fired. + // Catches the fixture-probe-not-installed bug (addInitScript after goto) that + // produced a VACUOUS pass — the primary assert below is green if no + // instrumentation ran, so liveness has to gate it. (Eva's checklist #2.) + expect(rafFires + roFires.length).toBeGreaterThan(0); + + // --- LAYER 2: PRIMARY CORRECTNESS ------------------------------------------- + // The felt outcome, engine-agnostic. The RO-late writer on WebKit produced a + // large, growing reversal count (dozens, up to the ~204px lurch Tyler feels). + // Design (c) collapses that to a small BOUNDED residual: a handful of one-frame + // detection-latency catch-ups, each no larger than a single row-height quantum. + // NOT strict-0 — an observe-then-correct loop can't predict a + // `content-visibility` realization, so the last realization before a quiet + // frame lands one frame late by construction. We assert the FLOOR: + // - count stays tiny (RO-late ran dozens and climbed with the corpus), and + // - no reversal exceeds one row-height quantum (~34px) — a fixed latency + // floor, not accumulating under-correction. + // NOTE ON FRAGILITY: the residual COUNT is sensitive to the WebKit frame + // scheduler (per-frame instrumentation or a `scrollTo` async write inflate it + // from ~2 to ~21 on this harness). The timing-invariant signal is the + // MAGNITUDE bound; count is asserted only loosely. Whether a sub-quantum + // one-frame catch-up is perceptible during trackpad-velocity motion is a + // live-feel call on the real WKWebView embedder (not Playwright WebKit's + // compositor) and is flagged for Tyler in the PR. + expect(reversals.length).toBeLessThanOrEqual(4); + expect(maxReversalPx).toBeLessThanOrEqual(34); + + // --- LAYER 3: MECHANISM (per-engine, both directions) ----------------------- + // The two mid-history observers coordinate ONLY through the shared height + // cache — no engine branch in the hook. These asserts pin which observer is + // the sole writer per engine and are the permanent regression tripwires: if a + // future change breaks the winning observer's cache refresh, the losing + // observer starts double-correcting and this trips before a user feels it. + // Asserting BOTH directions (winner fires > 0 AND loser fires == 0/no-op) is + // also the mid-history ≤1-scrollTo proof: exactly one observer writes per + // frame. (Quinn's ratified checklist #3.) + if (browserName === "chromium") { + // Chromium: the ungated RO delivers pre-paint and is the sole mid-history + // writer. It MUST fire (else the corpus realized nothing / the RO stopped + // triggering), and the rAF — one pair-frame behind the shift — must NEVER + // fire (firing means the RO stopped refreshing the cache and we've regressed + // to the 16-reversal pure-rAF bug). + expect(roFires.length).toBeGreaterThan(0); + expect(rafFires).toBe(0); + } else { + // WebKit: the RO is late, so the rAF is the sole mid-history writer and MUST + // fire. The late RO MAY still fire when it beats a slow frame — the invariant + // is not "RO never runs" but "no double-correction": every RO fire is a + // no-op against the cache the rAF already refreshed (residual sub-epsilon). + expect(rafFires).toBeGreaterThan(0); + expect(maxRoResidual).toBeLessThanOrEqual(0.5); + } +});