mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): correct realizing-upscroll reading-row reversal on both engines
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>
This commit is contained in:
co-authored by
Tyler Longwell
parent
2c422c1e34
commit
e7243887e4
@@ -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.
|
||||
|
||||
@@ -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<HTMLElement>("[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<Element, number>,
|
||||
): number {
|
||||
const wrapper = anchorRow.closest<HTMLElement>(".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 `<section>`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<Element, number>,
|
||||
): 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<ReadingAnchor | null>(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<WeakMap<Element, number>>(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<Element, number>();
|
||||
// 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<HTMLElement>(
|
||||
`[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<HTMLElement>(
|
||||
`[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);
|
||||
});
|
||||
|
||||
@@ -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<HTMLElement>("[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<HTMLElement>(
|
||||
`[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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user