diff --git a/desktop/playwright.perf-webkit.config.ts b/desktop/playwright.perf-webkit.config.ts new file mode 100644 index 0000000000..1a79732f17 --- /dev/null +++ b/desktop/playwright.perf-webkit.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + timeout: 900_000, + retries: 0, + workers: 1, + reporter: [["list"]], + use: { baseURL: "http://127.0.0.1:4173" }, + projects: [ + { + name: "perf-webkit", + testMatch: ["**/*.perf.ts"], + use: { ...devices["Desktop Safari"] }, + }, + ], + webServer: { + command: "python3 -m http.server 4173 -d dist", + cwd: ".", + reuseExistingServer: true, + url: "http://127.0.0.1:4173", + }, +}); diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 1215e3edb5..b6f99e59e8 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -182,6 +182,30 @@ 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. The 1140→1210 bump is the W4a chase instrumentation inflating + // a load-bearing hook mid-investigation: the signed-shift grow/shrink probe + // (classifier arm, 1140→1156) and the renderedScroll gate-clock rekey + // (w4a-gate-1 arm, ~1156→1206). The gate-arm growth is the deferred-refresh + // commit path + the momentum-gate rationale + the `renderedScroll` field + // threaded through the shared result type and all four returns so the + // classifier fixture can PROVE the scroll/reflow decomposition (Eva required + // this in the arm's first run); biome reflows each 4-prop return object to + // multi-line, which is most of the line cost. FLAGGED to Eva: this is heavy + // for a diagnostic — if the decomposition proof moves behind a lighter + // gate-only probe, this drops back toward 1185. EXIT CONDITION: once #1662's + // fate is decided, the diagnostic emits get stripped or gated and the + // mid-history corrector + band walk split into a sibling module — Eva's ruling + // holds the structural split until the arms in flight settle, so it is NOT + // done mid-chase. Comment-dominated overage on a correctness fix, not generic + // debt growth. Approved override; queued to split. + ["src/features/messages/ui/useAnchoredScroll.ts", 1210], // 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/lib/rowHeightEstimate.test.mjs b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs index dc33da4f8f..40cf61cec5 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs +++ b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs @@ -84,21 +84,95 @@ test("estimateRowHeight: imeta dim is not double-counted with its body url", () assert.ok(both < 400, `expected single media reserve, got ${both}`); }); -test("estimateRowHeight: bare URL line adds a preview card", () => { +test("estimateRowHeight: unsupported bare URL line does not add a preview card", () => { const withUrl = estimateRowHeight(msg({ body: "https://example.com/x" })); const withoutUrl = estimateRowHeight(msg({ body: "example" })); + assert.ok(withUrl < withoutUrl + 25, `url ${withUrl} vs ${withoutUrl}`); +}); + +test("estimateRowHeight: supported GitHub URL line adds a preview card", () => { + const withUrl = estimateRowHeight( + msg({ body: "https://github.com/block/buzz/pull/1641" }), + ); + const withoutUrl = estimateRowHeight(msg({ body: "example" })); assert.ok(withUrl > withoutUrl + 50, `url ${withUrl} vs ${withoutUrl}`); }); +test("estimateRowHeight: supported Linear and Google URLs add preview cards", () => { + const base = estimateRowHeight(msg({ body: "example" })); + const linear = estimateRowHeight( + msg({ body: "https://linear.app/block/issue/BUZZ-123/fix-scroll" }), + ); + const google = estimateRowHeight( + msg({ body: "https://docs.google.com/document/d/abc123/edit" }), + ); + assert.ok(linear > base + 50, `linear ${linear} vs ${base}`); + assert.ok(google > base + 50, `google ${google} vs ${base}`); +}); + +test("estimateRowHeight: column width option changes prose wrapping", () => { + const body = "x".repeat(160); + const narrow = estimateRowHeight(msg({ body }), { columnWidthPx: 320 }); + const fallback = estimateRowHeight(msg({ body })); + assert.ok(narrow > fallback + 20, `narrow ${narrow} vs fallback ${fallback}`); +}); + +test("estimateRowHeight: markdown structures reserve extra chrome", () => { + const table = estimateRowHeight( + msg({ body: "| A | B |\n| - | - |\n| 1 | 2 |" }), + ); + const plain = estimateRowHeight(msg({ body: "A B\n- -\n1 2" })); + assert.ok(table > plain + 20, `table ${table} vs plain ${plain}`); +}); + test("timelineRowReserveStyle: message item yields containIntrinsicSize", () => { const style = timelineRowReserveStyle({ kind: "message", key: "k", entry: { message: msg({ body: "hi" }), summary: null }, + isContinuation: false, + isFollowedByContinuation: false, }); assert.match(String(style.containIntrinsicSize), /^auto \d+px$/); }); +test("timelineRowReserveStyle: summary rows add summary chrome", () => { + const item = { + kind: "message", + key: "k", + entry: { + message: msg({ body: "hi" }), + summary: { + threadHeadId: "m1", + replyCount: 3, + lastReplyAt: 1, + participants: [], + }, + }, + isContinuation: false, + isFollowedByContinuation: false, + }; + const withSummary = Number.parseInt( + String(timelineRowReserveStyle(item).containIntrinsicSize).match( + /auto (\d+)px/, + )?.[1] ?? "0", + 10, + ); + const withoutSummary = Number.parseInt( + String( + timelineRowReserveStyle({ + ...item, + entry: { ...item.entry, summary: null }, + }).containIntrinsicSize, + ).match(/auto (\d+)px/)?.[1] ?? "0", + 10, + ); + assert.ok( + withSummary > withoutSummary + 25, + `${withSummary} vs ${withoutSummary}`, + ); +}); + test("timelineRowReserveStyle: divider is short fixed height", () => { const style = timelineRowReserveStyle({ kind: "day-divider", diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.ts b/desktop/src/features/messages/lib/rowHeightEstimate.ts index c979a8ad10..bc6f6f89dc 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.ts +++ b/desktop/src/features/messages/lib/rowHeightEstimate.ts @@ -1,5 +1,6 @@ import type * as React from "react"; +import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview"; import { dimensionsFromDim } from "@/shared/ui/markdown/utils"; import type { TimelineItem } from "./timelineItems"; import type { TimelineMessage } from "../types"; @@ -24,16 +25,33 @@ const MEDIA_MAX_WIDTH = 384; // max-w-[min(24rem,100%)] const MEDIA_MAX_HEIGHT = 256; // max-h-64 const TEXT_LINE_HEIGHT = 20; const CODE_LINE_HEIGHT = 19; -const CHARS_PER_LINE = 64; // rough wrap width at the timeline column +const FALLBACK_CHARS_PER_LINE = 64; // rough wrap width at the timeline column +const AVERAGE_TEXT_CHAR_WIDTH = 7.2; // text-sm, biased toward common prose +const ROW_HORIZONTAL_CHROME = 64; // avatar + row gap + inline padding +const MIN_CHARS_PER_LINE = 32; +const MAX_CHARS_PER_LINE = 96; const ROW_CHROME = 26; // author/time header + denser row padding const CONTINUATION_ROW_CHROME = 8; // dense row padding only; header/avatar are hidden const MEDIA_BLOCK_MARGIN_TOP = 4; // image/video blocks use mt-1 in markdown const REACTION_ROW = 24; const PREVIEW_CARD = 70; +const THREAD_SUMMARY_ROW = 38; +const FOOTER_ROW = 32; const MESSAGE_ITEM_BOTTOM_PADDING = 10; // TimelineMessageList pb-2.5 const MIN_ESTIMATE = 60; // never reserve less than the old flat floor const CONTINUATION_MIN_ESTIMATE = 34; +export type TimelineRowReserveOptions = { + /** Timeline column width measured once by the caller; absent keeps the old 64-char estimate. */ + columnWidthPx?: number; + /** Optional row footer chrome. The main channel timeline currently leaves this unset. */ + hasFooter?: boolean; +}; + +type EstimateRowHeightOptions = TimelineRowReserveOptions & { + isContinuation?: boolean; +}; + function mediaHeightFromDim(dim: string | undefined): number { const dimensions = dimensionsFromDim(dim); if (!dimensions) return MEDIA_MAX_HEIGHT; // unknown shape: reserve full box @@ -49,10 +67,27 @@ function mediaReserveHeight(dim: string | undefined): number { return MEDIA_BLOCK_MARGIN_TOP + mediaHeightFromDim(dim); } -function wrappedLineCount(text: string): number { +function charsPerLineFromColumnWidth( + columnWidthPx: number | undefined, +): number { + if (columnWidthPx == null || !Number.isFinite(columnWidthPx)) { + return FALLBACK_CHARS_PER_LINE; + } + + const textWidth = Math.max(0, columnWidthPx - ROW_HORIZONTAL_CHROME); + return Math.max( + MIN_CHARS_PER_LINE, + Math.min( + MAX_CHARS_PER_LINE, + Math.floor(textWidth / AVERAGE_TEXT_CHAR_WIDTH), + ), + ); +} + +function wrappedLineCount(text: string, charsPerLine: number): number { let lines = 0; for (const raw of text.split("\n")) { - lines += Math.max(1, Math.ceil(raw.length / CHARS_PER_LINE)); + lines += Math.max(1, Math.ceil(raw.length / charsPerLine)); } return lines; } @@ -61,7 +96,10 @@ function wrappedLineCount(text: string): number { * Strip fenced code blocks from the body, returning the prose remainder and the * total number of code lines (for separate mono line-height accounting). */ -function splitFencedCode(body: string): { prose: string; codeLines: number } { +function splitFencedCode(body: string): { + prose: string; + codeLines: number; +} { const parts = body.split(/```/); // Even indices are prose, odd indices are inside a fence. let prose = ""; @@ -108,19 +146,55 @@ function stripMediaOnlyLines(text: string): string { .join("\n"); } +function markdownStructureExtraHeight(text: string): number { + let extra = 0; + let tableRunLines = 0; + + const flushTableRun = () => { + if (tableRunLines >= 2) { + // GFM tables have cell padding/borders, so they are taller than the same + // raw markdown counted as plain 20px text lines. + extra += 14 + tableRunLines * 6; + } + tableRunLines = 0; + }; + + for (const line of text.split("\n")) { + const trimmed = line.trim(); + const isTableLine = + /^\|.+\|$/.test(trimmed) || /\S\s+\|\s+\S/.test(trimmed); + + if (isTableLine) tableRunLines += 1; + else flushTableRun(); + + if (/^(?:---+|\*\*\*+|___+)\s*$/.test(trimmed)) extra += 12; + } + + flushTableRun(); + return extra; +} + export function estimateRowHeight( message: TimelineMessage, - { isContinuation = false }: { isContinuation?: boolean } = {}, + { + columnWidthPx, + hasFooter = false, + isContinuation = false, + }: EstimateRowHeightOptions = {}, ): number { const body = message.body ?? ""; const { prose, codeLines } = splitFencedCode(body); const proseForLineCount = stripMediaOnlyLines(prose); + const charsPerLine = charsPerLineFromColumnWidth(columnWidthPx); let height = isContinuation ? CONTINUATION_ROW_CHROME : ROW_CHROME; height += - wrappedLineCount(proseForLineCount.trim() === "" ? "" : proseForLineCount) * - TEXT_LINE_HEIGHT; + wrappedLineCount( + proseForLineCount.trim() === "" ? "" : proseForLineCount, + charsPerLine, + ) * TEXT_LINE_HEIGHT; height += codeLines * CODE_LINE_HEIGHT; + height += markdownStructureExtraHeight(proseForLineCount); const imetaUrls = new Set(); if (message.tags && message.tags.length > 0) { @@ -137,16 +211,13 @@ export function estimateRowHeight( height += mediaReserveHeight(undefined); } - // A bare non-media URL on its own line usually renders a link-preview card. - const hasPreviewUrlLine = body - .split("\n") - .some( - (line) => - /^\s*https?:\/\/\S+\s*$/.test(line) && !MEDIA_URL_RE.test(line.trim()), - ); - if (hasPreviewUrlLine) height += PREVIEW_CARD; + // Reserve only cards the renderer can actually produce. Generic bare URLs do + // not render preview cards, so keeping the old blanket reserve overestimated + // unsupported links by about one card height during first realization. + height += extractSupportedLinkPreviews(body).length * PREVIEW_CARD; if (message.reactions && message.reactions.length > 0) height += REACTION_ROW; + if (hasFooter) height += FOOTER_ROW; return Math.max( isContinuation ? CONTINUATION_MIN_ESTIMATE : MIN_ESTIMATE, @@ -166,14 +237,18 @@ const DIVIDER_HEIGHT = 32; */ export function timelineRowReserveStyle( item: TimelineItem, + opts: TimelineRowReserveOptions = {}, ): React.CSSProperties { const height = item.kind === "message" ? estimateRowHeight(item.entry.message, { + ...opts, isContinuation: item.isContinuation, - }) + (item.isFollowedByContinuation ? 0 : MESSAGE_ITEM_BOTTOM_PADDING) + }) + + (item.entry.summary ? THREAD_SUMMARY_ROW : 0) + + (item.isFollowedByContinuation ? 0 : MESSAGE_ITEM_BOTTOM_PADDING) : item.kind === "system" - ? estimateRowHeight(item.entry.message) + ? estimateRowHeight(item.entry.message, opts) : DIVIDER_HEIGHT; return { containIntrinsicSize: `auto ${height}px` }; } diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index fdc9679104..e75f18e925 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -21,6 +21,7 @@ import type { TimelineMessage } from "@/features/messages/types"; import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; +import { useElementWidth } from "@/shared/hooks/use-mobile"; import { cn } from "@/shared/lib/cn"; import { DayDivider } from "./DayDivider"; import { MessageRow } from "./MessageRow"; @@ -178,6 +179,14 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ [itemsResult.items], ); + // Measure the row column once so row-height estimates reserve credible space + // for the *actual* wrap width instead of the 64-char fallback. The rows are + // `w-full` inside this wrapper, so its width is the row box the estimator + // subtracts chrome from. Zero (pre-measure) passes `undefined` to preserve + // the estimator's own fallback rather than tripping its min-chars floor. + const [columnRef, columnWidthPx] = useElementWidth(); + const reserveColumnWidthPx = columnWidthPx > 0 ? columnWidthPx : undefined; + const renderItem = React.useCallback( (item: TimelineNonDayItem) => { switch (item.kind) { @@ -256,7 +265,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ ); return ( -
+
{dayGroups.map((group) => (
{renderItem(item)}
diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs index e804ca0951..9392daef67 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs +++ b/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { settleProgrammaticBottomPin } from "./useAnchoredScroll.ts"; +import { computeAnchorCorrection } from "./useAnchoredScroll.ts"; function fakeContainer({ clientHeight, scrollHeight, scrollTop }) { const writes = []; @@ -48,3 +49,54 @@ test("settleProgrammaticBottomPin keeps settling when the floor is still out of 2, ); }); + +// computeAnchorCorrection — the scroll-invariant realization compensation. +// Convention: scrollTop grows downward; a row's topOffset is its top relative +// to the viewport top. A reflow ABOVE the row pushes it down (topOffset grows) +// at constant scrollTop; a user scroll down grows scrollTop and shrinks +// topOffset by equal amounts (document position fixed). + +test("computeAnchorCorrection returns null when nothing shifted", () => { + const anchor = { topOffset: 100, scrollTop: 500 }; + assert.equal(computeAnchorCorrection(anchor, anchor), null); +}); + +test("computeAnchorCorrection ignores pure user scroll (no reflow)", () => { + // User scrolled DOWN 40px since baseline: scrollTop +40, topOffset -40. + const baseline = { topOffset: 100, scrollTop: 500 }; + const current = { topOffset: 60, scrollTop: 540 }; + // Document position unchanged => no correction, the wheel is left alone. + assert.equal(computeAnchorCorrection(baseline, current), null); +}); + +test("computeAnchorCorrection compensates a reflow above the row, scrolling down by the shift", () => { + // A row above realized 30px taller: at constant scrollTop the anchor's + // topOffset grew 100 -> 130. To keep it visually fixed, scroll down 30px. + const baseline = { topOffset: 100, scrollTop: 500 }; + const current = { topOffset: 130, scrollTop: 500 }; + assert.equal(computeAnchorCorrection(baseline, current), 530); +}); + +test("computeAnchorCorrection compensates a reflow that shrank content above", () => { + // Content above shrank 20px: topOffset 100 -> 80 at constant scrollTop. + // Correct by scrolling UP 20px so the row stays put. + const baseline = { topOffset: 100, scrollTop: 500 }; + const current = { topOffset: 80, scrollTop: 500 }; + assert.equal(computeAnchorCorrection(baseline, current), 480); +}); + +test("computeAnchorCorrection isolates reflow from a simultaneous user scroll", () => { + // Since baseline the user scrolled down 40px (scrollTop +40, topOffset -40) + // AND a row above realized 30px taller (topOffset +30). Net topOffset: + // 100 - 40 + 30 = 90; scrollTop 540. Only the 30px reflow should be + // compensated: target = 540 + 30 = 570, leaving the user's 40px intact. + const baseline = { topOffset: 100, scrollTop: 500 }; + const current = { topOffset: 90, scrollTop: 540 }; + assert.equal(computeAnchorCorrection(baseline, current), 570); +}); + +test("computeAnchorCorrection treats sub-epsilon shift as noise", () => { + const baseline = { topOffset: 100, scrollTop: 500 }; + const current = { topOffset: 100.3, scrollTop: 500 }; + assert.equal(computeAnchorCorrection(baseline, current), null); +}); diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 2c0176df7f..4f0bf3f863 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -15,11 +15,58 @@ const AT_BOTTOM_THRESHOLD_PX = 32; // latest message; this strict threshold decides when a programmatic bottom pin // has actually finished settling. const TRUE_BOTTOM_THRESHOLD_PX = 1; +// Realization compensation only runs when the reading anchor's captured scroll +// position is consistent with the frame the ResizeObserver fires in. Under +// WebKit async ("coordinated") scrolling the rAF baseline read and the RO +// post-layout read can straddle a compositor commit, so a fragment of the +// user's own momentum can leak into the measured shift. If the RENDERED scroll +// (the painted wheel motion this frame — see `applyMidHistoryCorrection`) +// exceeds this bound, momentum is clearly in flight and the two reads are not +// trustworthy together — we SKIP the correction rather than risk folding the +// wheel delta into the pin. We gate on rendered, not raw `scrollTop`, delta +// because WebKit coalesces momentum into `scrollTop` on its own clock: a raw +// delta can read large on a frame that painted still. Under-correcting a single +// realization is invisible (the next quiet frame catches it); fighting the +// wheel is the visible lurch. Chosen at roughly one frame of aggressive +// trackpad momentum; tune against the gate. +const COMPENSATION_SCROLL_SKIP_PX = 120; +// Distance below the scroller top at which the reading anchor is chosen. We +// pick the first row whose top sits at least this far below the viewport top +// rather than the first row past the top edge, so the anchor stays OUT of the +// freshly-exposed realization band hanging just under the fold during an +// upscroll. A row inside that band can re-measure to a garbage position when +// the ResizeObserver re-queries it mid-realization; a row a notch below the +// churn moves only by the net height change above it. Matches the probe's +// 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" @@ -141,6 +188,388 @@ function computeAnchor(container: HTMLDivElement): AnchorState { return { kind: "at-bottom" }; } +/** + * Snapshot the reader's position for realization compensation: the first row + * FULLY inside the viewport (its top at/below the scroller top) and that row's + * top relative to the scroller top. + * + * Why *fully* visible and not the top-crossing straddler `computeAnchor` picks: + * the CSS scroll-anchoring spec descends past partially-visible candidates and + * anchors on the first fully-visible element for exactly the case we hit — + * during an upscroll the realizing band is the freshly exposed content hanging + * above the fold, so a straddler anchor sits *inside* that churning band and + * can't measure the shift below it. The first fully-visible row sits one notch + * below the churn, so re-pinning it to its saved offset cancels the net height + * change of everything above it (the layout engine sums those deltas for us — + * a row resizing below the anchor doesn't move the anchor's top, so it's + * excluded for free). We require the row's top to sit at least + * `READING_ANCHOR_SAFE_MARGIN_PX` below the scroller top so the anchor never + * sits inside the realization band itself. Returns null when no such row + * exists. + * + * We also capture `scrollTop` alongside the viewport-relative `topOffset` so + * compensation can be computed scroll-invariantly: the row's document position + * `scrollTop + topOffset` changes ONLY when content above it reflows — a user + * scroll moves `scrollTop` and `topOffset` by equal-and-opposite amounts and + * leaves the sum fixed. That decoupling is what makes the correction correct on + * WebKit even when the baseline is one frame stale relative to the user's live + * momentum: we compensate the reflow, never the wheel. + */ +function snapshotReadingAnchor( + container: HTMLDivElement, +): ReadingAnchor | null { + const containerTop = container.getBoundingClientRect().top; + const safeTop = containerTop + READING_ANCHOR_SAFE_MARGIN_PX; + const rows = container.querySelectorAll("[data-message-id]"); + for (const row of rows) { + const rect = row.getBoundingClientRect(); + if (rect.top >= safeTop) { + const id = row.dataset.messageId; + if (id) + return { + id, + topOffset: rect.top - containerTop, + scrollTop: container.scrollTop, + row, + }; + } + } + return null; +} + +/** + * The height the browser is CURRENTLY using to lay out `row`. For a + * `content-visibility: auto` row that has never painted this is its + * `contain-intrinsic-size` reserve (the estimate) rather than its realized + * height; for a row the browser has already realized-and-remembered (the `auto` + * keyword) it is that remembered size. We read the computed + * `contain-intrinsic-block-size` — Blink returns it as `"px"` or + * `"auto px"` — and fall back to the live box height if the property is + * empty/unsupported (e.g. WKWebView returning an empty string). Seeding the + * resize map with this value is what turns a realization into a MEASURABLE + * `realized - reserve` delta instead of an unmeasurable first sighting. + */ +function reservedRowHeight(row: HTMLElement): number { + const raw = getComputedStyle(row).containIntrinsicBlockSize; + const match = raw.match(/(-?\d+(?:\.\d+)?)px/); + if (match) return Number.parseFloat(match[1]); + return row.getBoundingClientRect().height; +} + +/** + * Layout-shift compensation for the reading anchor, computed scroll-invariantly. + * + * Given the anchor row's document position (`scrollTop + topOffset`) at baseline + * and now, returns the absolute `scrollTop` the container should be written to + * so the row stays visually fixed across a reflow above it — WITHOUT folding in + * the user's own scroll motion since the baseline. + * + * The row's document position moves ONLY when content above it changes height: + * a user scroll changes `scrollTop` and `topOffset` by equal-and-opposite + * amounts and leaves the sum fixed. So `shift` (the reflow above the row) is the + * change in document position, and the corrected target is `currentScrollTop + + * shift`. When the user has purely scrolled (no reflow) the shift is 0 and the + * target equals the current position — the correction ignores the wheel. + * + * Returns `null` when the shift is within `epsilonPx` (nothing to correct). + */ +export function computeAnchorCorrection( + baseline: { topOffset: number; scrollTop: number }, + current: { topOffset: number; scrollTop: number }, + epsilonPx = 0.5, +): number | null { + const shift = + current.scrollTop + + current.topOffset - + (baseline.scrollTop + baseline.topOffset); + if (Math.abs(shift) <= epsilonPx) return null; + 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. + * + * DEFERRED REFRESH: the cache write is staged, not applied inline — the walk + * returns `{ aboveShift, commit }` and the caller applies `commit()` ONLY when + * it keeps this frame (does not momentum-skip). This preserves "first observer + * wins, second no-ops" while letting the momentum gate — which now needs + * `aboveShift` to decide — skip a frame WITHOUT refreshing the cache, so the + * next quiet frame still sees the pending reflow. Refreshing on a skip would + * silently swallow it. + */ +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, +): { aboveShift: number; commit: () => void } { + const wrapper = anchorRow.closest(".timeline-row-cv"); + if (!wrapper) return { aboveShift: 0, commit: () => {} }; + 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; + // Staged cache writes, applied by `commit()` only when the caller keeps this + // frame (does not momentum-skip). Includes first-sighting seeds so a skipped + // frame does not seed either — the next quiet frame does the whole pass. + const staged: Array<[Element, number]> = []; + 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); + staged.push([row, height]); + if (last === undefined) continue; // first sighting in band: seed, don't count. + aboveShift += height - last; + } + const commit = () => { + for (const [row, height] of staged) heights.set(row, height); + }; + return { aboveShift, commit }; +} + +/** + * 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. + * + * `signedShift` is `aboveShift` WITHOUT the abs — diagnostic-only, read by the + * slow-scroll classifier. Its sign is the grow/shrink discriminator the escape + * counter cannot recover from magnitude alone: `> 0` = content above grew, the + * anchor was pushed down and the correction WRITE is the felt backward snap + * (absorption's amortizable topology); `< 0` = content above shrank, the reflow + * itself pulls the anchor up and renders the reversal BEFORE any write touches + * it (structurally uncorrectable by us — only smaller per-frame realization + * helps). `residual = |signedShift|` throws that sign away, so the classifier + * reads `signedShift` directly. Not consumed in production. + */ +type MidHistoryCorrection = { + wouldFire: boolean; + residual: number; + signedShift: number; + // Diagnostic-only, rAF path only: the painted wheel motion the momentum gate + // keyed on this frame (`aboveShift − ΔtopOffset`). Lets the classifier fixture + // PROVE the scroll/reflow decomposition holds — a pure-scroll frame reads + // large here, a pure-reflow (rendered-still) frame reads ~0. The RO path has + // no momentum gate, so it omits this. Not consumed in production. + renderedScroll?: 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` reads the band and + * STAGES its refresh, applied by `commit()` only past the momentum gate, so a + * kept 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 { + const currentScrollTop = container.scrollTop; + const containerTop = container.getBoundingClientRect().top; + const currentTopOffset = + baseline.row.getBoundingClientRect().top - containerTop; + const current = { topOffset: currentTopOffset, scrollTop: currentScrollTop }; + // The band walk computes `aboveShift` and STAGES the band-cache refresh; we + // apply the refresh (`commit()`) only past the momentum gate, so a skip does + // not swallow the pending reflow (see `sumAboveAnchorShift`). + const { aboveShift, commit } = sumAboveAnchorShift( + container, + baseline.row, + baseline.scrollTop + baseline.topOffset, + currentScrollTop, + heights, + ); + const residual = Math.abs(aboveShift); + // Momentum in flight: the two reads may not describe one coherent state, so + // skip rather than fold the wheel into the correction. We gate on RENDERED + // scroll, NOT the raw `scrollTop` delta: WebKit coalesces momentum into + // `scrollTop` on its own async clock, so a frame that PAINTED still can read a + // large raw delta and wrongly skip a genuine reflow (the survivor bin). The + // rendered scroll is the anchor's painted move (`ΔtopOffset`) with the + // reflow's own push removed — `renderedScroll = aboveShift − ΔtopOffset`, + // both terms from the painted DOM — so a still frame reads ~0 and corrects. + const renderedScroll = aboveShift - (currentTopOffset - baseline.topOffset); + if (Math.abs(renderedScroll) > COMPENSATION_SCROLL_SKIP_PX) { + return { + wouldFire: false, + residual, + signedShift: aboveShift, + renderedScroll, + }; + } + // Past the gate: keep this frame, so refresh the band cache now (zeroes the + // second observer; first-observer-wins stays intact). + commit(); + // 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 target = computeAnchorCorrection(baseline, current); + if (target === null) + return { + wouldFire: false, + residual, + signedShift: aboveShift, + renderedScroll, + }; + // 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, + signedShift: aboveShift, + renderedScroll, + }; + } + // Synchronous setter (not `scrollTo`, which WebKit may defer past paint). + container.scrollTop = target; + return { wouldFire: true, residual, signedShift: aboveShift, renderedScroll }; +} + +/** + * 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-gate-1"; + +/** + * 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; + signedShift: number; + renderedScroll?: 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, + signedShift: result.signedShift, + renderedScroll: result.renderedScroll, + }); + } +} + export function useAnchoredScroll({ scrollContainerRef, contentRef, @@ -181,6 +610,26 @@ export function useAnchoredScroll({ // ignores transient gaps and keeps chasing the floor. A `ref`, not state — the // guard runs on a native scroll event, outside React's render cycle. 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. 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 @@ -452,27 +901,245 @@ export function useAnchoredScroll({ ]); // --------------------------------------------------------------------------- - // Content resize: while stuck to the bottom, an in-viewport reflow (image - // decode, embed expand, late font load) that React isn't driving grows - // `scrollHeight` without a `messages` change, so the layout effect doesn't - // fire — re-pin to the new floor here to stay glued. When anchored - // mid-history, native scroll anchoring (overflow-anchor) holds the reading - // row across the reflow, so there's nothing to do. + // 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). + // + // 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: channelId is a deliberate re-subscription trigger — the effect body reads only the stable refs, but on a channel switch the keyed scroll container remounts and contentRef.current becomes a fresh node, so the observer must disconnect from the previous channel's detached node and re-observe the live one. + // 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; - const observer = new ResizeObserver(() => { + // 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; - if (anchorRef.current.kind === "at-bottom") { + // 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 + // exact signal the rAF baseline sampler uses to decide whether a reading + // 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 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, + entry.target.getBoundingClientRect().height, + ); + } container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + return; } + // 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; + for (const entry of entries) { + const row = entry.target as HTMLElement; + const height = row.getBoundingClientRect().height; + const last = lastHeights.get(row); + lastHeights.set(row, height); + if (last === undefined) continue; // first sighting: seed, don't count. + if (Math.abs(height - last) > 0.5) changed = true; + } + if (!changed) { + reportCorrection("ro", { + wouldFire: false, + residual: 0, + signedShift: 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 = + baseline.row.getBoundingClientRect().top - containerTop; + const drift = currentTopOffset - baseline.topOffset; + if (Math.abs(drift) <= 0.5) { + reportCorrection("ro", { + wouldFire: false, + residual: Math.abs(drift), + signedShift: 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), + signedShift: drift, + }); }); - observer.observe(content); + // Observe every timeline row (not the content wrapper): a + // `content-visibility: auto` row realizing to its true height is a resize + // of THAT row's box but does not reliably fire a ResizeObserver on the + // wrapper (Blink does not surface CV realization as an ancestor resize). + // The RO callback runs after layout, before paint, so the compensating + // scroll write is same-frame invisible. + for (const row of content.querySelectorAll( + ".timeline-row-cv", + )) { + lastHeights.set(row, reservedRowHeight(row)); + observer.observe(row); + } return () => observer.disconnect(); - }, [channelId, contentRef, scrollContainerRef]); + }, [channelId, contentRef, scrollContainerRef, messages]); + + // --------------------------------------------------------------------------- + // 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` (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. + React.useEffect(() => { + let rafId = requestAnimationFrame(function sample() { + const container = scrollContainerRef.current; + if (container && !settlingRef.current) { + // `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); + }); + return () => cancelAnimationFrame(rafId); + }, [scrollContainerRef]); // --------------------------------------------------------------------------- // Target message handling (deep link, jump-to-reply, etc.). Distinct from diff --git a/desktop/src/shared/styles/globals/utilities.css b/desktop/src/shared/styles/globals/utilities.css index 1dd7326e22..541758a595 100644 --- a/desktop/src/shared/styles/globals/utilities.css +++ b/desktop/src/shared/styles/globals/utilities.css @@ -10,6 +10,18 @@ contain-intrinsic-size: auto 200px; } + /* The conversation scroller owns its reading position through the JS + compensation writer in `useAnchoredScroll` (same-frame `scrollBy` on + realization/reflow deltas). Native scroll anchoring must be OFF so it + can't double-correct the same delta behind the writer's back: the shipped + WKWebView has no `overflow-anchor` at all, while Chromium does — leaving it + on means CI (Chromium) and macOS users (WKWebView) run different scroll + contracts, which is how the jitter shipped unseen. Forcing it off makes the + writer the single scroll authority on every engine. See L-A/L-C findings. */ + [data-buzz-conversation-scroll] { + overflow-anchor: none; + } + /* Timeline rows re-pin by measured offset on prepend; the 200px fallback over-reserves the common row and shifts that anchor, so reserve low. */ .timeline-row-cv { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index c24aada951..102da58d38 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2224,6 +2224,71 @@ const mockChannels: MockChannel[] = [ createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1900), ], }), + // Jitter-corpus channel for the upscroll-jitter GATE (upscroll-jitter.perf.ts). + // Seeded with 400 structurally HETEROGENEOUS messages (headings, lists, + // blockquotes, fenced code, long wrapped prose) so `estimateRowHeight`'s known + // biases produce a genuine reserve-vs-true error on every never-painted row. + // Its own channel so the uniform `deep-history` seed the load-older specs + // depend on stays undisturbed. + createMockChannel({ + id: "feedf00d-0000-4000-8000-000000000008", + name: "jitter-corpus", + channel_type: "stream", + visibility: "open", + description: "Heterogeneous history for the upscroll-jitter gate", + topic: null, + purpose: null, + last_message_at: isoMinutesAgo(1), + archived_at: null, + created_by: ALICE_PUBKEY, + topic_set_by: null, + topic_set_at: null, + purpose_set_by: null, + purpose_set_at: null, + topic_required: false, + max_members: null, + nip29_group_id: null, + created_minutes_ago: 2000, + updated_minutes_ago: 1, + members: [ + createMockMember(ALICE_PUBKEY, "owner", 2000), + createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1900), + ], + }), + // Media-corpus channel for the W4a upscroll gate's MEDIA class + // (upscroll-media.perf.ts). Seeded with 400 rows whose realization error + // comes from MEDIA reserve-vs-true mismatch, not text mis-estimation: + // synchronous link-preview cards reserved flat at PREVIEW_CARD=70 but + // rendered taller, and dim-carrying images whose reserved scaled box differs + // from the rendered `h-auto object-contain` height. Deliberately does NOT + // lean on dim-less image decode — that path is pinned to a fixed 256px box + // (markdown/utils.ts:resolveImageReserveBox) and provably cannot reflow. + // Its own channel so the jitter-corpus seed stays undisturbed. + createMockChannel({ + id: "feedf00d-0000-4000-8000-000000000009", + name: "media-corpus", + channel_type: "stream", + visibility: "open", + description: "Media-heavy history for the upscroll gate's media class", + topic: null, + purpose: null, + last_message_at: isoMinutesAgo(1), + archived_at: null, + created_by: ALICE_PUBKEY, + topic_set_by: null, + topic_set_at: null, + purpose_set_by: null, + purpose_set_at: null, + topic_required: false, + max_members: null, + nip29_group_id: null, + created_minutes_ago: 2000, + updated_minutes_ago: 1, + members: [ + createMockMember(ALICE_PUBKEY, "owner", 2000), + createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1900), + ], + }), ]; const mockMessages = new Map(); @@ -2930,12 +2995,130 @@ function buildReplyMessageTags( return tags; } +// Body generator for the jitter-corpus seed. Six structurally distinct row +// kinds, cycled by index, chosen because `estimateRowHeight` mis-reserves most +// of them — the reserve-vs-true error the upscroll-jitter gate measures. +function jitterCorpusBody(i: number): string { + switch (i % 6) { + case 0: + // Short prose — estimator is close here (control rows). + return `quick note ${i}`; + case 1: + // Heading + list: estimated as flat 20px lines; rendered line boxes and + // list margins are taller. + return `# Heading ${i}\n\n- alpha item ${i}\n- beta item ${i}\n- gamma item ${i}\n- delta item ${i}`; + case 2: + // Long wrapped prose: real wrap width differs from CHARS_PER_LINE=64. + return `This is a deliberately long paragraph number ${i} that wraps across several visual lines depending on the true column width, which the fixed characters-per-line heuristic only approximates and therefore systematically mis-estimates on wide and narrow windows alike.`; + case 3: + // Blockquote: block margins the flat estimate ignores. + return `> quoted reply ${i}\n>\n> second quoted line ${i}\n\nfollow-up prose ${i}`; + case 4: + // Fenced code: code line-height is modeled, block padding/border is not. + return `runtime log ${i}:\n\`\`\`\nconst x = ${i};\nconsole.log(x * 2);\nreturn x;\n\`\`\``; + default: + // Medium two-paragraph prose. + return `paragraph one for row ${i}\n\nparagraph two for row ${i} with a little more text to push it onto a second wrapped line`; + } +} + +// A 1x1 transparent PNG as a data URL. Decodes synchronously and reliably in +// both headless engines, so a seeded media row realizes a real rendered box +// regardless of network — the mock harness never serves image bytes. The +// intrinsic 1x1 shape is irrelevant to layout: dim-carrying images get explicit +// width/height attributes (markdown.tsx:1528/1533) that drive the reserved +// aspect box, and `object-contain` letterboxes the 1x1 inside it. +const TINY_PNG_DATA_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/1p3AAAAAElFTkSuQmCC"; + +// Body + imeta-tag generator for the media-corpus seed. Eight row kinds cycled +// by index. The realization error is MEDIA reserve-vs-true mismatch, from three +// sources verified in-source (thread: Eva's premise verification, mainbase +// 2cc0eb53), NOT text mis-estimation: +// +// A. Link-preview cards (kinds 1-3): parsed synchronously from URL TEXT +// (parseSupportedLinkPreview — no network). Card renders at mount at its +// true height while estimateRowHeight reserved a flat PREVIEW_CARD=70. A +// title + provider + type label renders taller than 70 → GROW. Deterministic. +// B. Width-clamp images (kinds 4-5): CORRECT dims, but the estimator hardcodes +// MEDIA_MAX_WIDTH=384 while the render clamps to `max-w-[min(24rem,100%)]`. +// In a timeline column narrower than 384px the rendered image is width- +// limited SHORTER than the estimator's 384-based scale reserved → SHRINK, +// with no lying dim and no decode timing. (Eva's third divergence source.) +// C. Dim-mismatch images, BOTH directions (kinds 6-7): a dim that OVERSTATES +// height (reserve tall, render short → SHRINK) and one that UNDERSTATES +// (reserve short, render tall → GROW). Seeded in both directions so the +// corpus CAN produce SHRINK as well as GROW — a corpus that only makes GROW +// can't falsify the GROW-dominant prediction. +// Control (kind 0): short prose, no media — the calibration row. +// +// Deliberately NO dim-less-only image band: that path is pinned to a fixed +// 256px box (resolveImageReserveBox → useFixedReserveBox), reserve==mount, and +// provably cannot reflow. Including it would only add rows that never move. +function mediaCorpusBody(i: number): { content: string; imeta?: string[] } { + const url = (tag: string) => `${TINY_PNG_DATA_URL}#${tag}-${i}`; + switch (i % 8) { + case 0: + // Control — no media, estimator close. + return { content: `media note ${i}` }; + case 1: + // GitHub PR link: supported preview card. + return { + content: `landed the fix — https://github.com/block/buzz/pull/${1600 + i}`, + }; + case 2: + // Linear issue link: supported preview card. + return { + content: `tracking this in https://linear.app/block/issue/BUZZ-${i}/upscroll-anchor-reversal-in-the-slow-momentum-regime`, + }; + case 3: + // Google Docs link: supported preview card. + return { + content: `notes are in https://docs.google.com/document/d/1AbCdEfGhIjKlMnOpQrStUvWxYz${i}/edit`, + }; + case 4: { + // Width-clamp SHRINK: a wide image with a CORRECT dim. Estimator scales + // 900x600 against 384 → reserves ~256 (height-capped); a column < 384px + // wide renders it shorter than that. Width clamp, no lying dim. + const u = url("wide"); + return { + content: `wide shot ${i}:\n![grab](${u})`, + imeta: [`url ${u}`, "m image/png", "dim 900x600"], + }; + } + case 5: { + // Width-clamp SHRINK (portrait): 480x900 scales to ~256 tall at 384 wide, + // but a narrower column scales width-first and renders shorter. + const u = url("tall"); + return { + content: `portrait ${i}:\n![grab](${u})`, + imeta: [`url ${u}`, "m image/png", "dim 480x900"], + }; + } + case 6: { + // Dim OVERSTATES height → reserve tall, render short → SHRINK. + const u = url("overstate"); + return { + content: `overstated ${i}:\n![grab](${u})`, + imeta: [`url ${u}`, "m image/png", "dim 200x800"], + }; + } + default: { + // Dim UNDERSTATES height → reserve short, render tall → GROW. + const u = url("understate"); + return { + content: `understated ${i}:\n![grab](${u})`, + imeta: [`url ${u}`, "m image/png", "dim 800x120"], + }; + } + } +} + function getMockMessageStore(channelId: string): RelayEvent[] { const existing = mockMessages.get(channelId); if (existing) { return existing; } - const seeded: RelayEvent[] = channelId === "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" ? [ @@ -3115,7 +3298,49 @@ function getMockMessageStore(channelId: string): RelayEvent[] { content: `Deep history message #${index}`, sig: "mocksig".repeat(20).slice(0, 128), })) - : []; + : channelId === "feedf00d-0000-4000-8000-000000000008" + ? // Heterogeneous corpus for the upscroll-jitter gate. Row height + // varies widely AND `estimateRowHeight` is known to misjudge + // several of these kinds (headings/lists/blockquotes counted as + // flat 20px prose lines; long prose vs the fixed CHARS_PER_LINE + // wrap guess; code-fence chrome) — that reserve-vs-true mismatch + // is exactly the realization error the gate measures. + Array.from({ length: 400 }, (_, index) => ({ + id: `mock-jitter-${index}`, + pubkey: index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY, + created_at: + Math.floor(Date.now() / 1000) - (400 - index) * 60, + kind: 9, + tags: [["h", channelId]], + content: jitterCorpusBody(index), + sig: "mocksig".repeat(20).slice(0, 128), + })) + : channelId === "feedf00d-0000-4000-8000-000000000009" + ? // Media corpus for the W4a gate's media class. Row height error + // is MEDIA reserve-vs-true mismatch (link-preview cards flat- + // reserved at PREVIEW_CARD=70 but rendered taller; dim-lying + // images whose object-contain render diverges from the reserved + // scaled box) — not text mis-estimation. See mediaCorpusBody. + Array.from({ length: 400 }, (_, index) => { + const body = mediaCorpusBody(index); + return { + id: `mock-media-${index}`, + pubkey: + index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY, + created_at: + Math.floor(Date.now() / 1000) - (400 - index) * 60, + kind: 9, + tags: body.imeta + ? [ + ["h", channelId], + ["imeta", ...body.imeta], + ] + : [["h", channelId]], + content: body.content, + sig: "mocksig".repeat(20).slice(0, 128), + }; + }) + : []; mockMessages.set(channelId, seeded); return seeded; diff --git a/desktop/tests/e2e/upscroll-1px-live.perf.ts b/desktop/tests/e2e/upscroll-1px-live.perf.ts new file mode 100644 index 0000000000..897f1c8e92 --- /dev/null +++ b/desktop/tests/e2e/upscroll-1px-live.perf.ts @@ -0,0 +1,433 @@ +import { expect, test } from "@playwright/test"; +import { decode } from "nostr-tools/nip19"; +import { getPublicKey } from "nostr-tools/pure"; + +import { installRelayBridge } from "../helpers/bridge"; + +/** + * LIVE 1-PIXEL UPSCROLL PROBE — Tyler's ask (2026-07-08): + * run the GUI against the LIVE staging relay, scroll up 1px at a time for + * many thousands of pixels (crossing multiple real fetchOlder scrollback + * loads in #buzz-bugs), and record how many pixels of VISIBLE MOVEMENT occur + * per 1px of input. + * + * Method: same live-relay harness as scrollback-buzzbugs.perf.ts (identity + * override, port-forward + Host rewrite, ACAO injection). Actuation is a + * synchronous `scrollTop -= 1` + scroll event per step with one RAF settle, + * batched in-page (800 steps/evaluate) so 15k+ steps are feasible. Per step + * we record: + * move = tracked center row's rect.top delta (the felt movement) + * appliedTop = scrollTop_before - scrollTop_after (what the scroller did) + * mounted = [data-message-id] count (prepend detection) + * fetch = __CHANNEL_WINDOW_FETCH_COUNT__ (fetchOlder activity) + * Smooth = move === +1 every step. Jank = |move| >> 1 (realization lurch, + * anchor jump, prepend re-anchor). + * + * WKWebView mirror: `overflow-anchor: none` forced on the scroller (the + * shipped engine has no scroll anchoring); the production computed value is + * logged first, un-forced, for the anchor-contract record. + * + * Run: + * kubectl --context bke-coder-stage -n sprout port-forward svc/sprout-relay 13000:3000 & + * BUZZ_E2E_RELAY_URL=http://127.0.0.1:13000 \ + * BUZZ_COMMUNITY_HOST=sprout-oss.stage.blox.sqprod.co \ + * BUZZ_PERF_NSEC=nsec1... \ + * npx playwright test --config=playwright.perf.config.ts upscroll-1px-live + */ + +const RELAY_HTTP = + process.env.BUZZ_E2E_RELAY_URL ?? "https://sprout-oss.stage.blox.sqprod.co"; +const NSEC = process.env.BUZZ_PERF_NSEC ?? ""; +const COMMUNITY_HOST = process.env.BUZZ_COMMUNITY_HOST ?? ""; +const TARGET_CHANNEL = process.env.BUZZ_PERF_CHANNEL ?? "buzz-bugs"; +// Stop when BOTH: at least this many fetchOlder pages landed AND this many +// pixels walked — or the step cap / top of history, whichever first. +const MIN_FETCHES = Number(process.env.BUZZ_PERF_MIN_FETCHES ?? 3); +const MIN_PX = Number(process.env.BUZZ_PERF_MIN_PX ?? 8000); +const MAX_STEPS = Number(process.env.BUZZ_PERF_MAX_STEPS ?? 20000); +const BATCH = 800; +const SAFE_MARGIN = 60; +// Pixels per input step (1 = original probe; 100+ = Tyler's big-notch repro). +const STEP_PX = Number(process.env.BUZZ_PERF_STEP_PX ?? 1); + +const IDENTITY_OVERRIDE_KEY = "buzz:e2e-identity-override.v1"; +const ONBOARDING_PREFIX = "buzz-onboarding-complete.v1:"; +const WELCOME_PREFIX = "buzz-welcome-channel-ensured.v2:"; +const REAL_CHROME_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"; + +test.use({ userAgent: REAL_CHROME_UA }); + +function deriveIdentity(nsec: string) { + const decoded = decode(nsec.trim()); + if (decoded.type !== "nsec") throw new Error("BUZZ_PERF_NSEC is not an nsec"); + const skBytes = decoded.data as Uint8Array; + return { + privateKey: Buffer.from(skBytes).toString("hex"), + pubkey: getPublicKey(skBytes), + username: "perf-eva", + }; +} + +type Step = { + i: number; + move: number | null; // tracked row rect.top delta; null = row lost this step + appliedTop: number; + mounted: number; + fetch: number; + scrollTop: number; + repick: boolean; // tracked row re-picked before this step +}; + +test("MEASURE: live 1px upscroll movement profile", async ({ page }) => { + test.setTimeout(900_000); + if (!NSEC) throw new Error("Set BUZZ_PERF_NSEC to a real member nsec"); + const identity = deriveIdentity(NSEC); + + await installRelayBridge(page, "tyler"); + const wsUrl = RELAY_HTTP.replace(/^http/, "ws"); + await page.addInitScript( + ({ ident, onboardingPrefix, welcomePrefix, relayUrl, overrideKey }) => { + window.localStorage.setItem(overrideKey, JSON.stringify(ident)); + window.localStorage.setItem(`${onboardingPrefix}${ident.pubkey}`, "true"); + window.localStorage.setItem( + `${welcomePrefix}${encodeURIComponent(relayUrl)}:${ident.pubkey}`, + "true", + ); + const w = window as unknown as { __BUZZ_E2E__?: Record }; + w.__BUZZ_E2E__ = { ...(w.__BUZZ_E2E__ ?? {}), identity: ident }; + }, + { + ident: identity, + onboardingPrefix: ONBOARDING_PREFIX, + welcomePrefix: WELCOME_PREFIX, + relayUrl: wsUrl, + overrideKey: IDENTITY_OVERRIDE_KEY, + }, + ); + + const relayHost = new URL(RELAY_HTTP).host; + await page.route( + (url) => url.host === relayHost, + async (route) => { + const req = route.request(); + const fwd = { ...req.headers(), "user-agent": REAL_CHROME_UA }; + delete fwd["sec-ch-ua"]; + delete fwd["sec-ch-ua-mobile"]; + delete fwd["sec-ch-ua-platform"]; + if (COMMUNITY_HOST) fwd.host = COMMUNITY_HOST; + // The port-forward can drop mid-run; retry a few times rather than + // aborting a 15k-step walk on one transient ECONNREFUSED. + let resp: Awaited> | null = null; + for (let attempt = 0; attempt < 5; attempt++) { + try { + resp = await route.fetch({ headers: fwd }); + break; + } catch (err) { + if (attempt === 4) throw err; + await new Promise((r) => setTimeout(r, 1500)); + } + } + if (!resp) throw new Error("unreachable"); + const body = req.postData() ?? ""; + if (body.includes("before_id") || body.includes("until")) { + console.log( + `[net] ${new URL(req.url()).pathname} ${resp.status()} ${(await resp.body()).byteLength}b body=${body.slice(0, 140)}`, + ); + } + const headers = { ...resp.headers() }; + headers["access-control-allow-origin"] = "*"; + headers["access-control-allow-headers"] = "*"; + headers["access-control-allow-methods"] = "*"; + await route.fulfill({ response: resp, headers, body: await resp.body() }); + }, + ); + + await page.goto("/"); + await page.getByTestId("app-sidebar").waitFor({ state: "visible" }); + const chan = page.getByTestId(`channel-${TARGET_CHANNEL}`).first(); + await chan.waitFor({ state: "visible", timeout: 45_000 }); + await chan.click(); + await page + .locator('[data-testid="message-timeline"] [data-message-id]') + .first() + .waitFor({ state: "visible", timeout: 30_000 }); + await page.waitForTimeout(3000); // let the open burst settle + + const timeline = page.getByTestId("message-timeline"); + + // Production anchor contract, read BEFORE forcing anything. + const anchor = await timeline.evaluate((el) => ({ + supports: CSS.supports("overflow-anchor", "none"), + computed: getComputedStyle(el as HTMLElement).overflowAnchor ?? "(none)", + })); + console.log( + `[anchor] CSS.supports=${anchor.supports} production computed=${anchor.computed}`, + ); + // WKWebView mirror: no engine-side anchoring during the walk. + await timeline.evaluate((el) => { + (el as HTMLElement).style.overflowAnchor = "none"; + }); + + // Pin to true bottom. + await timeline.evaluate((element) => { + const el = element as HTMLDivElement; + el.scrollTop = el.scrollHeight; + el.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await page.waitForTimeout(500); + + const all: Step[] = []; + const prependCommits: Array<{ + step: number; + mountedFrom: number; + mountedTo: number; + scrollTopTo: number; + feltJump: number | null; + }> = []; + let ended = "cap"; + let stepIndex = 0; + + while (stepIndex < MAX_STEPS) { + const batch = await timeline.evaluate( + async ( + element, + args: { n: number; margin: number; base: number; step: number }, + ) => { + const el = element as HTMLDivElement; + const probe = window as unknown as { + __CHANNEL_WINDOW_FETCH_COUNT__?: number; + }; + const out: Array<{ + i: number; + move: number | null; + appliedTop: number; + mounted: number; + fetch: number; + scrollTop: number; + repick: boolean; + }> = []; + let end = ""; + let trackedId: string | null = null; + + const pick = (): { id: string; top: number } | null => { + const box = el.getBoundingClientRect(); + const mid = box.top + box.height / 2; + let best: { id: string; top: number; d: number } | null = null; + for (const row of el.querySelectorAll( + "[data-message-id]", + )) { + const r = row.getBoundingClientRect(); + if ( + r.top <= box.top + args.margin || + r.bottom >= box.bottom - args.margin + ) + continue; + const d = Math.abs((r.top + r.bottom) / 2 - mid); + if (!best || d < best.d) + best = { id: row.dataset.messageId ?? "", top: r.top, d }; + } + return best && best.id ? { id: best.id, top: best.top } : null; + }; + + const locate = (id: string): number | null => { + const box = el.getBoundingClientRect(); + const row = el.querySelector( + `[data-message-id="${CSS.escape(id)}"]`, + ); + if (!row) return null; + const r = row.getBoundingClientRect(); + if ( + r.top <= box.top + args.margin || + r.bottom >= box.bottom - args.margin + ) + return null; // left safe band + return r.top; + }; + + for (let i = 0; i < args.n; i++) { + if (el.scrollTop <= 0) { + end = "top"; + break; + } + let repick = false; + let beforeTop = trackedId ? locate(trackedId) : null; + if (beforeTop === null) { + const p = pick(); + repick = true; + if (!p) { + trackedId = null; + } else { + trackedId = p.id; + beforeTop = p.top; + } + } + const beforeScroll = el.scrollTop; + + el.scrollTop = Math.max(0, el.scrollTop - args.step); + el.dispatchEvent(new Event("scroll", { bubbles: true })); + await new Promise((r) => requestAnimationFrame(() => r())); + + const afterTop = trackedId ? locate(trackedId) : null; + out.push({ + i: args.base + i, + move: + beforeTop !== null && afterTop !== null + ? afterTop - beforeTop + : null, + appliedTop: beforeScroll - el.scrollTop, + mounted: el.querySelectorAll("[data-message-id]").length, + fetch: probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0, + scrollTop: el.scrollTop, + repick, + }); + } + return { out, end }; + }, + { n: BATCH, margin: SAFE_MARGIN, base: stepIndex, step: STEP_PX }, + ); + + all.push(...batch.out); + stepIndex += batch.out.length; + const fetches = all.length ? all[all.length - 1].fetch : 0; + const walked = all.reduce((s, x) => s + Math.max(0, x.appliedTop), 0); + console.log( + `[progress] steps=${stepIndex} walkedPx=${walked.toFixed(0)} fetches=${fetches} mounted=${all[all.length - 1]?.mounted ?? "?"} scrollTop=${all[all.length - 1]?.scrollTop.toFixed(0) ?? "?"}`, + ); + if (batch.end === "top") { + // We are at scrollTop=0. A fetchOlder page may be in flight (the 600px + // sentinel fires well before the top). Wait for the prepend to commit, + // measure the FELT jump it causes on a visible row, then keep walking + // into the paged-in history. Only a timeout means true top-of-history. + const beforeWait = await timeline.evaluate((element) => { + const el = element as HTMLDivElement; + const first = el.querySelector("[data-message-id]"); + return { + mounted: el.querySelectorAll("[data-message-id]").length, + scrollTop: el.scrollTop, + firstId: first?.dataset.messageId ?? null, + firstTop: first?.getBoundingClientRect().top ?? 0, + }; + }); + const grew = await page + .waitForFunction( + (n) => + document.querySelectorAll( + '[data-testid="message-timeline"] [data-message-id]', + ).length > n, + beforeWait.mounted, + { timeout: 15_000 }, + ) + .then(() => true) + .catch(() => false); + if (!grew) { + ended = "top-of-history"; + break; + } + await page.waitForTimeout(300); // let the re-anchor settle + const afterWait = await timeline.evaluate( + (element, firstId: string | null) => { + const el = element as HTMLDivElement; + const row = firstId + ? el.querySelector( + `[data-message-id="${CSS.escape(firstId)}"]`, + ) + : null; + return { + mounted: el.querySelectorAll("[data-message-id]").length, + scrollTop: el.scrollTop, + firstTop: row?.getBoundingClientRect().top ?? null, + }; + }, + beforeWait.firstId, + ); + const felt = + afterWait.firstTop !== null + ? afterWait.firstTop - beforeWait.firstTop + : null; + console.log( + `[prepend-commit] mounted ${beforeWait.mounted}->${afterWait.mounted} scrollTop ${beforeWait.scrollTop.toFixed(0)}->${afterWait.scrollTop.toFixed(0)} FELT-JUMP of anchored row=${felt === null ? "row-gone" : `${felt.toFixed(1)}px`}`, + ); + prependCommits.push({ + step: stepIndex, + mountedFrom: beforeWait.mounted, + mountedTo: afterWait.mounted, + scrollTopTo: afterWait.scrollTop, + feltJump: felt, + }); + continue; + } + if (fetches >= MIN_FETCHES && walked >= MIN_PX) { + ended = "target-reached"; + break; + } + } + + // ---- REPORT ---- + const scored = all.filter((s) => s.move !== null) as Array< + Step & { move: number } + >; + const hist = new Map(); + for (const s of scored) { + const b = Math.round(s.move); + hist.set(b, (hist.get(b) ?? 0) + 1); + } + console.log( + `\n=== LIVE UPSCROLL PROFILE (step=${STEP_PX}px): #${TARGET_CHANNEL} ===`, + ); + console.log( + `relay=${RELAY_HTTP} steps=${all.length} scored=${scored.length} ended=${ended}`, + ); + console.log( + `total input px=${all.length} · applied px=${all.reduce((s, x) => s + Math.max(0, x.appliedTop), 0)} · fetchOlder pages=${all.length ? all[all.length - 1].fetch : 0}`, + ); + console.log("\n--- movement histogram (px moved per 1px input, rounded) ---"); + for (const [k, v] of [...hist.entries()].sort((a, b) => a[0] - b[0])) + console.log(` ${String(k).padStart(6)}px : ${v}`); + + const smooth = scored.filter( + (s) => Math.abs(s.move - STEP_PX) <= Math.max(0.5, STEP_PX * 0.1), + ).length; + console.log( + `\nsmooth steps (move≈STEP=${STEP_PX}px): ${smooth}/${scored.length} = ${((100 * smooth) / Math.max(1, scored.length)).toFixed(2)}%`, + ); + + const jumps = scored + .filter((s) => Math.abs(s.move - STEP_PX) > Math.max(2, STEP_PX * 0.25)) + .sort((a, b) => Math.abs(b.move) - Math.abs(a.move)); + console.log( + `\n--- jumps |move-STEP| > max(2, STEP*0.25): ${jumps.length} ---`, + ); + let prevFetch = 0; + const fetchSteps: number[] = []; + for (const s of all) { + if (s.fetch > prevFetch) fetchSteps.push(s.i); + prevFetch = s.fetch; + } + console.log(`fetchOlder landed at steps: ${fetchSteps.join(", ")}`); + for (const s of jumps.slice(0, 40)) + console.log( + ` step=${s.i} move=${s.move.toFixed(1)}px applied=${s.appliedTop.toFixed(1)} mounted=${s.mounted} fetch=${s.fetch} scrollTop=${s.scrollTop.toFixed(0)} repick=${s.repick}`, + ); + + console.log( + `\n--- prepend commits observed at top (${prependCommits.length}) ---`, + ); + for (const c of prependCommits) + console.log( + ` step=${c.step} mounted ${c.mountedFrom}->${c.mountedTo} scrollTopAfter=${c.scrollTopTo.toFixed(0)} feltJump=${c.feltJump === null ? "row-gone" : `${c.feltJump.toFixed(1)}px`}`, + ); + + // Prepend re-anchors (scrollTop jumped up) for the record. + const reanchors = all.filter((s) => s.appliedTop < -50); + console.log( + `\n--- prepend re-anchors (appliedTop < -50): ${reanchors.length} ---`, + ); + for (const s of reanchors.slice(0, 20)) + console.log( + ` step=${s.i} appliedTop=${s.appliedTop.toFixed(0)} mounted=${s.mounted} fetch=${s.fetch} scrollTop=${s.scrollTop.toFixed(0)}`, + ); + console.log("==============================================\n"); + + expect(all.length * STEP_PX).toBeGreaterThan(1000); +}); diff --git a/desktop/tests/e2e/upscroll-anchor-contract.perf.ts b/desktop/tests/e2e/upscroll-anchor-contract.perf.ts new file mode 100644 index 0000000000..e451b45bc7 --- /dev/null +++ b/desktop/tests/e2e/upscroll-anchor-contract.perf.ts @@ -0,0 +1,51 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +/** + * ANCHOR-CONTRACT GUARD (Wren #4) — companion to `upscroll-jitter.perf.ts`. + * + * The jitter gate *forces* `overflow-anchor: none` on the scroller before it + * measures, to reproduce shipped WKWebView (which has no `overflow-anchor` and + * so corrects nothing) on anchoring Chromium. That force is a test convenience, + * not a proof: it would stay green even if production ever stopped shipping the + * property, silently handing correction back to Chromium's native anchoring and + * masking a WKWebView-only regression on device. + * + * This test closes that gap by reading the PRODUCTION computed style — no test + * override — on the real conversation scroller and asserting it resolves to + * `none`. We run on Chromium, whose default `overflow-anchor` is `auto`, so a + * resolved value of `none` can only have come from the shipped stylesheet + * (`utilities.css` `[data-buzz-conversation-scroll]`). The engine-support + * assert keeps the check honest: if it ever runs somewhere without the property + * at all, we want the loud failure, not a vacuous pass on an empty string. + */ +test("CONTRACT: production ships overflow-anchor:none on the conversation scroller", 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(); + await expect(page.getByTestId("chat-title")).toHaveText("jitter-corpus"); + const timeline = page.getByTestId("message-timeline"); + await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); + + // Sanity: Chromium DOES support overflow-anchor (default `auto`), so a + // resolved `none` below is the production stylesheet's doing, not the engine + // returning an empty/unsupported value. + const anchorSupported = await page.evaluate(() => + typeof CSS !== "undefined" && typeof CSS.supports === "function" + ? CSS.supports("overflow-anchor", "auto") + : false, + ); + expect(anchorSupported).toBe(true); + + const resolvedOverflowAnchor = await timeline.evaluate( + (element) => getComputedStyle(element).overflowAnchor, + ); + expect(resolvedOverflowAnchor).toBe("none"); +}); diff --git a/desktop/tests/e2e/upscroll-fast-classify.perf.ts b/desktop/tests/e2e/upscroll-fast-classify.perf.ts new file mode 100644 index 0000000000..0bcc106dd5 --- /dev/null +++ b/desktop/tests/e2e/upscroll-fast-classify.perf.ts @@ -0,0 +1,467 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +/** + * Fast-corpus reversal characterization — the SKIP admission gate. + * + * WHY THIS EXISTS. The W4a gate (`upscroll-raf-correction.perf.ts`) drives a + * CONSTANT 12px/32ms (~375px/s) upscroll and, on WebKit, leaves 3-4 bounded + * reversal survivors (rows mock-jitter-387/393/381/375) that PASS the ≤4 gate. + * The classifier that typed those survivors as SKIP (fired=false, + * signedShift=0.0 → momentum-skip gate) was run against a fast-drive variant of + * the slow-scroll leg that was NEVER committed — so the SKIP labels were not + * reproducible from the tree. This fixture is that fast-drive classifier, + * committed, so the admission evidence is permanent and re-runnable. It reuses + * the slow leg's `probeLen` append-count join verbatim and adds two Leg-5 + * cross-checks (thread event 2a4e31fa, Eva's admission-gate ruling): + * + * 1. `dev = rowMove − scrollDelta` per reversal — Leg 5's rendered deviation + * from pure scroll-tracking. On a SKIP frame scrollDelta≈0 so dev≈rowMove + * with NO fired write behind it = abandonment, not a corrector footprint. + * 2. A WIDEN-INDEPENDENT neighborhood dump. Dawn's class attribution picks the + * single largest-|signedShift| attempt in a ±1-frame append-count window; + * the ±1 widen is the soft joint Eva flagged. This fixture ALSO reports, + * for every reversal, whether ANY `wouldFire=true` record exists in a + * WIDER ±2-frame window — attribution-free. If no fired write sits near a + * survivor at any reasonable window width, SKIP is robust to the widen; if + * one does, the largest-|shift| rule would have labelled it grow/shrink and + * the SKIP bin is in question. That neighborhood flag is the admission gate. + * + * HONESTY BOUND (unchanged from the slow leg): Playwright `mouse.wheel` is a + * synthetic discrete event; the WebKit `dScroll=0.0` coalesced still frame is a + * real-device phenomenon. But the fast gate corpus DOES surface the bounded + * survivors on Playwright WebKit, so this fixture reproduces the frames the + * admission gate must rule on. It CHARACTERIZES; it does not gate a ceiling. + */ + +// Fast constant drive — identical to the W4a gate (`upscroll-raf-correction`), +// so this fixture surfaces the same bounded survivors the gate leaves. +const WHEEL_DELTA = 12; // px/event — matches the gate's constant velocity +const WHEEL_PERIOD_MS = 32; // gate cadence (~375px/s) +const DURATION_MS = 12_000; +const SAFE_MARGIN = 100; +// Same reversal definition as the gate: row moving against the scroll by more +// than staircase noise. Upscroll → rowMove normally >= 0, so a genuine +// against-direction move is < -REVERSAL_PX. +const REVERSAL_PX = 3; +// Must equal `ANCHOR_BUILD_STAMP` in `useAnchoredScroll.ts` — stale-`dist` +// guard (see the gate fixture). Bump BOTH together per experiment. +const EXPECTED_BUILD_STAMP = "w4a-gate-1"; + +type Frame = { + t: number; + top: number | null; + scrollTop: number; + mounted: number; + rowId: string | null; + probeLen: number; +}; + +test("W4a fast-classify: SKIP admission — no fired write near the survivors", 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)); + 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; + }); + + 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 (identical row-tracking to the gate, plus a join key into + // the hook's correction probe). The fixture sampler and the hook's rAF sampler + // are SEPARATE rAF loops, so "the correction for frame i" is not reliably the + // same-tick probe entry (two rAF callbacks in one frame fire in registration + // order, which we don't control). The order-robust join is by APPEND COUNT: + // each frame records `probeLen` (the correction-probe array length at that + // tick) and the signed shift + fire flag of any attempts that appended since + // the previous frame. A reversal between frame i-1 and i is then attributed to + // the attempts in that interval — no same-tick ordering assumption. + await timeline.evaluate((element, margin: number) => { + const el = element as HTMLDivElement; + const w = window as unknown as { + __PROBE__: { frames: Frame[]; stop: boolean }; + }; + const g = globalThis as unknown as { + __ANCHOR_PROBE__?: Array<{ + wouldFire: boolean; + residual: number; + signedShift: number; + }>; + }; + type Frame = { + t: number; + top: number | null; + scrollTop: number; + mounted: number; + rowId: string | null; + // Correction-probe array length at this tick — the append-count join key. + probeLen: number; + }; + 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(); + w.__PROBE__.frames.push({ + t, + top, + scrollTop: el.scrollTop, + mounted, + rowId: trackedId, + probeLen: g.__ANCHOR_PROBE__?.length ?? 0, + }); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }, SAFE_MARGIN); + + // Fast constant drive — matches the W4a gate exactly, so the same bounded + // survivors surface. No decay: this is the fast regime, not Tyler's slow one. + 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; + probeLen: number; + }; + w.__PROBE__.stop = true; + return w.__PROBE__.frames; + }); + + const { corrections, buildStamp } = await page.evaluate(() => { + const g = globalThis as unknown as { + __ANCHOR_PROBE__?: Array<{ + source: "raf" | "ro"; + wouldFire: boolean; + residual: number; + signedShift: number; + renderedScroll?: number; + }>; + __ANCHOR_BUILD_STAMP__?: string; + }; + return { + corrections: g.__ANCHOR_PROBE__ ?? [], + buildStamp: g.__ANCHOR_BUILD_STAMP__ ?? null, + }; + }); + + // Score same-row frame pairs. A reversal is rowMove <= -REVERSAL_PX. For each + // reversal, join to the correction attempt(s) that appended to the hook probe + // BETWEEN frame i-1 and i (append-count window: probe indices [a.probeLen, + // b.probeLen)). Classify by the SIGN of aboveShift + whether the write fired — + // the three-way discriminator Sami specced (thread event 5b46582e): + // • wouldFire == false → SKIP: momentum gate (:29) / cross- + // check (:451) suppressed the write. The reversal is UNCORRECTED reflow; + // absorption never got to act. A 27→27 here = wiring/gate, not physics. + // • fired, signedShift > 0 (GROW) → content above grew, anchor shoved + // DOWN, the correction WRITE is the felt backward snap. Absorption's + // amortizable topology — deferring the pullback into forward frames helps. + // • fired, signedShift < 0 (SHRINK) → content above shrank, the reflow + // ITSELF pulls the anchor up and renders the reversal before any write. + // Structurally uncorrectable by us; only smaller per-frame realization + // (Max's pre-realization band / contain-intrinsic-size) shrinks it. + // A reversal with no attempt in its window is UNATTRIBUTED (the correcting + // observer's attempt landed in an adjacent frame under rAF interleave) — we + // count it separately rather than force it into a class. + let scored = 0; + let reanchors = 0; + type Klass = "skip" | "grow" | "shrink" | "unattributed"; + const reversals: Array<{ + i: number; + rowMove: number; + dScroll: number; + dev: number; // Leg 5: rowMove − scrollDelta (rendered deviation from tracking) + signedShift: number | null; + fired: boolean; + klass: Klass; + // Widen-independent admission flag: any wouldFire=true record in a WIDER + // ±2-frame append-count window than the ±1 attribution window. If false, + // no fired write sits near this reversal at any reasonable width → SKIP is + // robust to the widen. If true, the class attribution's largest-|shift| rule + // could have labelled it grow/shrink and the SKIP bin is in question. + firedNear: boolean; + 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; + const dScroll = b.scrollTop - a.scrollTop; + if (rowMove > -REVERSAL_PX) continue; + // Attribution window. The hook's correction attempt for the reflow that + // produced this reversal can append across a ±1-frame span relative to our + // sampler: the two rAF loops interleave in an order we don't control, and on + // WebKit a late RO appends a frame after the reflow paints. So the window is + // [prev-frame probeLen, NEXT-frame probeLen) — attempts from the frame + // before through the frame after. A reversal with NO attempt anywhere in + // that span is genuinely unattributed (the corrector did not run a mid- + // history attempt on those frames at all — e.g. re-pick guard or null cur), + // which is itself a distinct diagnosis from a fired-then-clamped write. + const next = frames[i + 1] ?? b; + const window = corrections.slice(a.probeLen, next.probeLen); + let attempt: (typeof corrections)[number] | null = null; + for (const c of window) { + if ( + attempt === null || + Math.abs(c.signedShift) > Math.abs(attempt.signedShift) + ) { + attempt = c; + } + } + let klass: Klass; + if (attempt === null) { + klass = "unattributed"; + } else if (!attempt.wouldFire) { + klass = "skip"; + } else { + klass = attempt.signedShift >= 0 ? "grow" : "shrink"; + } + // Leg 5 rendered deviation from pure scroll-tracking. A correctly-anchored + // row moves only with scroll (rowMove == scrollDelta), so any deviation is + // the corrector's footprint — or, on a SKIP, its ABSENCE. + const dev = rowMove - dScroll; + // Widen-independent admission check. Look one frame WIDER than the ±1 + // attribution window ([i-2 .. i+2] via probeLen) and ask only: is there ANY + // fired write in that neighborhood? This does not pick a single attempt or + // depend on the largest-|shift| tie-break, so it cannot be flipped by the + // widen. A SKIP survivor must have firedNear=false: no write could be the + // backward mover if none fired near the frame at all. + const lo = frames[i - 2] ?? a; + const hi = frames[i + 2] ?? next; + const neighborhood = corrections.slice(lo.probeLen, hi.probeLen); + const firedNear = neighborhood.some((c) => c.wouldFire); + reversals.push({ + i, + rowMove, + dScroll, + dev, + signedShift: attempt?.signedShift ?? null, + fired: attempt?.wouldFire ?? false, + klass, + firedNear, + rowId: b.rowId, + }); + } + + const maxReversalPx = + reversals.length === 0 + ? 0 + : Math.max(...reversals.map((r) => Math.abs(r.rowMove))); + // A reversal on a near-still frame (|dScroll| < REVERSAL_PX) is the felt case: + // the eye is barely moving, so a backward row snap is maximally visible. + const stillFrameReversals = reversals.filter( + (r) => Math.abs(r.dScroll) < REVERSAL_PX, + ); + const byClass = (k: Klass) => reversals.filter((r) => r.klass === k).length; + + /* eslint-disable no-console */ + console.log("\n=== W4a FAST-CORPUS SKIP ADMISSION ==="); + console.log(`engine: ${browserName}`); + console.log(`build stamp: ${buildStamp ?? "(absent)"}`); + 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(` of which still-frame: ${stillFrameReversals.length}`); + console.log(`max reversal px: ${maxReversalPx.toFixed(1)}`); + console.log("--- reversal class mix (Sami's discriminator) ---"); + console.log(` SKIP (gate/xcheck, uncorrected reflow): ${byClass("skip")}`); + console.log( + ` GROW (fired, write is the snap — absorb): ${byClass("grow")}`, + ); + console.log( + ` SHRINK (fired, reflow renders it — Max): ${byClass("shrink")}`, + ); + console.log( + ` UNATTRIBUTED (no attempt in window): ${byClass("unattributed")}`, + ); + for (const r of reversals + .slice() + .sort((x, y) => x.rowMove - y.rowMove) + .slice(0, 12)) { + const s = r.signedShift === null ? "n/a" : r.signedShift.toFixed(1); + console.log( + ` frame ${r.i} rowMove=${r.rowMove.toFixed(1)} dScroll=${r.dScroll.toFixed(1)} dev=${r.dev.toFixed(1)} signedShift=${s} fired=${r.fired} firedNear=${r.firedNear} class=${r.klass} row=${r.rowId}`, + ); + } + console.log("========================================\n"); + /* eslint-enable no-console */ + + // Sanity: the actuation actually produced a scored upscroll. + expect(scored).toBeGreaterThan(50); + // Stale-`dist` guard — a characterization on a stale bundle misleads exactly + // like a stale gate run. Assert the experiment's stamp ran. + expect(buildStamp).toBe(EXPECTED_BUILD_STAMP); + // Liveness: at least one mid-history correction fired, else the corpus + // realized nothing and the distribution above is vacuous. + const anyFired = corrections.some((c) => c.wouldFire); + expect(anyFired).toBe(true); + + // --- SKIP ADMISSION GATE (Eva, thread event 2a4e31fa) ----------------------- + // Every reversal typed SKIP must have NO fired write in its ±2-frame + // neighborhood. This is attribution-free: it does not depend on the ±1 widen + // or the largest-|shift| tie-break, so a SKIP that survives it is robust to + // the soft joint in the classifier. If any SKIP shows firedNear=true, a write + // did land near the frame and the class attribution mis-labelled it — the bin + // is not admissible and this fails loudly rather than passing a stale claim. + // (Characterization otherwise; the reversal count itself is not gated.) + const skips = reversals.filter((r) => r.klass === "skip"); + for (const r of skips) { + expect( + r.firedNear, + `SKIP survivor frame ${r.i} (row ${r.rowId}) has a fired write in its ±2-frame neighborhood — attribution is not widen-robust, bin in question`, + ).toBe(false); + } + + // --- DECOMPOSITION SELF-TEST (Eva: arm's first run must SHOW it holds) ------- + // The w4a-gate-1 fix keys the momentum gate off the RENDERED scroll + // (`renderedScroll = aboveShift − ΔtopOffset`) the hook emits per rAF attempt, + // not the raw `Δscrolltop`. The load-bearing claim is that `renderedScroll` + // ISOLATES the painted wheel component by subtracting the reflow's own push on + // the anchor — so it stays small on a genuine reflow even when `signedShift` + // (the reflow) is large. Prove that independence from the emit, NOT the gate's + // own branch (asserting the gate skips when |renderedScroll|>bound is + // circular). We compare two populations of rAF attempts: + // • PURE-REFLOW — a real above-anchor reflow (`|signedShift|` well past the + // 0.5px epsilon) on a rendered-still frame. If the decomposition works, + // `renderedScroll` here is SMALL (the reflow push was removed), NOT tracking + // `signedShift`. This is the WebKit survivor the raw gate dropped. + // • PURE-SCROLL — no reflow (`signedShift` ≈ 0). `renderedScroll` here is + // free to be large: it is the wheel motion, with nothing to subtract. + // The proof: pure-reflow's median |renderedScroll| is well BELOW its median + // |signedShift| — the reflow did not leak into the gated quantity — while + // pure-scroll shows |renderedScroll| CAN run large. If renderedScroll merely + // echoed signedShift (a broken decomposition) the reflow bin would fail this. + const median = (xs: number[]): number => { + if (xs.length === 0) return 0; + const s = [...xs].sort((p, q) => p - q); + return s[Math.floor(s.length / 2)]; + }; + const rafWithRendered = corrections.filter( + (c): c is typeof c & { renderedScroll: number } => + c.source === "raf" && typeof c.renderedScroll === "number", + ); + const pureReflow = rafWithRendered.filter((c) => Math.abs(c.signedShift) > 5); + const pureScroll = rafWithRendered.filter( + (c) => Math.abs(c.signedShift) <= 0.5, + ); + const reflowMedRendered = median( + pureReflow.map((c) => Math.abs(c.renderedScroll)), + ); + const reflowMedShift = median(pureReflow.map((c) => Math.abs(c.signedShift))); + const scrollMaxRendered = pureScroll.length + ? Math.max(...pureScroll.map((c) => Math.abs(c.renderedScroll))) + : 0; + /* eslint-disable no-console */ + console.log("=== DECOMPOSITION SELF-TEST (w4a-gate-1) ==="); + console.log(`rAF attempts w/ renderedScroll: ${rafWithRendered.length}`); + console.log(`pure-reflow attempts (|shift|>5): ${pureReflow.length}`); + console.log( + ` median |signedShift|: ${reflowMedShift.toFixed(1)}`, + ); + console.log( + ` median |renderedScroll|: ${reflowMedRendered.toFixed(1)}`, + ); + console.log( + ` fired: ${pureReflow.filter((c) => c.wouldFire).length}`, + ); + console.log(`pure-scroll attempts (|shift|<=.5): ${pureScroll.length}`); + console.log( + ` max |renderedScroll|: ${scrollMaxRendered.toFixed(1)}`, + ); + console.log("============================================\n"); + /* eslint-enable no-console */ + // Both populations must be exercised, else the decomposition is untested. + // Assertions are WEBKIT-ONLY: the rAF momentum gate is the ACTIVE corrector + // only on WebKit (the RO is late). On Chromium the on-time RO corrects and + // refreshes the cache first, so the rAF path is the passive loser observer — + // it never fires (ratified mechanism) and reads the reflow BEFORE the RO's + // compensation, so `renderedScroll` there does not cancel and tracks + // `signedShift` instead. That is the correct Chromium behavior, not a + // decomposition failure, so we characterize it (logged above) but only assert + // the decomposition on the engine whose gate the fix rekeyed. + if (browserName === "webkit") { + expect(pureReflow.length).toBeGreaterThan(0); + expect(pureScroll.length).toBeGreaterThan(0); + // THE PROOF: on real reflow frames the reflow does NOT leak into + // renderedScroll — its median stays well below the reflow magnitude (a + // broken decomposition that echoed signedShift would fail this). + expect(reflowMedRendered).toBeLessThan(reflowMedShift); + // And a genuine rendered-still reflow is let through the gate — the survivor + // the raw-delta gate dropped on WebKit's coalesced clock. If none fires the + // rekey did nothing. + expect(pureReflow.some((c) => c.wouldFire)).toBe(true); + } +}); diff --git a/desktop/tests/e2e/upscroll-jitter.perf.ts b/desktop/tests/e2e/upscroll-jitter.perf.ts new file mode 100644 index 0000000000..af66665710 --- /dev/null +++ b/desktop/tests/e2e/upscroll-jitter.perf.ts @@ -0,0 +1,388 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +/** + * UPSCROLL-JITTER GATE (L-E / T1.1) — the RED-today metric the fix train rides on. + * + * Tyler's report: scrolling UP a fully-loaded channel is jumpy in tiny + * variations; scrolling DOWN is smooth. Eva's H1 says why: rows above the + * opening viewport have NEVER painted, so they sit at `estimateRowHeight()` + * reserves under `content-visibility: auto`. Scrolling up, each row realizes + * at its TRUE height the instant it enters the realization band; the delta + * (true - estimate) shifts content, and (on WKWebView) NOTHING corrects it — + * one lurch per realization. Down is smooth because `contain-intrinsic-size: + * auto` has already remembered every passed row's exact size. + * + * WHAT THIS MEASURES — MOTION CONSISTENCY, not scrollTop-referenced residual. + * (This is the T1.1 reframe. The original gate subtracted the REALIZED + * scrollTop delta, which INCLUDES the fix-writer's compensating `scrollBy`, so + * the writer cancelled out of the metric and the gate scored raw estimate error + * R — invariant to whether the fix ran. Dawn found it; Quinn and Eva confirmed + * the algebra. See the diagnostic below for that residual — it is T3's estimator + * acceptance number, kept but NON-GATING.) + * + * The honest signal: during a steady upscroll a comfortably-visible reading row + * must move down the viewport by the SAME amount every notch. We actuate a + * fixed synchronous step (`scrollTop -= STEP`) so the input delta is a known + * constant every notch — this bypasses Blink's wheel-scaling entirely (a wheel + * notch can apply 218/220/222… and median-of-run would misread that per-notch + * scaling as jitter; a fixed step cannot). A perfectly-compensated scroll then + * moves the reading row by EXACTLY STEP every notch — deviation 0. A broken + * scroll lurches: rows realizing ABOVE the reading row shove it by the + * realization delta, which varies notch-to-notch, so its per-notch motion + * scatters around the run median. The metric is that scatter: + * + * rowMove_i = after.top - before.top (reading row's viewport motion; NO scrollTop reference) + * deviation_i = | rowMove_i - median(rowMove) | + * gate = peak deviation (worst lurch) and rms deviation (sustained shimmer) + * + * WHY THIS ESCAPES THE INVARIANCE that killed the old gate and the co-visible + * idea: it references the reading row's own motion ACROSS NOTCHES (temporal + * self-reference), never a neighbouring row (spatial) and never the realized + * scrollTop. The fix-writer's `scrollBy` moves the row and is NOT subtracted + * back out, so it survives into rowMove and the metric SEES it. A uniform + * global offset (which is all compensation is) cancels out of any row-vs-row + * differential — that is exactly why co-visible and the old residual were + * blind, and exactly why per-notch-motion-vs-run-median is not. + * + * ANTI-CHEAT FLOOR: a frozen or half-applying scroller has near-zero motion + * variance and would false-green. We assert mean rowMove ≈ STEP so a scroller + * that does not actually track the input cannot pass. + * + * RED-AT-TIP IS A HARD GATE, NOT CEREMONY. median-of-run is only valid if the + * corpus produces VARYING realization (dispersion); a degenerate constant-R + * corpus would green a broken writer. The tip run being RED IS the proof that + * `jitter-corpus` produces that dispersion. If a future corpus edit turns the + * tip-run green here, this gate is VOID and must fail loudly — see the assert. + * + * VALIDITY CONTROL: this metric goes to ~0 only when the reading row tracks the + * input every notch — i.e. when a correct owned-compensation fix + * (`overflow-anchor: none` + same-frame `scrollBy(realized − reserved)`) holds + * it. A synthetic per-notch-varying perfect-comp oracle drives 74/77 notches to + * EXACTLY STEP (T1.1 bake-off); the real T2 writer re-pins in the same + * ResizeObserver cycle and closes the remaining synthetic-only outliers. + * + * ENGINE FIDELITY: runs under Playwright headed Chromium, which ships + * `overflow-anchor`. The app ships in WKWebView, which — per Eva's L-C finding + * (Mari) — has NO `overflow-anchor`: it corrects NOTHING, so every + * above-viewport realization delta lands raw on the reading position. We FORCE + * `overflow-anchor: none` on the scroller before measuring so Chromium + * reproduces the shipped engine, and log `CSS.supports(...)` for the record. + * + * Run headed to watch it: + * pnpm build && npx playwright test --config=playwright.perf.config.ts \ + * upscroll-jitter --headed + */ + +// Peak per-notch motion deviation we tolerate (px). Above this is a lurch the +// eye catches. RED at tip: baseline peaks ~41px on the heterogeneous corpus. +const MAX_PEAK_DEVIATION_PX = 2.0; +// RMS motion deviation across notches (px) — the sustained micro-jitter floor. +const MAX_RMS_DEVIATION_PX = 0.6; + +// Fixed synchronous scroll step per notch (px). Constant by construction — no +// wheel-scaling variance for median-of-run to misread as jitter. +const STEP = 220; +const MAX_STEPS = 80; // cap; the walk now survives fetchOlder re-anchors and +// runs to the true top of history (~77 notches), scoring every paged-in window. +// Keep the tracked row this far (px) from both viewport edges so it stays +// realized across the step — no straddling-row un-realization artifact. +const SAFE_MARGIN = 100; + +type StepSample = { rowMove: number; appliedTop: number; residual: number }; + +type Result = { + samples: StepSample[]; + reachedTop: boolean; + rowCount: number; + // True once a `fetchOlder` prepend landed and was survived mid-run — the + // gate then keeps scoring notches in the newly paged-in population, so it + // exercises the pagination half of H1, not just in-window CV realization. + prependObserved: boolean; +}; + +// Drive one upscroll run and collect per-notch samples. `actuate` selects the +// input mode: "sync" (fixed STEP, the gate) or "wheel" (Blink-scaled, printed +// as a non-gating felt-mode diagnostic — Tyler's real input is a wheel). +async function measure( + page: import("@playwright/test").Page, + actuate: "sync" | "wheel", +): Promise { + const timeline = page.getByTestId("message-timeline"); + // Re-pin to the true bottom so everything above is unpainted (at estimate). + await timeline.evaluate((element) => { + const el = element as HTMLDivElement; + el.scrollTop = el.scrollHeight; + el.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await page.waitForTimeout(150); + await timeline.evaluate((element) => { + (element as HTMLElement).style.overflowAnchor = "none"; + }); + await page.waitForTimeout(50); + await timeline.hover(); + + const samples: StepSample[] = []; + let reachedTop = false; + let prependObserved = false; + // Mounted-row count before the walk begins — the baseline a prepend grows. + const mountedAtRunStart = await timeline.evaluate( + (el) => (el as HTMLDivElement).querySelectorAll("[data-message-id]").length, + ); + + for (let step = 0; step < MAX_STEPS; step += 1) { + // Pick a row comfortably inside the viewport (SAFE_MARGIN from both edges) + // so it stays realized across the notch. Capture its top + scrollTop BEFORE. + const before = await timeline.evaluate((element, margin: number) => { + const el = element as HTMLDivElement; + 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 { + id: row.dataset.messageId ?? null, + top: rect.top, + scrollTop: el.scrollTop, + }; + } + } + return { id: null, top: 0, scrollTop: el.scrollTop }; + }, SAFE_MARGIN); + + if (before.scrollTop <= 0) { + reachedTop = true; + break; + } + + // Actuate one notch upward. + if (actuate === "sync") { + await timeline.evaluate((element, s: number) => { + const el = element as HTMLDivElement; + el.scrollTop = Math.max(0, el.scrollTop - s); + el.dispatchEvent(new Event("scroll", { bubbles: true })); + }, STEP); + } else { + await page.mouse.wheel(0, -STEP); + } + // Settle two frames so realization + any writer compensation land before we + // read positions back. + await timeline.evaluate( + () => + new Promise((r) => + requestAnimationFrame(() => requestAnimationFrame(() => r())), + ), + ); + + if (!before.id) { + // No safe-band row this step (rare) — nudged already, try the next. + continue; + } + + const after = await timeline.evaluate( + (element, args: { id: string; margin: number }) => { + const el = element as HTMLDivElement; + const box = el.getBoundingClientRect(); + const mounted = el.querySelectorAll("[data-message-id]").length; + const row = el.querySelector( + `[data-message-id="${CSS.escape(args.id)}"]`, + ); + if (!row) + return { + top: null, + inSafeBand: false, + scrollTop: el.scrollTop, + mounted, + }; + const rect = row.getBoundingClientRect(); + return { + top: rect.top, + inSafeBand: + rect.top > box.top + args.margin && + rect.bottom < box.bottom - args.margin, + scrollTop: el.scrollTop, + mounted, + }; + }, + { id: before.id, margin: SAFE_MARGIN }, + ); + + const appliedTop = before.scrollTop - after.scrollTop; // realized px moved up + if (appliedTop <= 0) { + // scrollTop did not decrease. Two causes: (a) we reached the top of + // history (scrollTop ~ 0) — the real terminator; or (b) a `fetchOlder` + // prepend just landed ~CHANNEL_HISTORY_LIMIT older rows ABOVE the + // viewport, so scrollTop jumped UP to preserve the visible content — a + // RE-ANCHOR, not the top. Distinguish by mounted-count: a prepend grows + // it. On a re-anchor we skip scoring this step (its motion is the + // prepend jump, not a fixed STEP notch — scoring it would poison the + // metric with a one-off multi-thousand-px move) and CONTINUE; the next + // iteration re-baselines a fresh safe-band row in the newly paged-in + // window, so scoring resumes ACROSS the prepend. This is what makes the + // gate SCORE at least one prepend per run (T1.2) rather than terminate + // at the sentinel band — closing the pagination-coverage gap Quinn and + // Dawn flagged. Both the writer (GREEN) and the bare estimator (RED) + // survive the re-anchor, so RED still fails on the cold-realization + // jitter of the paged-in rows and GREEN still holds them to STEP. + if (after.mounted > mountedAtRunStart && after.scrollTop > 1) { + prependObserved = true; + continue; + } + reachedTop = after.scrollTop <= 0; + break; + } + if (after.top === null || !after.inSafeBand) { + // Tracked row left the safe band — skip scoring, keep scrolling. + continue; + } + const rowMove = after.top - before.top; // viewport motion — the gated signal + // NON-GATING diagnostic: motion minus REALIZED scrollTop delta = raw + // estimate error R (comp-invariant). This is T3's estimator acceptance + // number, printed for the record; it is NOT the gate. + const residual = rowMove - appliedTop; + samples.push({ rowMove, appliedTop, residual }); + } + + return { + samples, + reachedTop, + rowCount: await timeline.evaluate( + (el) => + (el as HTMLDivElement).querySelectorAll("[data-message-id]").length, + ), + prependObserved, + }; +} + +function stats(samples: StepSample[]) { + const moves = samples.map((s) => s.rowMove); + const sorted = moves.slice().sort((a, b) => a - b); + const median = sorted.length ? sorted[Math.floor(sorted.length / 2)] : 0; + const dev = moves.map((m) => Math.abs(m - median)); + const peakDev = dev.length ? Math.max(...dev) : 0; + const rmsDev = dev.length + ? Math.sqrt(dev.reduce((a, d) => a + d * d, 0) / dev.length) + : 0; + const meanMove = moves.length + ? moves.reduce((a, m) => a + m, 0) / moves.length + : 0; + // Old scrollTop-referenced residual (= estimate error R) — non-gating diag. + const resAbs = samples.map((s) => Math.abs(s.residual)); + const resPeak = resAbs.length ? Math.max(...resAbs) : 0; + const resRms = resAbs.length + ? Math.sqrt(resAbs.reduce((a, r) => a + r * r, 0) / resAbs.length) + : 0; + return { median, peakDev, rmsDev, meanMove, resPeak, resRms }; +} + +test("GATE: upscroll motion consistency stays below the realization-jitter threshold", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + + // The `jitter-corpus` channel is pre-seeded (e2eBridge.ts) with 400 + // heterogeneous rows in its mock store — the same reliable cold-load path + // scroll-history.spec.ts uses, no dependence on live-emit timing. + await page.getByTestId("channel-jitter-corpus").click(); + await expect(page.getByTestId("chat-title")).toHaveText("jitter-corpus"); + const timeline = page.getByTestId("message-timeline"); + await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); + await page.waitForFunction(() => { + const el = document.querySelector( + '[data-testid="message-timeline"]', + ) as HTMLDivElement | null; + return !!el && el.scrollHeight > el.clientHeight + 1000; + }); + + // Log native-anchoring support for this engine (measure() forces it off). + const anchorSupport = await page.evaluate(() => + typeof CSS !== "undefined" && typeof CSS.supports === "function" + ? CSS.supports("overflow-anchor", "auto") + : false, + ); + + // GATED run: fixed synchronous step (deterministic input, no Blink scaling). + const result = await measure(page, "sync"); + const s = stats(result.samples); + + // NON-GATING felt-mode diagnostic: same metric under a real wheel. Tyler's + // input is a wheel; we want the felt number on the record every run even + // though Blink's per-notch scaling makes it unsafe to gate on. + const wheelResult = await measure(page, "wheel"); + const w = stats(wheelResult.samples); + + /* eslint-disable no-console */ + console.log( + `overflow-anchor supported by this engine: ${anchorSupport} ` + + `(forced to 'none' on the scroller to mirror shipped WKWebView)`, + ); + console.log("\n=== UPSCROLL JITTER GATE (motion consistency, Chromium) ==="); + console.log(`rows mounted (live DOM): ${result.rowCount}`); + console.log(`steps measured (sync): ${result.samples.length}`); + console.log(`reached top of history: ${result.reachedTop}`); + console.log( + `scored a fetchOlder prepend: ${result.prependObserved} (T1.2: pagination half exercised)`, + ); + console.log( + `median per-notch motion: ${s.median.toFixed(1)}px (STEP=${STEP})`, + ); + console.log( + `peak motion deviation: ${s.peakDev.toFixed(2)}px (gate <= ${MAX_PEAK_DEVIATION_PX})`, + ); + console.log( + `rms motion deviation: ${s.rmsDev.toFixed(2)}px (gate <= ${MAX_RMS_DEVIATION_PX})`, + ); + console.log( + `mean per-notch motion: ${s.meanMove.toFixed(1)}px (anti-cheat: ~= ${STEP})`, + ); + console.log("--- non-gating diagnostics ---"); + console.log( + `estimate-error R (old resid):peak=${s.resPeak.toFixed(2)}px rms=${s.resRms.toFixed(2)}px (T3 estimator acceptance number)`, + ); + console.log( + `wheel-actuated (felt mode): peak-dev=${w.peakDev.toFixed(2)}px rms-dev=${w.rmsDev.toFixed(2)}px over ${wheelResult.samples.length} steps`, + ); + console.log("(0 == the reading row tracked the input exactly every notch)"); + console.log("===========================================================\n"); + /* eslint-enable no-console */ + + // Sanity: the run actually exercised a meaningful upscroll. Cold-load windows + // the 400-row seed to the newest ~100 rows (CHANNEL_HISTORY_LIMIT), all in the + // DOM (de-virtualized), so ~100 mounted rows is the expected window. + expect(result.rowCount).toBeGreaterThanOrEqual(80); + expect(result.samples.length).toBeGreaterThan(8); + + // COVERAGE (T1.2): the scored run must cross at least one `fetchOlder` + // prepend. Upscroll jitter has TWO sources (Eva's H1 + Quinn's seed trace): + // CV-skipped rows in the opening window that realize on entry, AND cold rows + // paged in above the viewport when scrollback fires near the top. The gate + // measures felt motion whatever the cause, but if the run terminated at the + // sentinel band it would only ever certify the first source. We assert it + // survived a re-anchor and kept scoring so BOTH sources are under the gate — + // pass or fail. This is corpus-structural (400-row seed > 300 limit), so it + // holds on the RED baseline and the GREEN fix alike; a run that stops before + // scoring a prepend is a coverage regression, not a jitter verdict. + expect(result.prependObserved).toBe(true); + + // ANTI-CHEAT: the reading row must actually track the input — a frozen or + // half-applying scroller (near-zero motion, would false-green on deviation) + // is caught here because its mean motion falls well below STEP. + expect(s.meanMove).toBeGreaterThan(STEP * 0.75); + + // THE GATE — per-notch motion consistency. RED at tip (~41px peak / ~23px rms + // on jitter-corpus); a correct owned-compensation fix (T2 writer) drives the + // reading row to STEP every notch and turns it green. + // + // ⚠️ RED-AT-TIP IS LOAD-BEARING: median-of-run is only valid while the corpus + // produces VARYING realization. This gate failing at tip IS the proof of that + // dispersion. If a future corpus edit makes the tip run PASS here WITHOUT a + // compensation fix, this gate is VOID — do not relax the thresholds; restore a + // heterogeneous corpus so the gate reds again, or the whole metric is moot. + expect(s.peakDev).toBeLessThanOrEqual(MAX_PEAK_DEVIATION_PX); + expect(s.rmsDev).toBeLessThanOrEqual(MAX_RMS_DEVIATION_PX); +}); diff --git a/desktop/tests/e2e/upscroll-media.perf.ts b/desktop/tests/e2e/upscroll-media.perf.ts new file mode 100644 index 0000000000..fcc4ecfeba --- /dev/null +++ b/desktop/tests/e2e/upscroll-media.perf.ts @@ -0,0 +1,518 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +/** + * Media-corpus reversal characterization — classify MEDIA reflow snaps. + * + * WHY THIS EXISTS. The jitter corpus mis-estimates TEXT row heights; this one + * mis-reserves MEDIA. The question this fixture answers (Eva's ruling, thread + * event 0a496379): are the −240px media reflow snaps Tyler may feel on scroll-up + * momentum-class (already killed by the w4a-gate-1 rekey → SKIP), walk-blind / + * UNATTRIBUTED (a separate RO-path leg), or SHRINK-class (needs Max's pre- + * realization band)? It reuses the fast classifier's machinery VERBATIM — the + * `probeLen` append-count join, the SKIP/GROW/SHRINK/UNATTRIBUTED discriminator, + * `dev`, and the ±2-frame `firedNear` admission flag — and only swaps the corpus, + * narrows the viewport so the width-clamp SHRINK source is active, and relaxes + * the SKIP-only admission assertion (media survivors are not all SKIP). + * + * THE CORPUS (channel `media-corpus`, e2eBridge `mediaCorpusBody`). Row height + * error is MEDIA reserve-vs-true mismatch, not text mis-estimation, from three + * in-source-verified divergence sources (Eva's premise verification, mainbase + * 2cc0eb53): + * A. Link-preview cards — reserved flat at PREVIEW_CARD=70, rendered taller → GROW. + * B. Width-clamp images — CORRECT dims, but the estimator hardcodes + * MEDIA_MAX_WIDTH=384 while render clamps to `max-w-[min(24rem,100%)]`; in a + * column NARROWER than 384px the render is width-limited shorter → SHRINK. + * This fixture MEASURES the container width and asserts it is < 384 so the + * clamp provably bites (else the SHRINK source is silently inert). + * C. Dim-mismatch images, BOTH directions — overstate height → SHRINK, understate + * → GROW; seeded both ways so the corpus CAN produce SHRINK, making the + * GROW-dominant prediction falsifiable. + * No dim-less band: that path is pinned to a fixed 256px box (reserve==mount) and + * provably cannot reflow. + * + * PREDICTIONS ON RECORD (classifier decides): Dawn GROW-likely, Eva SHRINK-likely. + * + * HONESTY BOUND: this is a deterministic proxy for media reflow — a committed, + * re-runnable classification of reserve-vs-true mismatch — NOT a claim to + * reproduce Tyler's exact frames. His live trackpad still owns acceptance. The + * WebKit `dScroll=0.0` coalesced still frame is the real-device phenomenon; the + * synthetic `mouse.wheel` drive surfaces the bounded survivors to rule on. + */ + +// Fast constant drive — identical to the W4a gate (`upscroll-raf-correction`), +// so this fixture surfaces the same bounded survivors the gate leaves. +const WHEEL_DELTA = 12; // px/event — matches the gate's constant velocity +const WHEEL_PERIOD_MS = 32; // gate cadence (~375px/s) +const DURATION_MS = 12_000; +const SAFE_MARGIN = 100; +// Same reversal definition as the gate: row moving against the scroll by more +// than staircase noise. Upscroll → rowMove normally >= 0, so a genuine +// against-direction move is < -REVERSAL_PX. +const REVERSAL_PX = 3; +// Must equal `ANCHOR_BUILD_STAMP` in `useAnchoredScroll.ts` — stale-`dist` +// guard (see the gate fixture). Bump BOTH together per experiment. +const EXPECTED_BUILD_STAMP = "w4a-gate-1"; +// Narrow window so the timeline column lands below MEDIA_MAX_WIDTH=384 and the +// width-clamp SHRINK source (divergence B) is active. Measured in-test below. +const NARROW_VIEWPORT = { width: 450, height: 720 }; +const MEDIA_MAX_WIDTH = 384; // must match rowHeightEstimate.ts + +type Frame = { + t: number; + top: number | null; + scrollTop: number; + mounted: number; + rowId: string | null; + probeLen: number; +}; + +test("W4a media-classify: MEDIA reflow class mix (SKIP/GROW/SHRINK)", 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)); + 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", + ); + // Navigate at the default (wide) viewport so the sidebar renders and the + // channel is clickable, THEN narrow — a narrow window collapses the sidebar. + await page.getByTestId("channel-media-corpus").click(); + await page.setViewportSize(NARROW_VIEWPORT); + 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; + }); + + // The width-clamp SHRINK source is only active if the rendered image + // container is narrower than the estimator's hardcoded MEDIA_MAX_WIDTH=384. + // Measure a mounted image block and assert it: if the column is wide the + // clamp is inert and any SHRINK-absence below would prove nothing. + const mediaBoxWidth = await page.evaluate(() => { + const box = document.querySelector( + '[data-testid="message-timeline"] [data-block-media]', + ); + return box ? Math.round(box.getBoundingClientRect().width) : null; + }); + expect( + mediaBoxWidth, + "no media block mounted in the timeline — corpus did not realize images", + ).not.toBeNull(); + expect( + mediaBoxWidth as number, + `media container width ${mediaBoxWidth}px is not below MEDIA_MAX_WIDTH=${MEDIA_MAX_WIDTH} — width-clamp SHRINK source is inert; narrow the viewport further`, + ).toBeLessThan(MEDIA_MAX_WIDTH); + + 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 (identical row-tracking to the gate, plus a join key into + // the hook's correction probe). The fixture sampler and the hook's rAF sampler + // are SEPARATE rAF loops, so "the correction for frame i" is not reliably the + // same-tick probe entry (two rAF callbacks in one frame fire in registration + // order, which we don't control). The order-robust join is by APPEND COUNT: + // each frame records `probeLen` (the correction-probe array length at that + // tick) and the signed shift + fire flag of any attempts that appended since + // the previous frame. A reversal between frame i-1 and i is then attributed to + // the attempts in that interval — no same-tick ordering assumption. + await timeline.evaluate((element, margin: number) => { + const el = element as HTMLDivElement; + const w = window as unknown as { + __PROBE__: { frames: Frame[]; stop: boolean }; + }; + const g = globalThis as unknown as { + __ANCHOR_PROBE__?: Array<{ + wouldFire: boolean; + residual: number; + signedShift: number; + }>; + }; + type Frame = { + t: number; + top: number | null; + scrollTop: number; + mounted: number; + rowId: string | null; + // Correction-probe array length at this tick — the append-count join key. + probeLen: number; + }; + 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(); + w.__PROBE__.frames.push({ + t, + top, + scrollTop: el.scrollTop, + mounted, + rowId: trackedId, + probeLen: g.__ANCHOR_PROBE__?.length ?? 0, + }); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }, SAFE_MARGIN); + + // Fast constant drive — matches the W4a gate exactly, so the same bounded + // survivors surface. No decay: this is the fast regime, not Tyler's slow one. + 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; + probeLen: number; + }; + w.__PROBE__.stop = true; + return w.__PROBE__.frames; + }); + + const { corrections, buildStamp } = await page.evaluate(() => { + const g = globalThis as unknown as { + __ANCHOR_PROBE__?: Array<{ + source: "raf" | "ro"; + wouldFire: boolean; + residual: number; + signedShift: number; + renderedScroll?: number; + }>; + __ANCHOR_BUILD_STAMP__?: string; + }; + return { + corrections: g.__ANCHOR_PROBE__ ?? [], + buildStamp: g.__ANCHOR_BUILD_STAMP__ ?? null, + }; + }); + + // Media-reflow census. The felt question is not "did any correction fire" + // (text rows fire too) but "did an above-anchor MEDIA reflow, on a row the eye + // is tracking, render a backward snap under the gate fix?" So for every frame + // interval carrying a real reflow attempt (|signedShift| > REFLOW_PX) we record + // whether the tracked row was in-band across it and what it actually did + // (rowMove). An in-band reflow with rowMove ≈ 0 or forward is an ABSORBED media + // reflow — the corpus produced the divergence and the gate+correction ate it. + // Consecutive frames carrying the SAME (row, sign, fired) reflow are collapsed + // to one census entry: a large above-anchor gap the momentum gate holds is + // re-measured every rAF, and counting each frame would inflate one persistent + // reflow into hundreds. We keep the WORST (most-backward) rowMove of the run so + // a hidden snap inside a persistent reflow still surfaces. + const REFLOW_PX = 3; + type ReflowFrame = { + i: number; + shift: number; + fired: boolean; + inBand: boolean; + worstRowMove: number | null; + frames: number; + rowId: string | null; + }; + const mediaReflows: ReflowFrame[] = []; + for (let i = 1; i < frames.length; i += 1) { + const a = frames[i - 1]; + const b = frames[i]; + const attempts = corrections.slice(a.probeLen, b.probeLen); + let biggest: (typeof attempts)[number] | null = null; + for (const c of attempts) { + if (Math.abs(c.signedShift) <= REFLOW_PX) continue; + if ( + biggest === null || + Math.abs(c.signedShift) > Math.abs(biggest.signedShift) + ) { + biggest = c; + } + } + if (biggest === null) continue; + const inBand = + a.top !== null && + b.top !== null && + a.rowId !== null && + a.rowId === b.rowId; + const rowMove = inBand ? (b.top as number) - (a.top as number) : null; + const sign = Math.sign(biggest.signedShift); + const prev = mediaReflows[mediaReflows.length - 1]; + // Collapse a run: same tracked row, same reflow sign, same fire decision. + if ( + prev && + prev.rowId === b.rowId && + Math.sign(prev.shift) === sign && + prev.fired === biggest.wouldFire && + prev.inBand === inBand + ) { + prev.frames += 1; + if (Math.abs(biggest.signedShift) > Math.abs(prev.shift)) { + prev.shift = biggest.signedShift; + } + if ( + rowMove !== null && + (prev.worstRowMove === null || rowMove < prev.worstRowMove) + ) { + prev.worstRowMove = rowMove; + } + continue; + } + mediaReflows.push({ + i, + shift: biggest.signedShift, + fired: biggest.wouldFire, + inBand, + worstRowMove: rowMove, + frames: 1, + rowId: b.rowId, + }); + } + const inBandReflows = mediaReflows.filter((r) => r.inBand); + + // Score same-row frame pairs. A reversal is rowMove <= -REVERSAL_PX. For each + // reversal, join to the correction attempt(s) that appended to the hook probe + // BETWEEN frame i-1 and i (append-count window: probe indices [a.probeLen, + // b.probeLen)). Classify by the SIGN of aboveShift + whether the write fired — + // the three-way discriminator Sami specced (thread event 5b46582e): + // • wouldFire == false → SKIP: momentum gate (:29) / cross- + // check (:451) suppressed the write. The reversal is UNCORRECTED reflow; + // absorption never got to act. A 27→27 here = wiring/gate, not physics. + // • fired, signedShift > 0 (GROW) → content above grew, anchor shoved + // DOWN, the correction WRITE is the felt backward snap. Absorption's + // amortizable topology — deferring the pullback into forward frames helps. + // • fired, signedShift < 0 (SHRINK) → content above shrank, the reflow + // ITSELF pulls the anchor up and renders the reversal before any write. + // Structurally uncorrectable by us; only smaller per-frame realization + // (Max's pre-realization band / contain-intrinsic-size) shrinks it. + // A reversal with no attempt in its window is UNATTRIBUTED (the correcting + // observer's attempt landed in an adjacent frame under rAF interleave) — we + // count it separately rather than force it into a class. + let scored = 0; + let reanchors = 0; + type Klass = "skip" | "grow" | "shrink" | "unattributed"; + const reversals: Array<{ + i: number; + rowMove: number; + dScroll: number; + dev: number; // Leg 5: rowMove − scrollDelta (rendered deviation from tracking) + signedShift: number | null; + fired: boolean; + klass: Klass; + // Widen-independent admission flag: any wouldFire=true record in a WIDER + // ±2-frame append-count window than the ±1 attribution window. If false, + // no fired write sits near this reversal at any reasonable width → SKIP is + // robust to the widen. If true, the class attribution's largest-|shift| rule + // could have labelled it grow/shrink and the SKIP bin is in question. + firedNear: boolean; + 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; + const dScroll = b.scrollTop - a.scrollTop; + if (rowMove > -REVERSAL_PX) continue; + // Attribution window. The hook's correction attempt for the reflow that + // produced this reversal can append across a ±1-frame span relative to our + // sampler: the two rAF loops interleave in an order we don't control, and on + // WebKit a late RO appends a frame after the reflow paints. So the window is + // [prev-frame probeLen, NEXT-frame probeLen) — attempts from the frame + // before through the frame after. A reversal with NO attempt anywhere in + // that span is genuinely unattributed (the corrector did not run a mid- + // history attempt on those frames at all — e.g. re-pick guard or null cur), + // which is itself a distinct diagnosis from a fired-then-clamped write. + const next = frames[i + 1] ?? b; + const window = corrections.slice(a.probeLen, next.probeLen); + let attempt: (typeof corrections)[number] | null = null; + for (const c of window) { + if ( + attempt === null || + Math.abs(c.signedShift) > Math.abs(attempt.signedShift) + ) { + attempt = c; + } + } + let klass: Klass; + if (attempt === null) { + klass = "unattributed"; + } else if (!attempt.wouldFire) { + klass = "skip"; + } else { + klass = attempt.signedShift >= 0 ? "grow" : "shrink"; + } + // Leg 5 rendered deviation from pure scroll-tracking. A correctly-anchored + // row moves only with scroll (rowMove == scrollDelta), so any deviation is + // the corrector's footprint — or, on a SKIP, its ABSENCE. + const dev = rowMove - dScroll; + // Widen-independent admission check. Look one frame WIDER than the ±1 + // attribution window ([i-2 .. i+2] via probeLen) and ask only: is there ANY + // fired write in that neighborhood? This does not pick a single attempt or + // depend on the largest-|shift| tie-break, so it cannot be flipped by the + // widen. A SKIP survivor must have firedNear=false: no write could be the + // backward mover if none fired near the frame at all. + const lo = frames[i - 2] ?? a; + const hi = frames[i + 2] ?? next; + const neighborhood = corrections.slice(lo.probeLen, hi.probeLen); + const firedNear = neighborhood.some((c) => c.wouldFire); + reversals.push({ + i, + rowMove, + dScroll, + dev, + signedShift: attempt?.signedShift ?? null, + fired: attempt?.wouldFire ?? false, + klass, + firedNear, + rowId: b.rowId, + }); + } + + const maxReversalPx = + reversals.length === 0 + ? 0 + : Math.max(...reversals.map((r) => Math.abs(r.rowMove))); + // A reversal on a near-still frame (|dScroll| < REVERSAL_PX) is the felt case: + // the eye is barely moving, so a backward row snap is maximally visible. + const stillFrameReversals = reversals.filter( + (r) => Math.abs(r.dScroll) < REVERSAL_PX, + ); + const byClass = (k: Klass) => reversals.filter((r) => r.klass === k).length; + + /* eslint-disable no-console */ + console.log("\n=== W4a MEDIA-CORPUS REFLOW CLASS MIX ==="); + console.log(`engine: ${browserName}`); + console.log(`build stamp: ${buildStamp ?? "(absent)"}`); + 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(` of which still-frame: ${stillFrameReversals.length}`); + console.log(`max reversal px: ${maxReversalPx.toFixed(1)}`); + console.log("--- reversal class mix (Sami's discriminator) ---"); + console.log(` SKIP (gate/xcheck, uncorrected reflow): ${byClass("skip")}`); + console.log( + ` GROW (fired, write is the snap — absorb): ${byClass("grow")}`, + ); + console.log( + ` SHRINK (fired, reflow renders it — Max): ${byClass("shrink")}`, + ); + console.log( + ` UNATTRIBUTED (no attempt in window): ${byClass("unattributed")}`, + ); + for (const r of reversals + .slice() + .sort((x, y) => x.rowMove - y.rowMove) + .slice(0, 12)) { + const s = r.signedShift === null ? "n/a" : r.signedShift.toFixed(1); + console.log( + ` frame ${r.i} rowMove=${r.rowMove.toFixed(1)} dScroll=${r.dScroll.toFixed(1)} dev=${r.dev.toFixed(1)} signedShift=${s} fired=${r.fired} firedNear=${r.firedNear} class=${r.klass} row=${r.rowId}`, + ); + } + console.log("========================================\n"); + + console.log("=== MEDIA-REFLOW CENSUS (|shift|>3px above-anchor reflows) ==="); + console.log(`distinct reflow runs: ${mediaReflows.length}`); + console.log(` in-band (tracked row): ${inBandReflows.length}`); + console.log( + ` GROW / SHRINK runs: ${mediaReflows.filter((r) => r.shift > 0).length} / ${mediaReflows.filter((r) => r.shift < 0).length}`, + ); + for (const r of mediaReflows) { + console.log( + ` frame ${r.i} shift=${r.shift.toFixed(1)} fired=${r.fired} inBand=${r.inBand} worstRowMove=${r.worstRowMove === null ? "n/a" : r.worstRowMove.toFixed(1)} frames=${r.frames} row=${r.rowId}`, + ); + } + console.log( + "==============================================================\n", + ); + /* eslint-enable no-console */ + + // Sanity: the actuation actually produced a scored upscroll. + expect(scored).toBeGreaterThan(50); + // Stale-`dist` guard — a characterization on a stale bundle misleads exactly + // like a stale gate run. Assert the experiment's stamp ran. + expect(buildStamp).toBe(EXPECTED_BUILD_STAMP); + // MEDIA LIVENESS. Not "did any correction fire" (text rows fire too) but "did + // the MEDIA corpus actually produce above-anchor reflows on tracked rows?" If + // it did not, the zero-reversal result below is vacuous (a dead corpus proves + // nothing). This asserts the corpus is a LIVE reflow source — the divergence + // bands realize — so the absence of felt reversals is a real absorption + // result, not a silent no-op. + expect( + inBandReflows.length, + "no |shift|>3px media reflow landed on a tracked in-band row — corpus did not realize media divergence, the reversal census is vacuous", + ).toBeGreaterThan(0); + + // --- SKIP ADMISSION GATE (Eva, thread event 2a4e31fa) ----------------------- + // Every reversal typed SKIP must have NO fired write in its ±2-frame + // neighborhood. This is attribution-free: it does not depend on the ±1 widen + // or the largest-|shift| tie-break, so a SKIP that survives it is robust to + // the soft joint in the classifier. If any SKIP shows firedNear=true, a write + // did land near the frame and the class attribution mis-labelled it — the bin + // is not admissible and this fails loudly rather than passing a stale claim. + // (Characterization otherwise; the reversal count itself is not gated.) + const skips = reversals.filter((r) => r.klass === "skip"); + for (const r of skips) { + expect( + r.firedNear, + `SKIP survivor frame ${r.i} (row ${r.rowId}) has a fired write in its ±2-frame neighborhood — attribution is not widen-robust, bin in question`, + ).toBe(false); + } +}); 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 0000000000..54c74c77c9 --- /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-gate-1"; + +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); + } +}); diff --git a/desktop/tests/e2e/upscroll-slow-scroll.perf.ts b/desktop/tests/e2e/upscroll-slow-scroll.perf.ts new file mode 100644 index 0000000000..9ce6c3af45 --- /dev/null +++ b/desktop/tests/e2e/upscroll-slow-scroll.perf.ts @@ -0,0 +1,350 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +/** + * Slow-scroll characterization leg — Tyler's felt-regime coverage. + * + * The W4a gate (`upscroll-raf-correction.perf.ts`) drives a CONSTANT 12px/32ms + * (~375px/s) upscroll. That is not the regime Tyler reports the residual in: a + * slow deliberate trackpad gesture moves 2-4px/frame and decays through a + * momentum tail, and a mid-history correction that is invisible under momentum + * is 5-7 frames of motion at 2-3px/frame (Eva's characterization, thread + * event 2de99f4d). This leg drives that regime and CHARACTERIZES the reversal + * distribution — it does NOT gate a pass/fail ceiling, because the slow-regime + * floor is exactly what we're measuring. Liveness + stale-`dist` guard only. + * + * HONESTY BOUND (flagged in-thread before build): Playwright `mouse.wheel` is a + * synthetic discrete event. It faithfully reproduces "the correction WRITE as a + * felt jump" (correction magnitude vs per-frame rendered row motion at low + * velocity — measured here) but it does NOT reproduce a real trackpad's + * compositor-coalesced momentum, so the WebKit `dScroll=0.0` coalesced still + * frame is a real-device phenomenon this fixture cannot manufacture. A green + * run here means "the correction is not a large felt jump in a low-velocity + * synthetic gesture" — NOT "reproduces everything Tyler feels." + */ + +// Momentum-decay drive: initial slow velocity decaying toward a tail. Each +// wheel event is small (2-4px early, ~1px in the tail) so a correction landing +// mid-gesture is many frames of the eye's motion, not one. +const WHEEL_START_DELTA = 4; // px/event at gesture start (slow deliberate) +const WHEEL_TAIL_DELTA = 1; // px/event in the momentum tail +const DECAY_PER_EVENT = 0.98; // geometric decay toward the tail +const WHEEL_PERIOD_MS = 16; // one event per frame (60fps trackpad cadence) +const DURATION_MS = 12_000; +const SAFE_MARGIN = 100; +// Same reversal definition as the gate: row moving against the scroll by more +// than staircase noise. Upscroll → rowMove normally >= 0, so a genuine +// against-direction move is < -REVERSAL_PX. +const REVERSAL_PX = 3; +// Must equal `ANCHOR_BUILD_STAMP` in `useAnchoredScroll.ts` — stale-`dist` +// guard (see the gate fixture). Bump BOTH together per experiment. +const EXPECTED_BUILD_STAMP = "w4a-gate-1"; + +type Frame = { + t: number; + top: number | null; + scrollTop: number; + mounted: number; + rowId: string | null; + probeLen: number; +}; + +test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", 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)); + 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; + }); + + 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 (identical row-tracking to the gate, plus a join key into + // the hook's correction probe). The fixture sampler and the hook's rAF sampler + // are SEPARATE rAF loops, so "the correction for frame i" is not reliably the + // same-tick probe entry (two rAF callbacks in one frame fire in registration + // order, which we don't control). The order-robust join is by APPEND COUNT: + // each frame records `probeLen` (the correction-probe array length at that + // tick) and the signed shift + fire flag of any attempts that appended since + // the previous frame. A reversal between frame i-1 and i is then attributed to + // the attempts in that interval — no same-tick ordering assumption. + await timeline.evaluate((element, margin: number) => { + const el = element as HTMLDivElement; + const w = window as unknown as { + __PROBE__: { frames: Frame[]; stop: boolean }; + }; + const g = globalThis as unknown as { + __ANCHOR_PROBE__?: Array<{ + wouldFire: boolean; + residual: number; + signedShift: number; + }>; + }; + type Frame = { + t: number; + top: number | null; + scrollTop: number; + mounted: number; + rowId: string | null; + // Correction-probe array length at this tick — the append-count join key. + probeLen: number; + }; + 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(); + w.__PROBE__.frames.push({ + t, + top, + scrollTop: el.scrollTop, + mounted, + rowId: trackedId, + probeLen: g.__ANCHOR_PROBE__?.length ?? 0, + }); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }, SAFE_MARGIN); + + // Momentum-decay drive: velocity decays geometrically from START toward TAIL, + // so the gesture spends most of its time in the 1-2px/frame regime where a + // correction is felt. Fractional deltas accumulate a remainder so sub-pixel + // velocity still produces integer wheel steps at the right average rate. + const started = Date.now(); + let delta = WHEEL_START_DELTA; + let remainder = 0; + while (Date.now() - started < DURATION_MS) { + remainder += delta; + const step = Math.max(1, Math.round(remainder)); + remainder -= step; + await page.mouse.wheel(0, -step); + await page.waitForTimeout(WHEEL_PERIOD_MS); + delta = Math.max(WHEEL_TAIL_DELTA, delta * DECAY_PER_EVENT); + } + + 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; + probeLen: number; + }; + w.__PROBE__.stop = true; + return w.__PROBE__.frames; + }); + + const { corrections, buildStamp } = await page.evaluate(() => { + const g = globalThis as unknown as { + __ANCHOR_PROBE__?: Array<{ + source: "raf" | "ro"; + wouldFire: boolean; + residual: number; + signedShift: number; + }>; + __ANCHOR_BUILD_STAMP__?: string; + }; + return { + corrections: g.__ANCHOR_PROBE__ ?? [], + buildStamp: g.__ANCHOR_BUILD_STAMP__ ?? null, + }; + }); + + // Score same-row frame pairs. A reversal is rowMove <= -REVERSAL_PX. For each + // reversal, join to the correction attempt(s) that appended to the hook probe + // BETWEEN frame i-1 and i (append-count window: probe indices [a.probeLen, + // b.probeLen)). Classify by the SIGN of aboveShift + whether the write fired — + // the three-way discriminator Sami specced (thread event 5b46582e): + // • wouldFire == false → SKIP: momentum gate (:29) / cross- + // check (:451) suppressed the write. The reversal is UNCORRECTED reflow; + // absorption never got to act. A 27→27 here = wiring/gate, not physics. + // • fired, signedShift > 0 (GROW) → content above grew, anchor shoved + // DOWN, the correction WRITE is the felt backward snap. Absorption's + // amortizable topology — deferring the pullback into forward frames helps. + // • fired, signedShift < 0 (SHRINK) → content above shrank, the reflow + // ITSELF pulls the anchor up and renders the reversal before any write. + // Structurally uncorrectable by us; only smaller per-frame realization + // (Max's pre-realization band / contain-intrinsic-size) shrinks it. + // A reversal with no attempt in its window is UNATTRIBUTED (the correcting + // observer's attempt landed in an adjacent frame under rAF interleave) — we + // count it separately rather than force it into a class. + let scored = 0; + let reanchors = 0; + type Klass = "skip" | "grow" | "shrink" | "unattributed"; + const reversals: Array<{ + i: number; + rowMove: number; + dScroll: number; + signedShift: number | null; + fired: boolean; + klass: Klass; + 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; + const dScroll = b.scrollTop - a.scrollTop; + if (rowMove > -REVERSAL_PX) continue; + // Attribution window. The hook's correction attempt for the reflow that + // produced this reversal can append across a ±1-frame span relative to our + // sampler: the two rAF loops interleave in an order we don't control, and on + // WebKit a late RO appends a frame after the reflow paints. So the window is + // [prev-frame probeLen, NEXT-frame probeLen) — attempts from the frame + // before through the frame after. A reversal with NO attempt anywhere in + // that span is genuinely unattributed (the corrector did not run a mid- + // history attempt on those frames at all — e.g. re-pick guard or null cur), + // which is itself a distinct diagnosis from a fired-then-clamped write. + const next = frames[i + 1] ?? b; + const window = corrections.slice(a.probeLen, next.probeLen); + let attempt: (typeof corrections)[number] | null = null; + for (const c of window) { + if ( + attempt === null || + Math.abs(c.signedShift) > Math.abs(attempt.signedShift) + ) { + attempt = c; + } + } + let klass: Klass; + if (attempt === null) { + klass = "unattributed"; + } else if (!attempt.wouldFire) { + klass = "skip"; + } else { + klass = attempt.signedShift >= 0 ? "grow" : "shrink"; + } + reversals.push({ + i, + rowMove, + dScroll, + signedShift: attempt?.signedShift ?? null, + fired: attempt?.wouldFire ?? false, + klass, + rowId: b.rowId, + }); + } + + const maxReversalPx = + reversals.length === 0 + ? 0 + : Math.max(...reversals.map((r) => Math.abs(r.rowMove))); + // A reversal on a near-still frame (|dScroll| < REVERSAL_PX) is the felt case: + // the eye is barely moving, so a backward row snap is maximally visible. + const stillFrameReversals = reversals.filter( + (r) => Math.abs(r.dScroll) < REVERSAL_PX, + ); + const byClass = (k: Klass) => reversals.filter((r) => r.klass === k).length; + + /* eslint-disable no-console */ + console.log("\n=== W4a SLOW-SCROLL CHARACTERIZATION ==="); + console.log(`engine: ${browserName}`); + console.log(`build stamp: ${buildStamp ?? "(absent)"}`); + 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(` of which still-frame: ${stillFrameReversals.length}`); + console.log(`max reversal px: ${maxReversalPx.toFixed(1)}`); + console.log("--- reversal class mix (Sami's discriminator) ---"); + console.log(` SKIP (gate/xcheck, uncorrected reflow): ${byClass("skip")}`); + console.log( + ` GROW (fired, write is the snap — absorb): ${byClass("grow")}`, + ); + console.log( + ` SHRINK (fired, reflow renders it — Max): ${byClass("shrink")}`, + ); + console.log( + ` UNATTRIBUTED (no attempt in window): ${byClass("unattributed")}`, + ); + for (const r of reversals + .slice() + .sort((x, y) => x.rowMove - y.rowMove) + .slice(0, 12)) { + const s = r.signedShift === null ? "n/a" : r.signedShift.toFixed(1); + console.log( + ` frame ${r.i} rowMove=${r.rowMove.toFixed(1)} dScroll=${r.dScroll.toFixed(1)} signedShift=${s} fired=${r.fired} class=${r.klass} row=${r.rowId}`, + ); + } + console.log("========================================\n"); + /* eslint-enable no-console */ + + // Sanity: the actuation actually produced a scored slow upscroll. + expect(scored).toBeGreaterThan(50); + // Stale-`dist` guard — a slow-regime characterization on a stale bundle would + // mislead exactly like a stale gate run. Assert the experiment's stamp ran. + expect(buildStamp).toBe(EXPECTED_BUILD_STAMP); + // Liveness: at least one mid-history correction fired, else the corpus + // realized nothing and the distribution above is vacuous. + const anyFired = corrections.some((c) => c.wouldFire); + expect(anyFired).toBe(true); + // Characterization only — no reversal ceiling asserted. The distribution and + // still-frame count above are the deliverable; the slow-regime floor is what + // we are measuring, not gating. +}); 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 */ +}); diff --git a/desktop/tests/e2e/upscroll-trackpad-gate.perf.ts b/desktop/tests/e2e/upscroll-trackpad-gate.perf.ts new file mode 100644 index 0000000000..860e5dcff4 --- /dev/null +++ b/desktop/tests/e2e/upscroll-trackpad-gate.perf.ts @@ -0,0 +1,508 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +/** + * TRACKPAD-MOMENTUM UPSCROLL GATE (W3/W4) — the merge-blocking, offline sibling + * of T1.2's sync gate. T1.2 guards the writer's MATH (fixed synchronous step, + * per-notch median-of-run); this gate guards the INPUT PATH Tyler actually + * feels (a real wheel under momentum, whose per-notch Blink scaling of + * 218/220/222 poisons median-of-run). The two JOIN — sync + felt — neither + * replaces the other. + * + * WHY FELT, NOT SYNC-ONLY (Quinn's sharpening, Eva-ratified). The sync path + * UNDER-REPORTS the WebKit defect ~4-5x (sync reds ~41px, felt reds ~200px): + * the wheel path exercises realization-under-momentum that the settled sync + * path never does. Sync catching *a* red is not sync catching the *whole* red — + * a sync-only gate can false-green a "fix" that still lurches 200px on the + * trackpad. Gate the path the user's input takes. + * + * THE METRIC — two legs, both on the reading row's per-frame `rect.top`, NEITHER + * referencing scrollTop nor the dispatched wheel delta. Full derivation + + * invariance proofs: RESEARCH/FELT_WHEEL_GATE_METRIC_W4.md (Quinn, W4). + * + * p_i = tracked row's viewport top, sampled PER FRAME i + * rowMove_i = p_i − p_{i-1} (first diff) + * + * LEG 1 — peak lurch = peak |second difference| (jerk): + * lurch_i = |p_i − 2·p_{i-1} + p_{i-2}| = |rowMove_i − rowMove_{i-1}| + * gate = max_i lurch_i ≤ MAX_PEAK_JERK_PX + * + * WHY JERK SURVIVES WHEEL-SCALING WITHOUT A MEDIAN OR INPUT CLOCK. Blink's + * scaling makes rowMove a SMOOTH ramp (218,220,222…); the second difference of + * any locally-linear sequence is ≈ 0 ((222−220)−(220−218)=0), so scaling + * flattens itself with NO reference. It is the coordinate-free version of Eva's + * "subtract the per-notch expected value" — differencing twice removes any + * smooth trend without naming it, so no dispatched-delta reference (Quinn's + * "Trap A" / timing-skew). Comp-invariant: a working writer produces smooth row + * motion (holding at ~0 OR tracking smoothly, both second-diff 0); a failing + * writer produces a one-frame realization spike → second-diff spikes. + * + * WHY PER-FRAME IS CORRECT HERE (and why it does NOT repeat the mistake that + * killed my median-of-run). median-of-run needed per-NOTCH settled sampling: a + * zero-motion per-RAF frame read as a false stall against the nonzero run + * median. Jerk is IMMUNE to that exact ghost — a paused frame is rowMove_i=0 AND + * rowMove_{i-1}=0 → second-diff 0. The sampling grain follows the metric: jerk + * is a frame-to-frame second difference and REQUIRES per-frame p_i (Quinn, W4). + * + * LEG 2 — skip-forever = signed cumulative drift over a trailing horizon: + * drift_H = Σ_{i in H} max(0, −sign(scrollDir)·rowMove_i) (anti-scroll) + * Jerk MISSES a *sustained* reversal (constant reversal → second-diff 0 too). + * Signed drift catches it: scaling changes forward MAGNITUDE, never SIGN, so + * scaled notches contribute 0 to the anti-scroll accumulator while a sustained + * reversal accumulates. sign(scrollDir) is the coarse run direction, a constant + * — not a per-frame input delta, so Leg 2 also touches no input clock. + * + * EXCLUSION SET (doc §Contract summary). Prepend-commit frames (page legitly + * gains height) and writer skip-catchup frames are EXCLUDED from Leg 1 peak — a + * one-frame reanchor is a real single event, not a lurch. Because jerk is a + * second difference, excluding a frame means BREAKING the diff sequence at it: + * we reset the diff run and only score jerk within contiguous CLEAN spans (span + * (a) in the thread — cleaner than dropping straddling windows, and a real lurch + * that coincides with a commit is not lost, it re-appears on the next clean + * span's first scored frame). Excluded frames are KEPT in Leg 2 drift. + * + * ENGINE FIDELITY — same mirror as T1.1/T1.2: shipped WKWebView has no + * `overflow-anchor`, so we force `overflow-anchor: none` and Chromium reproduces + * the shipped engine. Under `perf-webkit` this is the real WebKit family. + * + * ASYMMETRIC TWO-ENGINE CONTRACT (Eva-ratified; matches T1.2). The engines DO + * NOT share the defect (Dawn's white-box RO logs: WebKit's correction never + * fires, Chromium's fires and holds). So: + * - WebKit @ contract (`6b9203ca`): MUST be RED — the load-bearing validity + * proof. Dawn's fix turns it green. + * - Chromium: GREEN @ contract AND @ fix — a no-regression guard on the engine + * that already works. There is NO Chromium contract defect; a red there + * could only be a metric artifact. DO NOT "fix" Chromium's green red. + * If a future change greens WebKit at contract WITHOUT the fix, this gate is + * VOID — do not relax thresholds; restore the mirror/corpus so WebKit reds. + * + * GUARDRAIL / FALSIFIER (doc §Acceptance): a correct writer (Chromium + * T1.2-green) MUST score ~0 on BOTH legs under wheel actuation. If Leg 1 can't + * hold Chromium ~0 under scaling, the second-diff invariance claim is FALSE and + * we fall back to sync-only (option 2). The ceilings below are pinned only after + * the Chromium-green baseline number is on the record. + * + * Run (Chromium): + * pnpm build && npx playwright test --config=playwright.perf.config.ts \ + * upscroll-trackpad-gate + * Run (WebKit — the engine that reds at tip): + * npx playwright test --config=playwright.perf-webkit.config.ts \ + * upscroll-trackpad-gate --project=perf-webkit + */ + +// LEG 1 ceiling: peak |second difference of rect.top| across per-frame samples +// within a clean span (px). Sits in the gap between the Chromium quantization +// floor (~2-3px at STEP_PX=2) and the WebKit realization spike (~27px). RED at +// tip: WebKit felt lurches spike peak jerk to ~27px (peak ≫ rms — a clean +// outlier). Chromium stays at the floor. GUARDRAIL: pinned only after the +// Chromium-green baseline (~2px) is on the record (WORK_LOG 2026-07-08). +const MAX_PEAK_JERK_PX = 8.0; +// LEG 2 ceiling: signed anti-scroll cumulative drift over a trailing horizon +// (px). Chromium scores 0.00 (huge headroom); WebKit ~25px. Sits between. +const MAX_DRIFT_PX = 12.0; +// LEG 3 ceiling: rms of |second diff| (jerk) over the same non-commit clean +// spans as Leg 1 — the CHATTER catcher (sign-balanced sub-peak lurches that +// dodge Leg 1's peak AND Leg 2's sign; RESEARCH/FELT_WHEEL_GATE_METRIC_W4.md +// §Leg 3). Under discrete integer actuation rms-jerk has an irreducible +// staircase floor ≈ f(STEP) (STEP=2 → ~2px), so this GATES only once a chattery +// R_bad is measured to clear the floor with margin (≥~4-5px). Until that A/B +// separation is on the record the leg is LOG-ONLY: set BUZZ_PERF_GATE_RMS_JERK=1 +// to assert it, pinning the ceiling here between the floor and the R_bad number. +const MAX_RMS_JERK_PX = Number(process.env.BUZZ_PERF_MAX_RMS_JERK_PX ?? 4.0); +const GATE_RMS_JERK = process.env.BUZZ_PERF_GATE_RMS_JERK === "1"; +// Trailing horizon for Leg 2 drift (ms). Long enough to contain a real +// sustained reversal, short enough not to dilute one; sits above a phase pair. +const DRIFT_HORIZON_MS = Number(process.env.BUZZ_PERF_DRIFT_HORIZON_MS ?? 100); + +// Keep the tracked row this far (px) from both viewport edges so it stays +// realized across a frame — no straddling-row un-realization artifact. +const SAFE_MARGIN = 60; +// Swipes per run — enough to cross several fetchOlder pages on the 400-row seed. +const SWIPES = Number(process.env.BUZZ_PERF_SWIPES ?? 30); +// Deferred-commit latency for the mock fetchOlder page, so the prepend commits +// under continuous momentum (mirrors the live ~1s network fetch). +const FETCH_DELAY_MS = Number(process.env.BUZZ_PERF_FETCH_DELAY_MS ?? 1000); +// Constant per-event wheel delta (px). Continuous, no settle between events — +// realization happens WHILE scroll is in flight and more input keeps arriving +// (the momentum property). SMALL by design: Blink/WebKit deliver DISCRETE +// integer-px wheel events and rect.top is integer-quantized, so per-frame +// rowMove is a 0/STEP staircase whose second difference is STEP everywhere — a +// quantization FLOOR that scales with STEP. The WebKit realization shove is a +// FIXED physical displacement, independent of step size, so a SMALL step pulls +// the Chromium floor down (STEP=12 → 12px floor; STEP=2 → 2px floor) while the +// WebKit red holds (~27px) — signal-to-floor 4.6× → 13× (WORK_LOG 2026-07-08). +// This is what makes the raw per-frame 2nd-diff scorer survive without +// windowing: peak≫rms on WebKit is a clean outlier above a ~2px Chromium floor. +const STEP_PX = Number(process.env.BUZZ_PERF_STEP_PX ?? 2); + +type Frame = { + t: number; + scrollTop: number; + rowId: string | null; + rowTop: number | null; + mounted: number; + fetch: number; +}; + +test("GATE: trackpad-momentum upscroll — peak jerk in rect.top stays below the felt-lurch threshold", async ({ + page, +}) => { + test.setTimeout(900_000); + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + + await page.getByTestId("channel-jitter-corpus").click(); + await expect(page.getByTestId("chat-title")).toHaveText("jitter-corpus"); + const timeline = page.getByTestId("message-timeline"); + await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); + await page.waitForFunction(() => { + const el = document.querySelector( + '[data-testid="message-timeline"]', + ) as HTMLDivElement | null; + return !!el && el.scrollHeight > el.clientHeight + 1000; + }); + + // Defer the next fetchOlder page so it commits under momentum, not instantly. + await page.evaluate((delayMs: number) => { + window.__BUZZ_E2E__ = { + ...window.__BUZZ_E2E__, + mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: delayMs }, + }; + }, FETCH_DELAY_MS); + + const anchorSupport = await page.evaluate(() => + typeof CSS !== "undefined" && typeof CSS.supports === "function" + ? CSS.supports("overflow-anchor", "auto") + : false, + ); + + // Pin to the true bottom so everything above is unpainted (at estimate), then + // force the WKWebView mirror (no native scroll anchoring). + await timeline.evaluate((element) => { + const el = element as HTMLDivElement; + el.scrollTop = el.scrollHeight; + el.dispatchEvent(new Event("scroll", { bubbles: true })); + (el as HTMLElement).style.overflowAnchor = "none"; + }); + await page.waitForTimeout(500); + + // ---- Per-RAF sampler, in page, independent of input cadence. Records the + // tracked centre row's rect.top (viewport-relative) every frame. This is the + // per-frame grain jerk requires; scrollTop is recorded ONLY as an activity + // gate for the analysis, never fed into the scorer. + await timeline.evaluate((element, margin: number) => { + const el = element as HTMLDivElement; + const store = window as unknown as { + __FRAMES__: Frame[]; + __SAMPLER_STOP__?: boolean; + __CHANNEL_WINDOW_FETCH_COUNT__?: number; + }; + type Frame = { + t: number; + scrollTop: number; + rowId: string | null; + rowTop: number | null; + mounted: number; + fetch: number; + }; + store.__FRAMES__ = []; + let trackedId: string | null = null; + const pick = (): string | null => { + const box = el.getBoundingClientRect(); + const mid = box.top + box.height / 2; + let best: { id: string; d: number } | null = null; + for (const row of el.querySelectorAll("[data-message-id]")) { + const r = row.getBoundingClientRect(); + if (r.top <= box.top + margin || r.bottom >= box.bottom - margin) + continue; + const d = Math.abs((r.top + r.bottom) / 2 - mid); + if (!best || d < best.d) best = { id: row.dataset.messageId ?? "", d }; + } + return best?.id || null; + }; + const loop = () => { + if (store.__SAMPLER_STOP__) return; + const box = el.getBoundingClientRect(); + // Report the row's top RELATIVE to the container so a container reflow + // does not read as row motion. + let rowTop: number | null = null; + if (trackedId) { + const row = el.querySelector( + `[data-message-id="${CSS.escape(trackedId)}"]`, + ); + if (row) { + const r = row.getBoundingClientRect(); + if (r.top > box.top + margin && r.bottom < box.bottom - margin) + rowTop = r.top - box.top; + } + } + if (rowTop === null) { + trackedId = pick(); + if (trackedId) { + const r = el + .querySelector( + `[data-message-id="${CSS.escape(trackedId)}"]`, + ) + ?.getBoundingClientRect(); + rowTop = r ? r.top - box.top : null; + } + } + store.__FRAMES__.push({ + t: performance.now(), + scrollTop: el.scrollTop, + rowId: trackedId, + rowTop, + mounted: el.querySelectorAll("[data-message-id]").length, + fetch: store.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0, + }); + requestAnimationFrame(loop); + }; + requestAnimationFrame(loop); + }, SAFE_MARGIN); + + // ---- Continuous constant-delta wheel input. No settle between events: input + // keeps arriving through realization and prepend commits (the momentum + // property). Blink scales the delta per notch; jerk reads through it. + const box = await timeline.boundingBox(); + if (!box) throw new Error("no timeline box"); + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + const isChromium = + page.context().browser()?.browserType().name() === "chromium"; + const cdp = isChromium ? await page.context().newCDPSession(page) : null; + await page.mouse.move(cx, cy); + + // Each swipe covers a fixed SCROLL DISTANCE, not a fixed event count, so a + // smaller STEP_PX just means more (finer) events per swipe — the swipe still + // crosses the same amount of history and pages a fetchOlder prepend. At the + // live profile's ~816px/swipe (68 × 12px), STEP=2 → ~408 events/swipe. + const SWIPE_DISTANCE_PX = 816; + const EVENTS_PER_SWIPE = Math.max(1, Math.round(SWIPE_DISTANCE_PX / STEP_PX)); + for (let s = 0; s < SWIPES; s++) { + for (let e = 0; e < EVENTS_PER_SWIPE; e++) { + if (cdp) { + await cdp.send("Input.dispatchMouseEvent", { + type: "mouseWheel", + x: cx, + y: cy, + deltaX: 0, + deltaY: -STEP_PX, + pointerType: "mouse", + }); + } else { + await page.mouse.wheel(0, -STEP_PX); + } + await new Promise((r) => setTimeout(r, 8)); + } + await page.waitForTimeout(120); + const at = await timeline.evaluate( + (el) => (el as HTMLDivElement).scrollTop, + ); + if (at <= 0) { + // At the wall — let the deferred prepend land, then keep swiping. + await page.waitForTimeout(FETCH_DELAY_MS + 1500); + } + } + + await page.evaluate(() => { + (window as unknown as { __SAMPLER_STOP__?: boolean }).__SAMPLER_STOP__ = + true; + }); + const frames = (await page.evaluate( + () => (window as unknown as { __FRAMES__: Frame[] }).__FRAMES__, + )) as Frame[]; + + // ---- Analysis: two legs on per-frame rect.top derivatives (Quinn's W4). + // + // Build the first-difference series rowMove_i = p_i − p_{i-1}, flagging frames + // where the diff sequence must BREAK (an excluded frame): a re-pick boundary + // (rowId changed), a prepend commit (mounted grew), or a missing sample. A + // "clean span" is a maximal run of consecutive frames with a valid rowMove and + // no break inside it. Leg 1 (jerk = second diff) is scored WITHIN clean spans + // only; Leg 2 (drift) accumulates across everything (excluded frames kept). + type Step = { + i: number; + t: number; + dt: number; + rowMove: number; + scrollDelta: number; + breakBefore: boolean; // this step cannot chain to the previous (excluded) + }; + const steps: Step[] = []; + for (let i = 1; i < frames.length; i++) { + const a = frames[i - 1]; + const b = frames[i]; + const sameRow = a.rowId != null && a.rowId === b.rowId; + const haveTops = a.rowTop !== null && b.rowTop !== null; + const commit = b.mounted > a.mounted; // prepend re-anchor: excluded frame + if (!sameRow || !haveTops) { + // No valid rowMove across this boundary — emit no step. The resulting gap + // in frame indices is detected below and breaks the jerk chain. + continue; + } + steps.push({ + i, + t: b.t, + dt: b.t - a.t, + rowMove: (b.rowTop as number) - (a.rowTop as number), + scrollDelta: a.scrollTop - b.scrollTop, + breakBefore: commit, // a commit frame breaks the jerk chain before it + }); + } + // A gap in frame indices (a skipped invalid pair — re-pick / missing sample) + // also breaks the chain: the step after the gap cannot second-difference + // against a rowMove computed across the excluded region. + for (let k = 1; k < steps.length; k++) { + if (steps[k].i !== steps[k - 1].i + 1) steps[k].breakBefore = true; + } + + // Empirical run velocity (px/ms) from row motion + wall clock only — used as + // an activity gate and anti-cheat, never in the scorer. + const totalMove = steps.reduce((acc, s) => acc + s.rowMove, 0); + const totalDt = steps.reduce((acc, s) => acc + s.dt, 0); + const velocity = totalDt > 0 ? totalMove / totalDt : 0; // px/ms + const scrollDir = Math.sign(steps.reduce((acc, s) => acc + s.scrollDelta, 0)); // coarse run direction (up), constant — NOT a per-frame input delta + + // ---- LEG 1: peak |second difference| within clean spans. A span breaks at + // any step whose breakBefore is set; jerk_k = |rowMove_k − rowMove_{k-1}| is + // scored only when step k and k-1 are in the same span. + let peakJerk = 0; + let jerkCount = 0; + const jerks: number[] = []; + let peakAt = -1; + for (let k = 1; k < steps.length; k++) { + if (steps[k].breakBefore || steps[k - 1].breakBefore) continue; + // Activity gate: both frames must have actually scrolled — a paused pair + // has rowMove≈0 both sides (jerk 0 anyway), but skip to avoid inflating the + // sample count with dead frames. + if ( + Math.abs(steps[k].scrollDelta) < 0.5 && + Math.abs(steps[k - 1].scrollDelta) < 0.5 + ) + continue; + const jerk = Math.abs(steps[k].rowMove - steps[k - 1].rowMove); + jerks.push(jerk); + jerkCount += 1; + if (jerk > peakJerk) { + peakJerk = jerk; + peakAt = steps[k].i; + } + } + const rmsJerk = jerks.length + ? Math.sqrt(jerks.reduce((acc, j) => acc + j * j, 0) / jerks.length) + : 0; + + // ---- LEG 2: peak signed anti-scroll cumulative drift over a trailing + // horizon. For each end step, walk back DRIFT_HORIZON_MS accumulating only the + // component of rowMove OPPOSITE the scroll direction (a sustained reversal); + // scaled forward notches are same-sign and contribute 0. + let peakDrift = 0; + for (let end = 0; end < steps.length; end++) { + let acc = 0; + let dt = 0; + for (let start = end; start >= 0 && dt < DRIFT_HORIZON_MS; start--) { + const anti = -scrollDir * steps[start].rowMove; // >0 == against scroll + if (anti > 0) acc += anti; + dt += steps[start].dt; + } + if (dt < DRIFT_HORIZON_MS) continue; // not enough history (run start) + if (acc > peakDrift) peakDrift = acc; + } + + // Whole-run cumulative anti-scroll drift: the same anti-scroll component as + // Leg 2 but summed over EVERY step, no trailing window. LOG-ONLY, never gated + // — the windowed peakDrift is the felt-relevant burst (what you perceive); this + // is the W4a reference scale: summed-magnitude of ALL anti-scroll motion + // (non-recovering under-corrections + the reversal halves of recovering + // frame-late flashes + multi-frame realizations). The ~220px figure quoted + // earlier was the non-recovering subset only; measured whole-run baseline at + // e7297849 is WebKit 1042.81 / Chromium 0.00. Windowed can't reach it by + // construction. The sharp W2 signal: the band should collapse THIS number even + // if the windowed burst barely moves (RESEARCH/FELT_WHEEL_GATE_METRIC_W4.md). + let totalDrift = 0; + for (const step of steps) { + const anti = -scrollDir * step.rowMove; // >0 == against scroll + if (anti > 0) totalDrift += anti; + } + + const commits = frames.filter( + (f, i) => i > 0 && f.mounted > frames[i - 1].mounted, + ); + const prependObserved = commits.length > 0; + const finalMounted = frames[frames.length - 1]?.mounted ?? 0; + const engine = page.context().browser()?.browserType().name(); + + /* eslint-disable no-console */ + console.log( + `\n=== TRACKPAD-MOMENTUM UPSCROLL GATE (jerk + signed drift, mock jitter-corpus) engine=${engine} ===`, + ); + console.log( + `overflow-anchor supported by this engine: ${anchorSupport} (forced 'none' to mirror WKWebView)`, + ); + console.log( + `frames=${frames.length} steps=${steps.length} jerk-samples=${jerkCount} commits=${commits.length} swipes=${SWIPES} finalMounted=${finalMounted} velocity=${velocity.toFixed(3)}px/ms`, + ); + console.log( + `scored a fetchOlder prepend: ${prependObserved} (prepend-commit half exercised)`, + ); + console.log( + `LEG 1 peak jerk |d2 rect.top|: ${peakJerk.toFixed(2)}px (gate <= ${MAX_PEAK_JERK_PX}) @frame ${peakAt} rms=${rmsJerk.toFixed(2)}px`, + ); + console.log( + `LEG 2 peak signed drift: ${peakDrift.toFixed(2)}px over ${DRIFT_HORIZON_MS}ms horizon (gate <= ${MAX_DRIFT_PX})`, + ); + console.log( + `LEG 2 cumulative drift: ${totalDrift.toFixed(2)}px whole-run (LOG-ONLY — W4a reference scale, W2 should collapse this)`, + ); + console.log( + `LEG 3 rms jerk (chatter): ${rmsJerk.toFixed(2)}px (${GATE_RMS_JERK ? `gate <= ${MAX_RMS_JERK_PX}` : "LOG-ONLY — no A/B separation pinned yet"})`, + ); + console.log( + "(peak jerk ~0 == smooth row motion; a one-frame spike == felt lurch)", + ); + console.log("===========================================================\n"); + /* eslint-enable no-console */ + + // Sanity: the run actually exercised a meaningful momentum upscroll. + expect(frames.length).toBeGreaterThan(500); + expect(finalMounted).toBeGreaterThanOrEqual(80); + expect(jerkCount).toBeGreaterThan(20); + + // COVERAGE: the run must cross at least one fetchOlder prepend so BOTH lurch + // sources (CV realization + prepend-commit-under-momentum) are under the gate. + expect(prependObserved).toBe(true); + + // ANTI-CHEAT: the reading row must actually track the input. A frozen or + // half-applying scroller has near-zero velocity; assert a real scroll rate + // (frame-rate invariant — px/ms, not per-frame motion). The floor scales with + // STEP_PX: a smaller step delivers the same total distance over more events + // (more per-event pacing overhead), so wall-clock px/ms drops proportionally — + // a fixed 0.1 floor was pinned at STEP=12 and false-fails a correctly-tracking + // STEP=2 run. 0.008·STEP_PX ≈ 0.016 at STEP=2 / 0.096 at STEP=12: well above a + // frozen scroller (~0), well below a real tracking run (STEP=2 measured ~0.07). + const MIN_VELOCITY = 0.008 * STEP_PX; + expect(Math.abs(velocity)).toBeGreaterThanOrEqual(MIN_VELOCITY); + + // THE GATE (LEG 1). RED at tip under the WebKit mirror (felt lurches spike + // jerk well past the threshold); Dawn's engine-order-independent fix produces + // smooth row motion (jerk ~0) and turns it green on both engines. + // + // GUARDRAIL: trustworthy only once a correct writer (Chromium T1.2-green) is + // confirmed ~0 here under wheel. If jerk can't hold Chromium ~0, invariance is + // falsified — do NOT relax; fall back to sync-only (option 2). See the header. + expect(peakJerk).toBeLessThanOrEqual(MAX_PEAK_JERK_PX); + // Leg 2 (signed drift) — the skip-forever guard. Pinned once the guardrail + // number lands; asserted here as the second leg of the ratified contract. + expect(peakDrift).toBeLessThanOrEqual(MAX_DRIFT_PX); + // Leg 3 (rms-jerk) — the chatter guard. Gated only when the A/B separation is + // on the record (BUZZ_PERF_GATE_RMS_JERK=1); log-only until then so a run + // against a correct writer that merely sits at the ~2px discretization floor + // does not false-red. See §Leg 3 pin criterion in the metric doc. + if (GATE_RMS_JERK) { + expect(rmsJerk).toBeLessThanOrEqual(MAX_RMS_JERK_PX); + } +}); diff --git a/desktop/tests/e2e/upscroll-trackpad-live.perf.ts b/desktop/tests/e2e/upscroll-trackpad-live.perf.ts new file mode 100644 index 0000000000..e38d2444b7 --- /dev/null +++ b/desktop/tests/e2e/upscroll-trackpad-live.perf.ts @@ -0,0 +1,358 @@ +import { expect, test } from "@playwright/test"; +import { decode } from "nostr-tools/nip19"; +import { getPublicKey } from "nostr-tools/pure"; + +import { installRelayBridge } from "../helpers/bridge"; + +/** + * LIVE TRACKPAD-MOMENTUM UPSCROLL PROBE — Tyler: "the jumping is bad even on + * PR 1650" with a trackpad. The prior probes settled 1-2 RAFs after every + * step; a trackpad does not. This probe replicates macOS trackpad swipes: + * trusted CDP mouseWheel events at ~8ms spacing — finger ramp then an + * exponentially-decaying momentum tail — fired CONTINUOUSLY through real + * fetchOlder commits in #buzz-bugs against live staging. No settling. + * + * Measurement is a per-RAF in-page sampler, independent of input timing: + * every frame it records scrollTop + the tracked center row's rect.top + + * mounted count + fetch count. For a solid page, + * rowMove(frame) = rect.top delta = scrollTop_before - scrollTop_after. + * Per-frame deviation = rowMove - appliedScroll. Nonzero = content shifted + * under the viewport that the input didn't ask for — the felt jump — + * regardless of whether it came from CV realization, a losing compensation + * race, or a prepend anchor miss. + * + * Run: same env as upscroll-1px-live.perf.ts. + */ + +const RELAY_HTTP = + process.env.BUZZ_E2E_RELAY_URL ?? "https://sprout-oss.stage.blox.sqprod.co"; +const NSEC = process.env.BUZZ_PERF_NSEC ?? ""; +const COMMUNITY_HOST = process.env.BUZZ_COMMUNITY_HOST ?? ""; +const TARGET_CHANNEL = process.env.BUZZ_PERF_CHANNEL ?? "buzz-bugs"; +const SWIPES = Number(process.env.BUZZ_PERF_SWIPES ?? 30); +const SAFE_MARGIN = 60; + +const IDENTITY_OVERRIDE_KEY = "buzz:e2e-identity-override.v1"; +const ONBOARDING_PREFIX = "buzz-onboarding-complete.v1:"; +const WELCOME_PREFIX = "buzz-welcome-channel-ensured.v2:"; +const REAL_CHROME_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"; + +test.use({ userAgent: REAL_CHROME_UA }); + +function deriveIdentity(nsec: string) { + const decoded = decode(nsec.trim()); + if (decoded.type !== "nsec") throw new Error("BUZZ_PERF_NSEC is not an nsec"); + const skBytes = decoded.data as Uint8Array; + return { + privateKey: Buffer.from(skBytes).toString("hex"), + pubkey: getPublicKey(skBytes), + username: "perf-eva", + }; +} + +// One macOS-ish swipe: finger ramp (accelerating) + momentum tail (exp decay). +function swipeDeltas(): number[] { + const deltas: number[] = []; + // finger: ~12 events ramping 4 -> 36 px + for (let i = 0; i < 12; i++) deltas.push(4 + Math.round((32 * i) / 11)); + // momentum: decay from 36 by 0.94/event until < 1 + let v = 36; + while (v >= 1) { + deltas.push(Math.round(v)); + v *= 0.94; + } + return deltas; // ~68 events, ~1500px total +} + +type Frame = { + t: number; + scrollTop: number; + rowId: string | null; + rowTop: number | null; + mounted: number; + fetch: number; +}; + +test("MEASURE: live trackpad-momentum upscroll profile", async ({ page }) => { + test.setTimeout(900_000); + if (!NSEC) throw new Error("Set BUZZ_PERF_NSEC to a real member nsec"); + const identity = deriveIdentity(NSEC); + + await installRelayBridge(page, "tyler"); + const wsUrl = RELAY_HTTP.replace(/^http/, "ws"); + await page.addInitScript( + ({ ident, onboardingPrefix, welcomePrefix, relayUrl, overrideKey }) => { + window.localStorage.setItem(overrideKey, JSON.stringify(ident)); + window.localStorage.setItem(`${onboardingPrefix}${ident.pubkey}`, "true"); + window.localStorage.setItem( + `${welcomePrefix}${encodeURIComponent(relayUrl)}:${ident.pubkey}`, + "true", + ); + const w = window as unknown as { __BUZZ_E2E__?: Record }; + w.__BUZZ_E2E__ = { ...(w.__BUZZ_E2E__ ?? {}), identity: ident }; + }, + { + ident: identity, + onboardingPrefix: ONBOARDING_PREFIX, + welcomePrefix: WELCOME_PREFIX, + relayUrl: wsUrl, + overrideKey: IDENTITY_OVERRIDE_KEY, + }, + ); + + const relayHost = new URL(RELAY_HTTP).host; + await page.route( + (url) => url.host === relayHost, + async (route) => { + const req = route.request(); + const fwd = { ...req.headers(), "user-agent": REAL_CHROME_UA }; + delete fwd["sec-ch-ua"]; + delete fwd["sec-ch-ua-mobile"]; + delete fwd["sec-ch-ua-platform"]; + if (COMMUNITY_HOST) fwd.host = COMMUNITY_HOST; + let resp: Awaited> | null = null; + for (let attempt = 0; attempt < 5; attempt++) { + try { + resp = await route.fetch({ headers: fwd }); + break; + } catch (err) { + if (attempt === 4) throw err; + await new Promise((r) => setTimeout(r, 1500)); + } + } + if (!resp) throw new Error("unreachable"); + const headers = { ...resp.headers() }; + headers["access-control-allow-origin"] = "*"; + headers["access-control-allow-headers"] = "*"; + headers["access-control-allow-methods"] = "*"; + await route.fulfill({ response: resp, headers, body: await resp.body() }); + }, + ); + + await page.goto("/"); + await page.getByTestId("app-sidebar").waitFor({ state: "visible" }); + const chan = page.getByTestId(`channel-${TARGET_CHANNEL}`).first(); + await chan.waitFor({ state: "visible", timeout: 45_000 }); + await chan.click(); + await page + .locator('[data-testid="message-timeline"] [data-message-id]') + .first() + .waitFor({ state: "visible", timeout: 30_000 }); + await page.waitForTimeout(3000); + + const timeline = page.getByTestId("message-timeline"); + // WKWebView mirror. + await timeline.evaluate((el) => { + (el as HTMLElement).style.overflowAnchor = "none"; + }); + await timeline.evaluate((element) => { + const el = element as HTMLDivElement; + el.scrollTop = el.scrollHeight; + el.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await page.waitForTimeout(500); + + // ---- Per-RAF sampler, in page, independent of input cadence ---- + await timeline.evaluate((element, margin: number) => { + const el = element as HTMLDivElement; + const store = window as unknown as { + __FRAMES__: Frame[]; + __SAMPLER_STOP__?: boolean; + __CHANNEL_WINDOW_FETCH_COUNT__?: number; + }; + type Frame = { + t: number; + scrollTop: number; + rowId: string | null; + rowTop: number | null; + mounted: number; + fetch: number; + }; + store.__FRAMES__ = []; + let trackedId: string | null = null; + const pick = (): string | null => { + const box = el.getBoundingClientRect(); + const mid = box.top + box.height / 2; + let best: { id: string; d: number } | null = null; + for (const row of el.querySelectorAll("[data-message-id]")) { + const r = row.getBoundingClientRect(); + if (r.top <= box.top + margin || r.bottom >= box.bottom - margin) + continue; + const d = Math.abs((r.top + r.bottom) / 2 - mid); + if (!best || d < best.d) best = { id: row.dataset.messageId ?? "", d }; + } + return best?.id || null; + }; + const loop = () => { + if (store.__SAMPLER_STOP__) return; + const box = el.getBoundingClientRect(); + let rowTop: number | null = null; + if (trackedId) { + const row = el.querySelector( + `[data-message-id="${CSS.escape(trackedId)}"]`, + ); + if (row) { + const r = row.getBoundingClientRect(); + if (r.top > box.top + margin && r.bottom < box.bottom - margin) + rowTop = r.top; + } + } + if (rowTop === null) { + trackedId = pick(); + if (trackedId) { + const r = el + .querySelector( + `[data-message-id="${CSS.escape(trackedId)}"]`, + ) + ?.getBoundingClientRect(); + rowTop = r ? r.top : null; + } + } + store.__FRAMES__.push({ + t: performance.now(), + scrollTop: el.scrollTop, + rowId: trackedId, + rowTop, + mounted: el.querySelectorAll("[data-message-id]").length, + fetch: store.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0, + }); + requestAnimationFrame(loop); + }; + requestAnimationFrame(loop); + }, SAFE_MARGIN); + + // ---- Trusted trackpad-like wheel input via CDP ---- + const box = await timeline.boundingBox(); + if (!box) throw new Error("no timeline box"); + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + const isChromium = + page.context().browser()?.browserType().name() === "chromium"; + const cdp = isChromium ? await page.context().newCDPSession(page) : null; + await page.mouse.move(cx, cy); + + for (let s = 0; s < SWIPES; s++) { + const deltas = swipeDeltas(); + for (const d of deltas) { + if (cdp) { + await cdp.send("Input.dispatchMouseEvent", { + type: "mouseWheel", + x: cx, + y: cy, + deltaX: 0, + deltaY: -d, + pointerType: "mouse", + }); + } else { + await page.mouse.wheel(0, -d); + } + await new Promise((r) => setTimeout(r, 8)); + } + // brief finger-off pause between swipes, like a real gesture chain + await page.waitForTimeout(120); + const at = await timeline.evaluate( + (el) => (el as HTMLDivElement).scrollTop, + ); + if (at <= 0) { + // at the wall — give the prepend a chance to land, keep swiping after + await page.waitForTimeout(2500); + } + if (s % 5 === 4) + console.log( + `[swipe ${s + 1}/${SWIPES}] scrollTop=${at.toFixed(0)} frames=${await page.evaluate(() => (window as unknown as { __FRAMES__: unknown[] }).__FRAMES__.length)}`, + ); + } + + await page.evaluate(() => { + (window as unknown as { __SAMPLER_STOP__?: boolean }).__SAMPLER_STOP__ = + true; + }); + const frames = (await page.evaluate( + () => (window as unknown as { __FRAMES__: Frame[] }).__FRAMES__, + )) as Frame[]; + + // ---- Analysis: per-frame deviation of row motion vs applied scroll ---- + type Dev = { + i: number; + dev: number; + applied: number; + rowMove: number; + dt: number; + mountedGrew: boolean; + fetch: number; + scrollTop: number; + }; + const devs: Dev[] = []; + for (let i = 1; i < frames.length; i++) { + const a = frames[i - 1]; + const b = frames[i]; + if (!a.rowId || a.rowId !== b.rowId) continue; // re-pick boundary + if (a.rowTop === null || b.rowTop === null) continue; + const applied = a.scrollTop - b.scrollTop; + const rowMove = b.rowTop - a.rowTop; + devs.push({ + i, + dev: rowMove - applied, + applied, + rowMove, + dt: b.t - a.t, + mountedGrew: b.mounted > a.mounted, + fetch: b.fetch, + scrollTop: b.scrollTop, + }); + } + + const scored = devs.filter((d) => !d.mountedGrew); // prepend-commit frames reported separately + const commits = devs.filter((d) => d.mountedGrew); + const jumps = scored + .filter((d) => Math.abs(d.dev) > 2) + .sort((x, y) => Math.abs(y.dev) - Math.abs(x.dev)); + + const hist = new Map(); + for (const d of scored) { + const bucket = + Math.abs(d.dev) <= 1 + ? 0 + : Math.sign(d.dev) * 2 ** Math.ceil(Math.log2(Math.abs(d.dev))); + hist.set(bucket, (hist.get(bucket) ?? 0) + 1); + } + + console.log( + `\n=== LIVE TRACKPAD PROFILE: #${TARGET_CHANNEL} engine=${page.context().browser()?.browserType().name()} ===`, + ); + console.log( + `frames=${frames.length} scoredFramePairs=${scored.length} swipes=${SWIPES} fetchPages=${frames[frames.length - 1]?.fetch ?? 0} finalMounted=${frames[frames.length - 1]?.mounted ?? 0}`, + ); + console.log( + "\n--- per-frame deviation histogram (0 = tracked row moved exactly what input asked; buckets are +/- powers of 2 px) ---", + ); + for (const [k, v] of [...hist.entries()].sort((a, b) => a[0] - b[0])) + console.log(` ${String(k).padStart(6)} : ${v}`); + console.log( + `\nsmooth frames (|dev|<=1px): ${scored.filter((d) => Math.abs(d.dev) <= 1).length}/${scored.length} = ${((100 * scored.filter((d) => Math.abs(d.dev) <= 1).length) / Math.max(1, scored.length)).toFixed(2)}%`, + ); + console.log(`\n--- jump frames |dev|>2px: ${jumps.length} ---`); + for (const d of jumps.slice(0, 50)) + console.log( + ` frame=${d.i} dev=${d.dev.toFixed(1)}px applied=${d.applied.toFixed(1)} rowMove=${d.rowMove.toFixed(1)} dt=${d.dt.toFixed(1)}ms fetch=${d.fetch} scrollTop=${d.scrollTop.toFixed(0)}`, + ); + console.log(`\n--- prepend-commit frames (${commits.length}) ---`); + for (const d of commits) + console.log( + ` frame=${d.i} dev=${d.dev.toFixed(1)}px applied=${d.applied.toFixed(1)} rowMove=${d.rowMove.toFixed(1)} dt=${d.dt.toFixed(1)}ms fetch=${d.fetch} scrollTop=${d.scrollTop.toFixed(0)}`, + ); + + // long frames = jank of a different kind (main-thread stalls) + const longFrames = devs.filter((d) => d.dt > 34); + console.log( + `\n--- long frames (>34ms, i.e. dropped at least one 60Hz frame): ${longFrames.length} ---`, + ); + for (const d of longFrames.slice(0, 25)) + console.log( + ` frame=${d.i} dt=${d.dt.toFixed(0)}ms dev=${d.dev.toFixed(1)} applied=${d.applied.toFixed(1)} fetch=${d.fetch} scrollTop=${d.scrollTop.toFixed(0)}`, + ); + console.log("==============================================\n"); + + expect(frames.length).toBeGreaterThan(500); +});