From 8239284d925fcfb383eaa7c1efa2c1a0c9a1ef5d Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Wed, 8 Jul 2026 19:23:25 -0400 Subject: [PATCH] test(desktop): slow-trackpad mismatch probe (per-frame rect.top trajectory) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tyler's on-device verdict at 3d1e91ea: better but not perfect. This probe actuates the shape of his input — small (12px) wheel deltas at slow (~32ms) cadence, no settling — and samples the tracked reading row's rect.top, scrollTop and mounted count EVERY rAF, so one-frame events are visible. Flags frames whose rowMove escapes the slow-wheel staircase envelope (reversal <= -3px, or shove >= 2*delta+3px), pairs escapes that cancel within 3 frames (one-frame flash of a deferred correction) vs unpaired (felt lurch), pure rect.top in the flag condition per the W4 framing. At tip b170680d (includes W1r3 fe786ea4): Chromium 0 escapes / 3447 frames; WebKit 81 escapes / 1754 frames — 11 flash pairs (-27.5 shove then +28 correction one frame later) + 59 unpaired single-frame jumps of exactly 35px / 41px recurring every ~6 rows. Diagnostic, non-gating. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../tests/e2e/upscroll-slow-trackpad.perf.ts | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 desktop/tests/e2e/upscroll-slow-trackpad.perf.ts diff --git a/desktop/tests/e2e/upscroll-slow-trackpad.perf.ts b/desktop/tests/e2e/upscroll-slow-trackpad.perf.ts new file mode 100644 index 0000000000..505fecddc6 --- /dev/null +++ b/desktop/tests/e2e/upscroll-slow-trackpad.perf.ts @@ -0,0 +1,287 @@ +import { test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +/** + * SLOW-TRACKPAD MISMATCH PROBE (diagnostic, non-gating) — Tyler's ask 2026-07-08: + * "Try playwright trackpad style scrolling slowly and find the movement + * mismatches and fix them." + * + * Actuation: SMALL wheel deltas (12px) at slow cadence (~32ms), the shape of a + * slow deliberate trackpad drag — NOT the 220px settled notches of the jitter + * gate. Slow scrolling maximizes the ratio of realization delta to scroll + * delta, which is exactly where per-notch residuals feel worst. + * + * Sampling: an in-page rAF loop records, EVERY FRAME, the tracked reading + * row's rect.top, scrollTop, and mounted-row count while wheel events arrive + * asynchronously. No settling — we want the raw trajectory including any + * one-frame flash before a deferred correction lands. + * + * Mismatch signal (diagnostic only — NOT a gate metric). The felt event is the + * reading row's own screen motion, pure rect.top (per Quinn's W4 framing — + * no scrollTop, no input reference in the FLAG condition). Under slow 12px + * wheel input the row's per-frame trajectory is a 0/+12 staircase (Sami's + * quantization finding), so we flag frames whose rowMove falls OUTSIDE the + * staircase envelope: + * - REVERSAL: rowMove <= -3 (row moves against the scroll direction) + * - SHOVE: rowMove >= 2*WHEEL_DELTA + 3 (row jumps more than two + * coalesced wheel events could produce) + * For each flagged frame we also record e = rowMove + dScroll — which by the + * rect.top identity equals the raw reflow-above-row that frame (comp- + * INVARIANT: nonzero even when perfectly compensated). e is CONTEXT ONLY: + * it distinguishes "reflow reached the row uncompensated" (rowMove tracks e) + * from "correction landed a frame late" (a +/- rowMove pair, e on the first). + * Frames where the tracked row was re-picked or a prepend landed are marked, + * not scored. + */ + +const WHEEL_DELTA = 12; // px per wheel event — slow deliberate trackpad +const WHEEL_PERIOD_MS = 32; // cadence +const DURATION_MS = 30_000; // total actuation time +const SAFE_MARGIN = 100; +const E_NOTABLE = 3; // px of unexplained row motion worth logging + +type Frame = { + t: number; + top: number | null; // tracked row rect.top (null = row left DOM) + scrollTop: number; + mounted: number; + rowId: string | null; // id of tracked row this frame +}; + +test("PROBE: slow-trackpad movement mismatches (diagnostic)", async ({ + page, +}) => { + await installMockBridge(page); + 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; force overflow-anchor none (mirror 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(); + + // Start the in-page per-frame sampler. It re-picks the tracked row when the + // current one leaves the safe band (marking the frame), and records every + // rAF tick until told to stop. + 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(); + const rows = el.querySelectorAll("[data-message-id]"); + for (const row of rows) { + 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 re-anchor marker + } + w.__PROBE__.frames.push({ + t, + top, + scrollTop: el.scrollTop, + mounted, + rowId: trackedId, + }); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }, SAFE_MARGIN); + + // Slow trackpad actuation: small deltas, steady cadence, no settling. + 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; + }); + + // Analysis: per-frame rowMove (pure rect.top), scored only across + // consecutive frames tracking the SAME row with valid tops. Flag frames + // whose rowMove escapes the slow-wheel staircase envelope. + const REVERSAL_PX = -E_NOTABLE; // row moved against the scroll + const SHOVE_PX = 2 * WHEEL_DELTA + E_NOTABLE; // > two coalesced notches + type Ev = { + i: number; + t: number; + kind: "REVERSAL" | "SHOVE"; + rowMove: number; + e: number; // rowMove + dScroll = raw reflow-above-row (context only) + dScroll: number; + dMounted: number; + rowId: string | null; + }; + const events: Ev[] = []; + const rowMoves: number[] = []; + let scored = 0; + let reanchors = 0; + let prepends = 0; + for (let i = 1; i < frames.length; i += 1) { + const a = frames[i - 1]; + const b = frames[i]; + const dMounted = b.mounted - a.mounted; + if (dMounted > 0) prepends += 1; + if ( + a.top === null || + b.top === null || + a.rowId === null || + a.rowId !== b.rowId + ) { + reanchors += 1; + continue; + } + const rowMove = b.top - a.top; + const dScroll = b.scrollTop - a.scrollTop; + const e = rowMove + dScroll; + scored += 1; + rowMoves.push(rowMove); + const kind = + rowMove <= REVERSAL_PX + ? ("REVERSAL" as const) + : rowMove >= SHOVE_PX + ? ("SHOVE" as const) + : null; + if (kind) { + events.push({ + i, + t: b.t, + kind, + rowMove, + e, + dScroll, + dMounted, + rowId: b.rowId, + }); + } + } + + // Flash pairing: a shove/reversal cancelled by an opposite move within 3 + // frames is a one-frame flash (deferred correction); unpaired = felt lurch. + const flashes: Array<[Ev, Ev]> = []; + const lurches: Ev[] = []; + const used = new Set(); + for (let k = 0; k < events.length; k += 1) { + if (used.has(k)) continue; + const ev = events[k]; + let paired = false; + for (let m = k + 1; m < events.length && events[m].i - ev.i <= 3; m += 1) { + if (used.has(m)) continue; + // Opposite-signed rowMove of comparable magnitude (allowing the +12 + // staircase riding on top of the correction). + const cancel = events[m].rowMove + ev.rowMove; + if (Math.abs(cancel) <= Math.abs(ev.rowMove) * 0.4 + WHEEL_DELTA) { + flashes.push([ev, events[m]]); + used.add(k); + used.add(m); + paired = true; + break; + } + } + if (!paired) { + lurches.push(ev); + used.add(k); + } + } + // Net signed drift of the row across all scored frames, minus the expected + // downward staircase — sustained same-sign residue = skip-forever shape. + const totalRowMove = rowMoves.reduce((acc, m) => acc + m, 0); + + /* eslint-disable no-console */ + console.log("\n=== SLOW-TRACKPAD MISMATCH PROBE (diagnostic) ==="); + console.log( + `wheel: ${WHEEL_DELTA}px every ${WHEEL_PERIOD_MS}ms for ${DURATION_MS / 1000}s`, + ); + console.log(`frames sampled: ${frames.length}`); + console.log(`frame-pairs scored: ${scored}`); + console.log(`re-anchor/skip frames: ${reanchors}`); + console.log(`prepend commits observed: ${prepends}`); + console.log( + `envelope escapes (reversal<=${REVERSAL_PX} or shove>=${SHOVE_PX}): ${events.length} ` + + `(flash-pairs: ${flashes.length}, unpaired lurches: ${lurches.length})`, + ); + console.log( + `total tracked rowMove: ${totalRowMove.toFixed(1)}px over ${scored} frames`, + ); + const fmt = (ev: Ev) => + ` frame ${ev.i} t=${ev.t.toFixed(0)} ${ev.kind} rowMove=${ev.rowMove.toFixed(1)} ` + + `(e=${ev.e.toFixed(1)}, dScroll=${ev.dScroll.toFixed(1)}, ` + + `dMounted=${ev.dMounted}, row=${ev.rowId})`; + console.log("--- worst unpaired lurches (top 12 by |rowMove|) ---"); + for (const ev of lurches + .slice() + .sort((x, y) => Math.abs(y.rowMove) - Math.abs(x.rowMove)) + .slice(0, 12)) { + console.log(fmt(ev)); + } + console.log("--- worst flash pairs (top 6) ---"); + for (const [x, y] of flashes + .slice() + .sort((p, q) => Math.abs(q[0].rowMove) - Math.abs(p[0].rowMove)) + .slice(0, 6)) { + console.log(fmt(x)); + console.log(fmt(y)); + } + console.log("=================================================\n"); + /* eslint-enable no-console */ +});