diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index df9af09e67..082af46130 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -570,12 +570,17 @@ const MessageTimelineBase = React.forwardRef< // Indexed find navigation can legitimately land near the current history // boundary. Do not mistake that programmatic jump for scrollback intent and // prepend underneath the active match. - // A settle-gate hold means the reader is still parked at the OLD - // boundary — don't stack more page fetches behind the held commit. + // A landed page that has not painted yet — deferred snapshot still behind + // the live cache, or held by the settle gate — means the reader is still + // parked at the OLD boundary. The scroll events WebKit keeps emitting + // there would otherwise start the next page in the gap between + // `isFetchingOlder` clearing and the prepend committing, cascading one + // gesture into several pages. Same predicate the spinner uses below. if ( searchActiveMessageId || !fetchOlder || isFetchingOlder || + isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) || isHoldingPrepend || showTimelineSkeleton || !hasOlderMessages @@ -585,10 +590,12 @@ const MessageTimelineBase = React.forwardRef< void fetchOlder(); return true; }, [ + deferredMessages, fetchOlder, hasOlderMessages, isFetchingOlder, isHoldingPrepend, + messages, searchActiveMessageId, showTimelineSkeleton, ]); diff --git a/desktop/src/features/messages/ui/useSettleGatedPrependMessages.test.mjs b/desktop/src/features/messages/ui/useSettleGatedPrependMessages.test.mjs index b134b832af..12bc9332b1 100644 --- a/desktop/src/features/messages/ui/useSettleGatedPrependMessages.test.mjs +++ b/desktop/src/features/messages/ui/useSettleGatedPrependMessages.test.mjs @@ -86,3 +86,122 @@ test("passes when the previous snapshot was empty (initial load)", () => { "pass", ); }); + +// Hook-level: the hold is released by the scroller's observed motion, never by +// an assumed one. Frames and the clock are both driven by hand. +async function mountHold({ motionBeforeHoldAt = null } = {}) { + const { JSDOM } = await import("jsdom"); + const React = await import("react"); + const { act } = React; + const { createRoot } = await import("react-dom/client"); + const { useSettleGatedPrependMessages } = await import( + "./useSettleGatedPrependMessages.ts" + ); + const dom = new JSDOM( + "
", + ); + const frames = []; + let now = 0; + const saved = { + document: globalThis.document, + window: globalThis.window, + IS_REACT_ACT_ENVIRONMENT: globalThis.IS_REACT_ACT_ENVIRONMENT, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + performanceNow: performance.now, + }; + Object.assign(globalThis, { + document: dom.window.document, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + requestAnimationFrame(callback) { + frames.push(callback); + return frames.length; + }, + cancelAnimationFrame() {}, + }); + performance.now = () => now; + + const scroller = dom.window.document.getElementById("scroller"); + scroller.scrollTop = 400; + if (motionBeforeHoldAt !== null) { + now = motionBeforeHoldAt; + } + let output; + function Harness({ messages }) { + output = useSettleGatedPrependMessages({ + channelId: "c", + messages, + meta: null, + scrollElementRef: { current: scroller }, + }); + return null; + } + const root = createRoot(dom.window.document.getElementById("root")); + await act(async () => + root.render(React.createElement(Harness, { messages: rows("a", "b") })), + ); + if (motionBeforeHoldAt !== null) { + scroller.dispatchEvent(new dom.window.Event("scroll")); + } + now = motionBeforeHoldAt === null ? 1_000 : motionBeforeHoldAt + 50; + await act(async () => + root.render( + React.createElement(Harness, { messages: rows("older", "a", "b") }), + ), + ); + const tick = async (advanceMs) => { + now += advanceMs; + const pending = frames.splice(0); + await act(async () => { + for (const frame of pending) frame(); + }); + }; + const cleanup = async () => { + await act(async () => root.unmount()); + dom.window.close(); + performance.now = saved.performanceNow; + for (const key of [ + "document", + "window", + "IS_REACT_ACT_ENVIRONMENT", + "requestAnimationFrame", + "cancelAnimationFrame", + ]) { + if (saved[key] === undefined) delete globalThis[key]; + else globalThis[key] = saved[key]; + } + }; + return { output: () => output, tick, cleanup }; +} + +test("a scroller at rest admits after the stable-frame count with no assumed quiet window", async () => { + const hold = await mountHold(); + assert.equal(hold.output().isHoldingPrepend, true); + // Three frames, clock barely moving: nothing has reported motion, so the + // only thing the gate may wait for is frame-over-frame stability. + await hold.tick(1); + await hold.tick(1); + assert.equal(hold.output().isHoldingPrepend, true); + await hold.tick(1); + assert.equal(hold.output().isHoldingPrepend, false); + assert.deepEqual( + hold.output().messages.map(({ id }) => id), + ["older", "a", "b"], + ); + await hold.cleanup(); +}); + +test("motion observed before the hold opened still charges the quiet window", async () => { + const hold = await mountHold({ motionBeforeHoldAt: 10_000 }); + // Scroll event at t=10000, hold opened at t=10050. Stable frames alone must + // not admit until 100ms have passed since that event. + await hold.tick(10); + await hold.tick(10); + await hold.tick(10); + await hold.tick(10); + assert.equal(hold.output().isHoldingPrepend, true); + await hold.tick(20); + assert.equal(hold.output().isHoldingPrepend, false); + await hold.cleanup(); +}); diff --git a/desktop/src/features/messages/ui/useSettleGatedPrependMessages.ts b/desktop/src/features/messages/ui/useSettleGatedPrependMessages.ts index bad8d0f04a..0f42eae443 100644 --- a/desktop/src/features/messages/ui/useSettleGatedPrependMessages.ts +++ b/desktop/src/features/messages/ui/useSettleGatedPrependMessages.ts @@ -27,13 +27,15 @@ import * as React from "react"; export const SETTLE_MOTION_WINDOW_MS = 100; export const SETTLE_FRAME_COUNT = 3; /** - * Upper bound on how long a fetched page may be withheld. Trackpad momentum - * decays in well under a second; a reader actively driving the scroller for - * this long has moved on, and admitting under continuous REAL input is safe — - * the dropped-write hazard is specific to the inertial momentum phase, which - * cannot outlive this deadline. + * Upper bound on how long a fetched page may be withheld. The dropped-write + * hazard is specific to the inertial momentum phase, and trackpad momentum + * decays in well under a second — so a scroller still reporting motion past + * this deadline is under continuous REAL input, where admitting is safe. + * Every millisecond above the momentum bound is pure wait for a reader who + * keeps two fingers on the pad while history loads (#1698 used 4s; a 450ms + * page then painted seconds after it arrived). */ -export const SETTLE_HOLD_DEADLINE_MS = 4_000; +export const SETTLE_HOLD_DEADLINE_MS = 1_500; export type SettleGateDecision = | { kind: "pass" } @@ -126,6 +128,28 @@ export function useSettleGatedPrependMessages({ const latestMetaRef = React.useRef(meta); latestMetaRef.current = meta; + // Track motion for the scroller's whole life, not just while holding, so a + // hold that begins on a scroller at rest knows it is at rest. #1698 assumed + // motion at hold start instead, which charged every page a full quiet + // window (~100ms) even when the reader had stopped long before it landed. + // A live fling keeps this fresh on its own: its scroll events precede the + // hold, so a hold opened mid-fling still waits out the quiet window even if + // WebKit starves the first events after it opens. + const lastMotionTsRef = React.useRef(Number.NEGATIVE_INFINITY); + React.useEffect(() => { + const scroller = scrollElementRef.current; + if (!scroller) return; + const markMotion = () => { + lastMotionTsRef.current = performance.now(); + }; + scroller.addEventListener("scroll", markMotion, { passive: true }); + scroller.addEventListener("wheel", markMotion, { passive: true }); + return () => { + scroller.removeEventListener("scroll", markMotion); + scroller.removeEventListener("wheel", markMotion); + }; + }, [scrollElementRef]); + React.useEffect(() => { if (!isHoldingPrepend) return; const scroller = scrollElementRef.current; @@ -138,24 +162,15 @@ export function useSettleGatedPrependMessages({ } let frame: number | null = null; const deadline = performance.now() + SETTLE_HOLD_DEADLINE_MS; - // Assume motion at hold start: worst case this costs one quiet window - // (~100ms) behind the fetching-older spinner when the reader was already - // at rest; the alternative admits mid-fling if WebKit starves the first - // scroll events. - let lastMotionTs = performance.now(); let previousScrollTop = scroller.scrollTop; let settledFrames = 0; - const markMotion = () => { - lastMotionTs = performance.now(); - }; - scroller.addEventListener("scroll", markMotion, { passive: true }); - scroller.addEventListener("wheel", markMotion, { passive: true }); const watch = () => { const scrollTop = scroller.scrollTop; settledFrames = Math.abs(scrollTop - previousScrollTop) < 0.5 ? settledFrames + 1 : 0; previousScrollTop = scrollTop; - const quiet = performance.now() - lastMotionTs >= SETTLE_MOTION_WINDOW_MS; + const quiet = + performance.now() - lastMotionTsRef.current >= SETTLE_MOTION_WINDOW_MS; if ( (quiet && settledFrames >= SETTLE_FRAME_COUNT) || performance.now() >= deadline @@ -170,8 +185,6 @@ export function useSettleGatedPrependMessages({ }; frame = requestAnimationFrame(watch); return () => { - scroller.removeEventListener("scroll", markMotion); - scroller.removeEventListener("wheel", markMotion); if (frame !== null) cancelAnimationFrame(frame); }; }, [isHoldingPrepend, scrollElementRef]); diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index 0e1d4cfed6..6c6492f780 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -1976,16 +1976,30 @@ test("one scroll-up gesture pages older history once, not to the channel top", a // Let any cascade run unimpeded for a generous window. With the bug, the // observer re-arms after each page and digs through all ~1200 older roots in // this window; with the fix it pages once and waits for the next gesture. - await page.waitForTimeout(2_500); + // + // WKWebView keeps emitting `scroll` events while the reader rubber-bands at + // the boundary; each one re-enters the load-older trigger. Chromium emits + // none at rest, which hid the resolve→commit re-fire gap from this test. + // Emulate WebKit: nudge the offset 0↔1 every frame while parked in the + // trigger band, until the prepend commit carries the reader out of it. + await timeline.evaluate(async (element) => { + const deadline = performance.now() + 2_500; + while (performance.now() < deadline) { + if (element.scrollTop <= 200) { + element.scrollTop = element.scrollTop === 0 ? 1 : 0; + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + }); const pagesFetched = await fetchCount(); const deepest = await oldestRenderedIndex(); - // One gesture should yield a small, bounded number of pages — not dozens. - // pageOlderMessagesUntilRowFloor may fetch up to MAX_BATCHES_PER_FETCH (3) - // relay pages to satisfy one visible row floor, so allow that ceiling plus a - // little slack; a cascade blows far past it. - expect(pagesFetched).toBeLessThanOrEqual(4); + // One gesture, one page. `pageOlderMessagesUntilRowFloor` fetches exactly + // one server-defined window per call (#1500), and the trigger refuses to + // re-fire while a landed page is still unpainted, so anything above one is a + // cascade off the resolve→commit gap. + expect(pagesFetched).toBeLessThanOrEqual(1); // And it must NOT have reached the oldest seeded root on its own. expect(deepest ?? Number.POSITIVE_INFINITY).toBeGreaterThan(50); }); diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts index f7a44f05c7..2764b603c7 100644 --- a/desktop/tests/e2e/virtualization.spec.ts +++ b/desktop/tests/e2e/virtualization.spec.ts @@ -483,19 +483,19 @@ test.describe("list virtualization", () => { } }); - test("09 — older-page render commit waits for scroller rest under continued wheel input", async ({ + test("09 — older-page render commit is bounded under continued wheel input, and holds the anchor", async ({ page, }) => { test.setTimeout(60_000); - // Production shape for the WKWebView dropped-write hazard: heavy - // variable-height rows, a slow older-page fetch, and wheel input that - // KEEPS ARRIVING through fetch resolution. Every prepend-compensation - // mechanism is a scrollTop write, and macOS WebKit can drop those writes - // while trackpad momentum owns the offset — so the contract under test is - // that the fetched page's RENDER COMMIT (the scrollHeight jump) is - // deferred until input quiesces, and that the at-rest commit then holds - // the anchored row. Chromium cannot reproduce the dropped write itself; - // it CAN prove the commit-at-rest scheduling that makes it unreachable. + // Production shape: heavy variable-height rows, a slow older-page fetch, + // and wheel input that KEEPS ARRIVING through fetch resolution. #1698 held + // the landed page until input stopped entirely; under a sustained trackpad + // gesture that turned a ~450ms page into a multi-second wait behind the + // spinner. The contract now is a bounded hold: the page's RENDER COMMIT + // (the scrollHeight jump) lands within SETTLE_HOLD_DEADLINE_MS of the + // fetch resolving even though input never quiesces, and that commit still + // holds the reader's anchored row. Chromium cannot reproduce WebKit's + // dropped-write hazard; it CAN prove the scheduling bound and the anchor. await installMockBridge(page, { deepHistoryMessageCount: 1_800, channelWindowDelayMs: 300, @@ -505,119 +505,143 @@ test.describe("list virtualization", () => { await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); await page.waitForTimeout(1_000); - // Mount mid-history rows clear of the load-older sentinel, then trip it. + // Mount mid-history rows clear of the load-older trigger, then trip it. await timeline.evaluate((element) => { element.scrollTop = 4000; }); await page.waitForTimeout(300); - // In-page observer: tracks the last wheel-input timestamp, captures the - // first at-rest anchor after input stops, and records when the prepend - // commit (scrollHeight jump) lands relative to the last input. - const tracePromise = timeline.evaluate(async (scroller) => { + // In-page observer: records when the spinner first appears (the fetch is + // in flight), when the prepend commit (scrollHeight jump) lands, how long + // input had been quiet at that moment, and the anchored row's drift + // across the commit. The anchor is re-captured on every frame before the + // commit so it reflects the reader's CURRENT position under live input. + const WHEEL_TICK_PX = 30; + const tracePromise = timeline.evaluate(async (scroller, WHEEL_TICK_PX) => { const s = scroller as HTMLElement; const baseHeight = s.scrollHeight; let lastInputTs = 0; - let sawInput = false; - let restAnchor: { id: string; top: number } | null = null; + let wheelEvents = 0; + // Wheel ticks since the last anchor sample. The commit frame is heavy + // (~100ms of prepend render), so ticks land inside it, and each tick's + // main-thread event and compositor scroll apply on independent + // schedules. The reader's own motion at the commit frame is therefore + // some whole number of ticks — up to one more than the events seen. + // Only displacement that is NOT a whole number of ticks is drift. + let wheelTicksSinceSample = 0; const onWheel = () => { lastInputTs = performance.now(); - sawInput = true; - // Input after a lull invalidates any anchor captured during it — - // the commit must be measured against the FINAL at-rest position. - restAnchor = null; + wheelEvents += 1; + wheelTicksSinceSample += 1; }; s.addEventListener("wheel", onWheel, { passive: true }); - let commit: { ts: number; gapSinceInput: number } | null = null; - let sawSpinnerDuringHold = false; - let anchorDriftAfterCommit: number | null = null; + const topRow = () => { + const scrollerTop = s.getBoundingClientRect().top; + const row = Array.from( + s.querySelectorAll("[data-message-id]"), + ).find( + (candidate) => + candidate.getBoundingClientRect().top - scrollerTop >= 0, + ); + return row?.dataset.messageId + ? { + id: row.dataset.messageId, + top: row.getBoundingClientRect().top - scrollerTop, + } + : null; + }; + let spinnerAt: number | null = null; + let anchor: { id: string; top: number } | null = null; + let commit: { + ts: number; + sinceSpinner: number; + gapSinceInput: number; + wheelEventsBefore: number; + } | null = null; + let anchorDriftAtCommit: number | null = null; const deadline = performance.now() + 8_000; while (performance.now() < deadline) { const now = performance.now(); - if (commit === null) { - if ( - document.querySelector( - '[data-testid="message-timeline-fetching-older"]', - ) !== null - ) { - sawSpinnerDuringHold = true; - } - // First frame at rest (input quiet for 60ms — shorter than the - // gate's own window, so this reading always precedes admission): - // capture the row the at-rest commit must hold. - if (restAnchor === null && sawInput && now - lastInputTs >= 60) { - const scrollerTop = s.getBoundingClientRect().top; + if ( + spinnerAt === null && + document.querySelector( + '[data-testid="message-timeline-fetching-older"]', + ) !== null + ) { + spinnerAt = now; + } + if (s.scrollHeight > baseHeight + 800) { + commit = { + ts: now, + sinceSpinner: spinnerAt === null ? -1 : now - spinnerAt, + gapSinceInput: now - lastInputTs, + wheelEventsBefore: wheelEvents, + }; + if (anchor !== null) { const row = Array.from( s.querySelectorAll("[data-message-id]"), - ).find( - (candidate) => - candidate.getBoundingClientRect().top - scrollerTop >= 0, - ); - if (row?.dataset.messageId) { - restAnchor = { - id: row.dataset.messageId, - top: row.getBoundingClientRect().top - scrollerTop, - }; + ).find((candidate) => candidate.dataset.messageId === anchor?.id); + if (row) { + const top = + row.getBoundingClientRect().top - s.getBoundingClientRect().top; + anchorDriftAtCommit = Number.POSITIVE_INFINITY; + for (let ticks = 0; ticks <= wheelTicksSinceSample + 1; ticks++) { + anchorDriftAtCommit = Math.min( + anchorDriftAtCommit, + Math.abs(top - (anchor.top - ticks * WHEEL_TICK_PX)), + ); + } + } else { + anchorDriftAtCommit = Number.POSITIVE_INFINITY; } } - if (s.scrollHeight > baseHeight + 800) { - commit = { ts: now, gapSinceInput: now - lastInputTs }; - } - } else if (restAnchor !== null) { - const anchor = restAnchor; - const scrollerTop = s.getBoundingClientRect().top; - const row = Array.from( - s.querySelectorAll("[data-message-id]"), - ).find((candidate) => candidate.dataset.messageId === anchor.id); - if (row) { - anchorDriftAfterCommit = Math.max( - anchorDriftAfterCommit ?? 0, - Math.abs( - row.getBoundingClientRect().top - scrollerTop - anchor.top, - ), - ); - } - // Watch a settle window after the commit, then finish. - if (now - commit.ts > 700) break; + break; + } + const sampled = topRow(); + if (sampled) { + anchor = sampled; + wheelTicksSinceSample = 0; } await new Promise((resolve) => requestAnimationFrame(resolve)); } s.removeEventListener("wheel", onWheel); - return { - commit, - capturedRestAnchor: restAnchor !== null, - sawSpinnerDuringHold, - anchorDriftAfterCommit, - }; - }); + return { commit, anchorDriftAtCommit, wheelEvents }; + }, WHEEL_TICK_PX); // Trip the boundary, then keep real wheel input flowing DOWN (away from // the boundary) through and well past the 300ms fetch resolution — the - // mid-gesture window in which the ungated build commits the page. + // sustained-gesture window in which #1698 withheld the page. await timeline.evaluate((element) => { element.scrollTop = 150; }); const box = await timeline.boundingBox(); if (!box) throw new Error("timeline has no bounding box"); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); - for (let burst = 0; burst < 30; burst += 1) { - await page.mouse.wheel(0, 30); + for (let burst = 0; burst < 60; burst += 1) { + await page.mouse.wheel(0, WHEEL_TICK_PX); await page.waitForTimeout(40); } const trace = await tracePromise; - // The page must eventually commit — the gate defers, never strands. + // The page committed — and while input was still flowing (the gesture + // above outlasts the fetch by ~2s), not after the reader gave up. expect(trace.commit).not.toBeNull(); - // The commit landed only after input quiesced. On the ungated build the - // deferred snapshot flushes as soon as the fetch resolves — between wheel - // bursts, a gap far below the quiet window — so this line is the red/green - // signal for the settle gate. - expect(trace.commit?.gapSinceInput ?? 0).toBeGreaterThanOrEqual(80); - // The reader saw the fetching affordance while the page was held. - expect(trace.sawSpinnerDuringHold).toBe(true); - // The at-rest commit held the anchored row (writes land at rest). - expect(trace.capturedRestAnchor).toBe(true); - expect(trace.anchorDriftAfterCommit ?? 0).toBeLessThan(5); + expect( + trace.commit?.gapSinceInput ?? Number.POSITIVE_INFINITY, + ).toBeLessThan(80); + // Bounded hold: the commit landed within the gate's deadline of the fetch + // going in flight (spinner) — 300ms network + SETTLE_HOLD_DEADLINE_MS, with + // slack for the rAF watcher. #1698's unbounded hold lands ~2.4s here. + expect(trace.commit?.sinceSpinner ?? Number.POSITIVE_INFINITY).toBeLessThan( + 300 + 1_500 + 250, + ); + // The commit held the reader's anchored row within the 5px contract even + // though it landed under live input (modulo the reader's own whole wheel + // ticks — see the trace). #2855's lost correction read 452px here. + expect(trace.anchorDriftAtCommit).not.toBeNull(); + expect(trace.anchorDriftAtCommit ?? Number.POSITIVE_INFINITY).toBeLessThan( + 5, + ); }); });