From c9a26d31f2b629f8a16c03ec4207cf63cf35aa5f Mon Sep 17 00:00:00 2001 From: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Date: Sat, 22 Aug 2026 19:31:14 -0400 Subject: [PATCH] fix(desktop): bound the older-page settle hold and stop page cascades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrollback paging felt slow even with a fast relay because a fetched page could sit unpainted for seconds, and one gesture often pulled several pages. useSettleGatedPrependMessages held a landed page on ANY wheel/scroll input with a 4s deadline, and assumed motion at hold start — so even a scroller at rest paid a full quiet window, and a reader keeping fingers on the trackpad waited seconds for a 450ms page. Track motion for the scroller's whole life (lastMotionTsRef) so an at-rest hold admits after the stable-frame count, and cut SETTLE_HOLD_DEADLINE_MS to 1500: trackpad momentum — the only phase with the dropped-write hazard — decays well inside that bound. Retiring the hold directly on wheel was tried and rejected: it drifted the anchor ~30px under live input. loadOlderViaVirtualizer refused to re-fire only while isFetchingOlder or the settle hold was set. Between the fetch resolving and the prepend committing, WebKit's scroll events at the boundary started the next page, cascading one gesture into many. Add isRenderedTimelineBehindHistoryPrepend to the refuse predicate — the same signal the spinner already uses. Tests (each fails on the previous code): - useSettleGatedPrependMessages.test.mjs: at-rest hold admits without a charged quiet window; motion before the hold still charges it. - virtualization 09: commit under 60 wheel bursts is bounded (gapSinceInput < 80ms, sinceSpinner < deadline+slack) and the anchor holds within 5px at commit. - scroll-history "one gesture pages once": emulate WebKit's at-rest scroll events and tighten the ceiling from <=4 pages to <=1. Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> --- .../features/messages/ui/MessageTimeline.tsx | 11 +- .../ui/useSettleGatedPrependMessages.test.mjs | 119 +++++++++++ .../ui/useSettleGatedPrependMessages.ts | 51 +++-- desktop/tests/e2e/scroll-history.spec.ts | 26 ++- desktop/tests/e2e/virtualization.spec.ts | 196 ++++++++++-------- 5 files changed, 290 insertions(+), 113 deletions(-) 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, + ); }); });