From aad563e1d558b8e35d14dc0810b71f03741734df Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 23:05:19 -0400 Subject: [PATCH 1/3] perf(desktop): virtualize message timeline to stop the cold-switch beachball MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel switch streamed up to 200 uncontained MessageRows (each with synchronous shiki markdown), then scrollToBottom("auto") forced a full-document scrollHeight read-then-write reflow before paint over every row — the macOS beachball Will reported on v0.3.25. Windows the main timeline on @tanstack/react-virtual. The day-grouped section tree is flattened to a typed TimelineItem[] stream plus a messageId->itemIndex map from one walk (cannot drift), and every DOM-querySelector scroll path (deep-link, search-jump, jump-to-unread, scrollToBottom, load-older anchor) is re-pathed onto the index model so windowing does not silently break jumps to off-screen rows. Scroll convergence is split: @tanstack/react-virtual owns offset convergence (its rAF loop re-aims getOffsetForIndex as rows mount and measure); a pure reducer owns only staleness re-resolution and termination — re-resolving the target's index by id each frame so a concurrent prepend/delete cannot strand the loop on a stale index, and terminating when the target is deleted or a 32-frame cap is hit. The breaking math lives in lib/ under the .mjs suite. The thread reply list stays content-visibility:auto rather than virtualized — it is bounded, unpaginated, ungrouped, and shares the scroll hook, so virtualizing it would force a second index re-path and a head/prologue split for no beachball gain. Phase-2 route-chunk preload warms the agents/channel/lazy-view chunks on idle to clear the Agents-menu first-visit stall. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/app/AppShell.tsx | 16 + desktop/src/app/routes/agents.tsx | 12 +- .../src/app/routes/channels.$channelId.tsx | 11 +- .../channels/ui/ChannelScreenLazyViews.ts | 15 +- .../messages/lib/scrollConvergence.test.mjs | 160 +++++++ .../messages/lib/scrollConvergence.ts | 103 +++++ .../messages/lib/timelineItems.test.mjs | 164 +++++++ .../features/messages/lib/timelineItems.ts | 91 ++++ .../messages/ui/MessageThreadPanel.tsx | 2 +- .../features/messages/ui/MessageTimeline.tsx | 52 ++- .../messages/ui/TimelineMessageList.tsx | 422 +++++++++++------- .../ui/useConvergentScrollToMessage.ts | 166 +++++++ .../messages/ui/useLoadOlderOnScroll.ts | 65 +++ .../messages/ui/useTimelineScrollManager.ts | 90 +++- 14 files changed, 1188 insertions(+), 181 deletions(-) create mode 100644 desktop/src/features/messages/lib/scrollConvergence.test.mjs create mode 100644 desktop/src/features/messages/lib/scrollConvergence.ts create mode 100644 desktop/src/features/messages/lib/timelineItems.test.mjs create mode 100644 desktop/src/features/messages/lib/timelineItems.ts create mode 100644 desktop/src/features/messages/ui/useConvergentScrollToMessage.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index cca89f471..a6ab4bda5 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -76,6 +76,9 @@ import { relayClient } from "@/shared/api/relayClient"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal"; import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; +import { preloadAgentsScreen } from "@/app/routes/agents"; +import { preloadChannelRouteScreen } from "@/app/routes/channels.$channelId"; +import { preloadChannelViews } from "@/features/channels/ui/ChannelScreenLazyViews"; import { joinChannel } from "@/shared/api/tauri"; import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; @@ -540,6 +543,19 @@ export function AppShell() { }; }, []); + // Warm the lazy route chunks (channel timeline, forum, agents) once the shell + // is idle, so the FIRST main-nav transition doesn't stall on a cold chunk + // fetch+parse. `startupReady` is the existing idle-or-timeout gate; the chunk + // imports dedupe, so racing an actual navigation is harmless. + React.useEffect(() => { + if (!startupReady) { + return; + } + preloadChannelRouteScreen(); + preloadChannelViews(); + preloadAgentsScreen(); + }, [startupReady]); + React.useEffect(() => { const numericCount = highPriorityUnreadChannelIds.size + homeBadgeCountExcludingHighPriority; diff --git a/desktop/src/app/routes/agents.tsx b/desktop/src/app/routes/agents.tsx index c70ff7eb9..2cc1afa0c 100644 --- a/desktop/src/app/routes/agents.tsx +++ b/desktop/src/app/routes/agents.tsx @@ -3,11 +3,21 @@ import { createFileRoute } from "@tanstack/react-router"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; +// The chunk import is hoisted so it can be triggered eagerly (route preload) +// as well as lazily on render — calling it twice is a no-op, the module loader +// dedupes and caches the in-flight promise. +const importAgentsScreen = () => import("@/features/agents/ui/AgentsScreen"); + const AgentsScreen = React.lazy(async () => { - const module = await import("@/features/agents/ui/AgentsScreen"); + const module = await importAgentsScreen(); return { default: module.AgentsScreen }; }); +/** Warms the AgentsScreen route chunk so first navigation doesn't stall. */ +export function preloadAgentsScreen(): void { + void importAgentsScreen(); +} + export const Route = createFileRoute("/agents")({ component: AgentsRouteComponent, }); diff --git a/desktop/src/app/routes/channels.$channelId.tsx b/desktop/src/app/routes/channels.$channelId.tsx index 3c64f3ec9..61b64d0f3 100644 --- a/desktop/src/app/routes/channels.$channelId.tsx +++ b/desktop/src/app/routes/channels.$channelId.tsx @@ -38,11 +38,20 @@ export const Route = createFileRoute("/channels/$channelId")({ component: ChannelRouteComponent, }); +// Hoisted so the chunk can be warmed eagerly (route preload) as well as loaded +// lazily on render; the loader dedupes repeat calls. +const importChannelRouteScreen = () => import("./ChannelRouteScreen"); + const ChannelRouteScreen = React.lazy(async () => { - const module = await import("./ChannelRouteScreen"); + const module = await importChannelRouteScreen(); return { default: module.ChannelRouteScreen }; }); +/** Warms the ChannelRouteScreen chunk so first channel open doesn't stall. */ +export function preloadChannelRouteScreen(): void { + void importChannelRouteScreen(); +} + function ChannelRouteComponent() { const { channelId } = Route.useParams(); const search = Route.useSearch(); diff --git a/desktop/src/features/channels/ui/ChannelScreenLazyViews.ts b/desktop/src/features/channels/ui/ChannelScreenLazyViews.ts index 4e4fc0f2a..8784fc345 100644 --- a/desktop/src/features/channels/ui/ChannelScreenLazyViews.ts +++ b/desktop/src/features/channels/ui/ChannelScreenLazyViews.ts @@ -1,11 +1,22 @@ import * as React from "react"; +// Hoisted chunk imports so each view can be warmed eagerly (route preload) as +// well as loaded lazily on render; the module loader dedupes repeat calls. +const importChannelPane = () => import("@/features/channels/ui/ChannelPane"); +const importForumView = () => import("@/features/forum/ui/ForumView"); + export const ChannelPane = React.lazy(async () => { - const module = await import("@/features/channels/ui/ChannelPane"); + const module = await importChannelPane(); return { default: module.ChannelPane }; }); export const ForumView = React.lazy(async () => { - const module = await import("@/features/forum/ui/ForumView"); + const module = await importForumView(); return { default: module.ForumView }; }); + +/** Warms the channel/forum view chunks so first open doesn't stall. */ +export function preloadChannelViews(): void { + void importChannelPane(); + void importForumView(); +} diff --git a/desktop/src/features/messages/lib/scrollConvergence.test.mjs b/desktop/src/features/messages/lib/scrollConvergence.test.mjs new file mode 100644 index 000000000..d85d96146 --- /dev/null +++ b/desktop/src/features/messages/lib/scrollConvergence.test.mjs @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { CONVERGENCE_FRAME_CAP, convergenceStep } from "./scrollConvergence.ts"; + +function input(overrides) { + return { + targetMessageId: "target", + indexByMessageId: new Map([["target", 100]]), + lastIssuedIndex: null, + librarySettled: false, + framesUsed: 0, + ...overrides, + }; +} + +// --- re-aim / staleness guard ------------------------------------------------ + +test("convergenceStep: first frame aims at the resolved index, not yet done", () => { + const step = convergenceStep(input({ lastIssuedIndex: null })); + assert.equal(step.nextIndex, 100); + assert.equal(step.done, false); + assert.equal(step.converged, false); +}); + +test("convergenceStep: re-resolves a shifted index from the map each frame", () => { + // A prepend shifted the target from 100 to 105. The library is still chasing + // the old index (lastIssuedIndex 100); the reducer must aim at the NEW index + // so the adapter re-issues scrollToIndex(105). This is the staleness guard. + const step = convergenceStep( + input({ + indexByMessageId: new Map([["target", 105]]), + lastIssuedIndex: 100, + }), + ); + assert.equal(step.nextIndex, 105); + assert.equal(step.done, false); + assert.equal(step.converged, false); +}); + +test("convergenceStep: target removed mid-settle stops with converged=false", () => { + // Target deleted from the map while the loop was chasing it. Terminate so the + // adapter clears the highlight instead of chasing a vanished row. + const step = convergenceStep( + input({ + indexByMessageId: new Map(), // target gone + lastIssuedIndex: 100, + framesUsed: 3, + }), + ); + assert.equal(step.nextIndex, null); + assert.equal(step.done, true); + assert.equal(step.converged, false); +}); + +// --- settle ------------------------------------------------------------------ + +test("convergenceStep: library settled while aiming at current index converges", () => { + const step = convergenceStep( + input({ lastIssuedIndex: 100, librarySettled: true }), + ); + assert.equal(step.nextIndex, 100); + assert.equal(step.done, true); + assert.equal(step.converged, true); +}); + +test("convergenceStep: a settle reported WHILE re-aiming is ignored", () => { + // The index just moved (105) but the library reports settled — that settle is + // on the OLD index (100), so it must NOT count as convergence. The reducer + // keeps going and aims at the new index. + const step = convergenceStep( + input({ + indexByMessageId: new Map([["target", 105]]), + lastIssuedIndex: 100, + librarySettled: true, + }), + ); + assert.equal(step.nextIndex, 105); + assert.equal(step.done, false); + assert.equal(step.converged, false); +}); + +test("convergenceStep: aiming at current but not yet settled keeps waiting", () => { + // Library is chasing the right index but its offset hasn't stabilized. The + // reducer returns the same index (so the adapter re-issues NOTHING — issuing + // would reset the library's stableFrames and prevent settling) and waits. + const step = convergenceStep( + input({ lastIssuedIndex: 100, librarySettled: false }), + ); + assert.equal(step.nextIndex, 100); + assert.equal(step.done, false); + assert.equal(step.converged, false); +}); + +// --- frame cap --------------------------------------------------------------- + +test("convergenceStep: terminates at the frame cap without converging", () => { + // A row that never settles (librarySettled stays false) must still stop at the + // cap rather than spin forever. + const step = convergenceStep( + input({ + lastIssuedIndex: 100, + librarySettled: false, + framesUsed: CONVERGENCE_FRAME_CAP - 1, + }), + ); + assert.equal(step.done, true); + assert.equal(step.converged, false); + assert.equal(step.nextIndex, 100); +}); + +test("convergenceStep: frame cap bounds a perpetually shifting target", () => { + // Drive the loop the way the adapter would: the target index moves every + // frame, so the library never settles. The loop must terminate at the cap. + let lastIssuedIndex = null; + let framesUsed = 0; + let done = false; + let converged = true; + + while (framesUsed < CONVERGENCE_FRAME_CAP + 5) { + const movingIndex = 100 + framesUsed; // shifts every frame + const step = convergenceStep( + input({ + indexByMessageId: new Map([["target", movingIndex]]), + lastIssuedIndex, + librarySettled: false, + framesUsed, + }), + ); + lastIssuedIndex = step.nextIndex; + framesUsed += 1; + if (step.done) { + done = step.done; + converged = step.converged; + break; + } + } + + assert.equal(done, true); + assert.equal(converged, false); + assert.ok(framesUsed <= CONVERGENCE_FRAME_CAP); +}); + +test("convergenceStep: converges once a re-aimed index then settles", () => { + // Realistic flow: frame 0 aims (lastIssued null -> 100), frame 1 the library + // is chasing 100 and reports settled -> converged. + const aim = convergenceStep(input({ lastIssuedIndex: null })); + assert.equal(aim.nextIndex, 100); + assert.equal(aim.done, false); + + const settle = convergenceStep( + input({ + lastIssuedIndex: aim.nextIndex, + librarySettled: true, + framesUsed: 1, + }), + ); + assert.equal(settle.done, true); + assert.equal(settle.converged, true); +}); diff --git a/desktop/src/features/messages/lib/scrollConvergence.ts b/desktop/src/features/messages/lib/scrollConvergence.ts new file mode 100644 index 000000000..9ad86e27e --- /dev/null +++ b/desktop/src/features/messages/lib/scrollConvergence.ts @@ -0,0 +1,103 @@ +/** + * Pure staleness + termination decision for scrolling a virtualized timeline to + * a message that may be far off-screen. + * + * @tanstack/react-virtual already owns the OFFSET convergence: a single + * `scrollToIndex(index)` captures that index in `scrollState`, and its internal + * `reconcileScroll` rAF loop re-runs `getOffsetForIndex(index)` every frame — + * re-aiming as off-screen rows mount and `measureElement` corrects their + * heights — until the offset is stable (or a 5s safety valve fires). We do NOT + * recompute offsets; duplicating `getOffsetForIndex` against the library's own + * `measurementsCache`/`scrollMargin`/`scrollPadding` would only drift. + * + * What the library does NOT do: it chases the INDEX captured at call time, with + * no concept of a message id. If the data shifts mid-settle — a prepend or a + * delete above the target — the captured index now points at the wrong row and + * the library happily settles on it. This reducer owns exactly that gap: each + * frame it re-resolves the target's CURRENT index from the live map and decides + * whether the adapter must re-aim the library, let it settle, or stop. + * + * Two correctness properties this enforces and the `.mjs` suite gates: + * - The target index is re-resolved by id every frame (never frozen), so a + * concurrent prepend/delete that shifts the target re-aims the library at the + * new index instead of stranding it on the old one. + * - If the target id leaves the data mid-settle (deleted), the loop terminates + * with `converged: false` rather than chasing a vanished row to the cap. + */ + +/** Where a scroll target should land in the viewport. Mirrors the library's align. */ +export type ConvergenceAlign = "start" | "center" | "end"; + +export type ConvergenceInput = { + /** Id of the message to settle on — re-resolved against the map each frame. */ + targetMessageId: string; + /** Live message-id -> item-index map; re-read every frame (staleness guard). */ + indexByMessageId: Map; + /** + * Index the library is currently chasing (the last index the adapter issued + * via `scrollToIndex`), or `null` before the first issue. Lets the reducer + * tell a re-aim (index moved) from a steady settle (index unchanged). + */ + lastIssuedIndex: number | null; + /** + * Whether the library reports its scroll has settled this frame + * (`virtualizer.scrollState === null`). Only meaningful once the library is + * chasing the CURRENT index; a settle reported while re-aiming is ignored. + */ + librarySettled: boolean; + /** Frames already spent in the loop (the adapter increments per rAF). */ + framesUsed: number; +}; + +export type ConvergenceDecision = { + /** + * Index the adapter should be aiming the library at, or `null` when the + * target is gone. The adapter only re-issues `scrollToIndex` when this differs + * from `lastIssuedIndex`, so a steady settle issues no redundant scroll (which + * would reset the library's `stableFrames` and prevent it from ever settling). + */ + nextIndex: number | null; + /** True once the loop must stop (settled, target gone, or frame cap hit). */ + done: boolean; + /** True only when the loop stopped because the target row actually settled. */ + converged: boolean; +}; + +/** + * Hard cap on frames so a perpetually re-measuring row, or a target whose index + * keeps shifting, can't spin the loop forever. The library has its own 5s valve; + * this is the adapter-side bound expressed in frames for deterministic testing. + */ +export const CONVERGENCE_FRAME_CAP = 32; + +/** + * One frame of the convergence loop. Pure: given the live map and the library's + * settle state, decides the index to aim at and whether to stop. + */ +export function convergenceStep(input: ConvergenceInput): ConvergenceDecision { + const currentIndex = input.indexByMessageId.get(input.targetMessageId); + + // Target left the data mid-settle (deleted) — stop without converging so the + // adapter clears the highlight instead of chasing a vanished row. + if (currentIndex === undefined) { + return { nextIndex: null, done: true, converged: false }; + } + + const aimingAtCurrent = input.lastIssuedIndex === currentIndex; + + // The library only settles meaningfully once it is chasing the CURRENT index. + // A settle reported while we are still re-aiming (index just moved) is stale. + if (aimingAtCurrent && input.librarySettled) { + return { nextIndex: currentIndex, done: true, converged: true }; + } + + // Frame cap: accept the best index we have rather than spin forever on a row + // whose height never settles or a target whose index keeps shifting. + if (input.framesUsed + 1 >= CONVERGENCE_FRAME_CAP) { + return { nextIndex: currentIndex, done: true, converged: false }; + } + + // Either the index moved (adapter will re-issue scrollToIndex) or the library + // is still settling on the current index (adapter issues nothing, just waits). + return { nextIndex: currentIndex, done: false, converged: false }; +} diff --git a/desktop/src/features/messages/lib/timelineItems.test.mjs b/desktop/src/features/messages/lib/timelineItems.test.mjs new file mode 100644 index 000000000..04f8738ec --- /dev/null +++ b/desktop/src/features/messages/lib/timelineItems.test.mjs @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; +import { buildTimelineItems, getTimelineItemKey } from "./timelineItems.ts"; + +function dayAt(year, month, day, hour = 12) { + return Math.floor( + new Date(year, month - 1, day, hour, 0, 0).getTime() / 1_000, + ); +} + +function message(overrides) { + return { + id: "m", + renderKey: undefined, + createdAt: dayAt(2026, 6, 14), + pubkey: "author", + parentId: null, + rootId: null, + depth: 0, + kind: 9, + tags: [], + ...overrides, + }; +} + +// The builder takes MainTimelineEntry[] (post top-level filter); summary is +// irrelevant to item/divider placement, so null is fine here. +function entry(overrides) { + return { message: message(overrides), summary: null }; +} + +function kinds(items) { + return items.map((item) => item.kind); +} + +// --- divider placement ------------------------------------------------------- + +test("buildTimelineItems: 3-day channel with unread mid-day-2 places dividers by index", () => { + const entries = [ + entry({ id: "d1a", createdAt: dayAt(2026, 6, 12) }), + entry({ id: "d1b", createdAt: dayAt(2026, 6, 12, 13) }), + entry({ id: "d2a", createdAt: dayAt(2026, 6, 13) }), + entry({ id: "d2b", createdAt: dayAt(2026, 6, 13, 13) }), // first unread + entry({ id: "d2c", createdAt: dayAt(2026, 6, 13, 14) }), + entry({ id: "d3a", createdAt: dayAt(2026, 6, 14) }), + ]; + + const { items } = buildTimelineItems(entries, "d2b"); + + assert.deepEqual(kinds(items), [ + "day-divider", // day 1 + "message", // d1a + "message", // d1b + "day-divider", // day 2 + "message", // d2a + "unread-divider", // above d2b + "message", // d2b + "message", // d2c + "day-divider", // day 3 + "message", // d3a + ]); +}); + +test("buildTimelineItems: unread divider suppressed when first unread is the first entry", () => { + const entries = [ + entry({ id: "a", createdAt: dayAt(2026, 6, 14) }), + entry({ id: "b", createdAt: dayAt(2026, 6, 14, 13) }), + ]; + // firstUnread === index 0 — nothing above it, so no divider. + const { items } = buildTimelineItems(entries, "a"); + assert.equal(items.filter((i) => i.kind === "unread-divider").length, 0); +}); + +test("buildTimelineItems: system messages flatten to a 'system' item", () => { + const entries = [ + entry({ id: "a", createdAt: dayAt(2026, 6, 14) }), + entry({ + id: "sys", + kind: KIND_SYSTEM_MESSAGE, + createdAt: dayAt(2026, 6, 14, 13), + }), + ]; + const { items } = buildTimelineItems(entries, null); + assert.deepEqual(kinds(items), ["day-divider", "message", "system"]); +}); + +test("buildTimelineItems: empty entries produce no items and an empty map", () => { + const { items, indexByMessageId } = buildTimelineItems([], null); + assert.equal(items.length, 0); + assert.equal(indexByMessageId.size, 0); +}); + +// --- index map correctness --------------------------------------------------- + +test("buildTimelineItems: map points each message id at its flattened item index", () => { + const entries = [ + entry({ id: "d1", createdAt: dayAt(2026, 6, 12) }), + entry({ id: "d2", createdAt: dayAt(2026, 6, 13) }), + ]; + const { items, indexByMessageId } = buildTimelineItems(entries, null); + + // dividers occupy indices 0 and 2; messages land at 1 and 3. + assert.equal(indexByMessageId.get("d1"), 1); + assert.equal(indexByMessageId.get("d2"), 3); + assert.equal(items[1].entry.message.id, "d1"); + assert.equal(items[3].entry.message.id, "d2"); +}); + +test("buildTimelineItems: appending a new message keeps prior indices stable", () => { + const base = [entry({ id: "a", createdAt: dayAt(2026, 6, 14) })]; + const before = buildTimelineItems(base, null).indexByMessageId; + + const appended = [ + ...base, + entry({ id: "b", createdAt: dayAt(2026, 6, 14, 13) }), + ]; + const after = buildTimelineItems(appended, null).indexByMessageId; + + assert.equal(after.get("a"), before.get("a")); + assert.equal(after.get("b"), 2); +}); + +test("buildTimelineItems: prepending an older-day message shifts later indices", () => { + const original = [entry({ id: "b", createdAt: dayAt(2026, 6, 14) })]; + const beforeIdx = buildTimelineItems(original, null).indexByMessageId.get( + "b", + ); + + // Prepend a message on an earlier day → adds its own day-divider + message, + // pushing "b" (now on a new day boundary too) further down. + const prepended = [ + entry({ id: "a", createdAt: dayAt(2026, 6, 13) }), + entry({ id: "b", createdAt: dayAt(2026, 6, 14) }), + ]; + const afterIdx = buildTimelineItems(prepended, null).indexByMessageId.get( + "b", + ); + assert.ok(afterIdx > beforeIdx); +}); + +test("buildTimelineItems: deleting a message drops it from the map", () => { + const entries = [ + entry({ id: "a", createdAt: dayAt(2026, 6, 14) }), + entry({ id: "b", createdAt: dayAt(2026, 6, 14, 13) }), + ]; + const afterDelete = buildTimelineItems( + entries.filter((e) => e.message.id !== "a"), + null, + ).indexByMessageId; + assert.equal(afterDelete.has("a"), false); + assert.equal(afterDelete.get("b"), 1); +}); + +test("getTimelineItemKey: keys are unique across the stream", () => { + const entries = [ + entry({ id: "a", createdAt: dayAt(2026, 6, 12) }), + entry({ id: "b", createdAt: dayAt(2026, 6, 13) }), + ]; + const { items } = buildTimelineItems(entries, "b"); + const keys = items.map(getTimelineItemKey); + assert.equal(new Set(keys).size, keys.length); +}); diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts new file mode 100644 index 000000000..8ef9a20ec --- /dev/null +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -0,0 +1,91 @@ +/** + * Flattens the heterogeneous day-grouped timeline tree into a flat + * discriminated-union item stream that a virtualizer can window over, and + * builds the `messageId -> itemIndex` map every DOM-query scroll path now + * resolves against instead of `querySelector`. + * + * Kept pure (no React, no DOM) so it is covered by the lib-level `*.test.mjs` + * suite. The list and the index map are produced together from the SAME walk, + * so they can never drift: a stale map would scroll deep-links to the wrong + * row, the exact failure virtualization risks. + */ + +import { + buildDayGroupBoundaries, + type DayGroupBoundary, +} from "@/features/messages/lib/timelineSnapshot"; +import { shouldRenderUnreadDivider } from "@/features/messages/lib/threadPanel"; +import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; +import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; + +/** + * One renderable row in the flattened timeline. Dividers carry no message and + * never appear in the index map; the three message-bearing kinds do. + */ +export type TimelineItem = + // `headingTimestamp` (not a prebaked label) so the render still resolves + // "Today"/"Yesterday" relative to the current clock, not to build time. + | { kind: "day-divider"; key: string; headingTimestamp: number } + | { kind: "unread-divider"; key: string } + | { kind: "system"; key: string; entry: MainTimelineEntry } + | { kind: "message"; key: string; entry: MainTimelineEntry }; + +export type TimelineItemsResult = { + items: TimelineItem[]; + /** Maps a top-level message id to its index in `items`. */ + indexByMessageId: Map; +}; + +/** Stable per-item key, unique across the flattened stream. */ +export function getTimelineItemKey(item: TimelineItem): string { + return item.key; +} + +function entryRenderKey(entry: MainTimelineEntry): string { + return entry.message.renderKey ?? entry.message.id; +} + +/** + * Walks the (already top-level-filtered) entries once, emitting a day-divider + * at each calendar-day boundary and an unread-divider above the first unread + * message, then the message/system row itself. The index map records where + * each message landed in the flat stream so scroll targets resolve in O(1) + * without touching the DOM. + */ +export function buildTimelineItems( + entries: MainTimelineEntry[], + firstUnreadMessageId: string | null, +): TimelineItemsResult { + const items: TimelineItem[] = []; + const indexByMessageId = new Map(); + + const dayStartIndices = new Set( + buildDayGroupBoundaries(entries.map((entry) => entry.message)).map( + (boundary: DayGroupBoundary) => boundary.startIndex, + ), + ); + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const { message } = entry; + const renderKey = entryRenderKey(entry); + + if (dayStartIndices.has(i)) { + items.push({ + kind: "day-divider", + key: `day-${message.createdAt}-${renderKey}`, + headingTimestamp: message.createdAt, + }); + } + + if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) { + items.push({ kind: "unread-divider", key: `unread-${renderKey}` }); + } + + const kind = message.kind === KIND_SYSTEM_MESSAGE ? "system" : "message"; + indexByMessageId.set(message.id, items.length); + items.push({ kind, key: renderKey, entry }); + } + + return { items, indexByMessageId }; +} diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index c8d8f99ea..a905cd9cf 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -437,7 +437,7 @@ export function MessageThreadPanel({ return (
(null); + // The virtualizer instance and the flattened item stream are owned by the + // child TimelineMessageList (which mounts the VirtualizedList) and reported + // up here so the scroll manager can resolve scroll targets by index. The + // virtualizer reaches us via a ref (its identity is stable across renders, + // but it arrives after first paint); the item stream + id->index map arrive + // as state so a rebuild re-runs the scroll manager's index-model paths. + const virtualizerRef = React.useRef(null); + const handleVirtualizer = React.useCallback((instance: ListVirtualizer) => { + virtualizerRef.current = instance; + }, []); + const getVirtualizer = React.useCallback(() => virtualizerRef.current, []); + const [timelineItems, setTimelineItems] = + React.useState(null); + const handleItems = React.useCallback((result: TimelineItemsResult) => { + setTimelineItems(result); + }, []); + const virtualizerOption = React.useMemo( + () => + timelineItems + ? { + getVirtualizer, + indexByMessageId: timelineItems.indexByMessageId, + itemCount: timelineItems.items.length, + } + : null, + [getVirtualizer, timelineItems], + ); + // Gate the heavy timeline render (each row runs a synchronous // react-markdown parse) behind React concurrency. `useDeferredValue` lets the // commit that rebuilds the message list yield to higher-priority work, so the @@ -220,6 +250,7 @@ const MessageTimelineBase = React.forwardRef< onTargetReached, scrollContainerRef, targetMessageId, + virtualizer: virtualizerOption, }); React.useImperativeHandle( @@ -263,9 +294,10 @@ const MessageTimelineBase = React.forwardRef< } }, [firstUnreadMessageId, scrollToMessage]); - // Scroll to the active search match when it changes. + // Scroll to the active search match when it changes. `scrollToMessage` + // resolves the target through the virtualizer's index model (the row may be + // windowed out of the DOM), falling back to a DOM query when not virtualized. const prevSearchActiveRef = React.useRef(null); - // biome-ignore lint/correctness/useExhaustiveDependencies: scrollContainerRef is a stable React ref React.useEffect(() => { if (showTimelineSkeleton) return; if ( @@ -277,16 +309,8 @@ const MessageTimelineBase = React.forwardRef< } prevSearchActiveRef.current = searchActiveMessageId; - const container = scrollContainerRef.current; - if (!container) return; - - const el = container.querySelector( - `[data-message-id="${searchActiveMessageId}"]`, - ); - if (el) { - el.scrollIntoView({ block: "center", behavior: "smooth" }); - } - }, [searchActiveMessageId, showTimelineSkeleton]); + scrollToMessage(searchActiveMessageId); + }, [scrollToMessage, searchActiveMessageId, showTimelineSkeleton]); useLoadOlderOnScroll({ fetchOlder, @@ -295,6 +319,7 @@ const MessageTimelineBase = React.forwardRef< restoreScrollPosition, scrollContainerRef, sentinelRef: topSentinelRef, + virtualizer: virtualizerOption, }); const timelineSkeletonRows = useTimelineSkeletonRows({ @@ -521,6 +546,9 @@ const MessageTimelineBase = React.forwardRef< searchQuery={searchQuery} threadUnreadCounts={threadUnreadCounts} unfollowThreadById={unfollowThreadById} + scrollContainerRef={scrollContainerRef} + onItems={handleItems} + onVirtualizer={handleVirtualizer} />
) : null} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 9f66b161e..4e91a01a8 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -2,19 +2,25 @@ import * as React from "react"; import { formatDayHeading } from "@/features/messages/lib/dateFormatters"; import { - buildMainTimelineEntries, - shouldRenderUnreadDivider, -} from "@/features/messages/lib/threadPanel"; + buildTimelineItems, + getTimelineItemKey, + type TimelineItem, + type TimelineItemsResult, +} from "@/features/messages/lib/timelineItems"; +import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; +import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import { buildVideoReviewCommentsByRootId, buildVideoReviewContextForMessage, } from "@/features/messages/lib/videoReviewContext"; -import { buildDayGroupBoundaries } from "@/features/messages/lib/timelineSnapshot"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; -import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; +import { + type ListVirtualizer, + VirtualizedList, +} from "@/shared/ui/VirtualizedList"; import { DayDivider } from "./DayDivider"; import { MessageRow } from "./MessageRow"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; @@ -63,6 +69,13 @@ type TimelineMessageListProps = { searchQuery?: string; /** Per-thread unread counts keyed by thread root id. */ threadUnreadCounts?: ReadonlyMap; + /** Caller-owned scroll container the virtualizer measures and scrolls. */ + scrollContainerRef: React.RefObject; + /** Receives the flattened item stream + index map so the scroll manager can + * resolve scroll targets by id. Called whenever the stream is rebuilt. */ + onItems?: (result: TimelineItemsResult) => void; + /** Receives the virtualizer instance for index-model scroll paths. */ + onVirtualizer?: (virtualizer: ListVirtualizer) => void; }; export const TimelineMessageList = React.memo(function TimelineMessageList({ @@ -90,6 +103,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ searchQuery, threadUnreadCounts, unfollowThreadById, + scrollContainerRef, + onItems, + onVirtualizer, }: TimelineMessageListProps) { const entries = React.useMemo( () => buildMainTimelineEntries(messages), @@ -137,161 +153,259 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ profiles, reviewCommentsByRootId, ]); - const dayGroups: Array<{ - key: string; - label: string; - elements: React.ReactNode[]; - }> = []; - let currentDayGroup: (typeof dayGroups)[number] | null = null; - // Day-divider decision delegated to a pure, lib-tested helper: a new group - // starts at index 0 and whenever a message falls on a different calendar day - // than the one before it. We index the boundary start positions so the render - // loop below stays a straight walk while the grouping logic lives in `lib/`. - const dayGroupStartIndices = new Set( - buildDayGroupBoundaries(entries.map((entry) => entry.message)).map( - (boundary) => boundary.startIndex, - ), + // The flattened item stream and its messageId -> itemIndex map are produced + // together from ONE memo, keyed on the entries and the unread boundary (the + // unread divider is its own item, so it shifts indices). A separate memo with + // diverging deps would let the map go stale and scroll deep-links to the wrong + // row — the exact failure virtualization risks. + const itemsResult = React.useMemo( + () => buildTimelineItems(entries, firstUnreadMessageId), + [entries, firstUnreadMessageId], ); - for (let i = 0; i < entries.length; i++) { - const { message, summary } = entries[i]; - const messageRenderKey = message.renderKey ?? message.id; + React.useEffect(() => { + onItems?.(itemsResult); + }, [itemsResult, onItems]); - if (dayGroupStartIndices.has(i)) { - currentDayGroup = { - key: `day-${message.createdAt}`, - label: formatDayHeading(message.createdAt), - elements: [], - }; - dayGroups.push(currentDayGroup); - } + const renderItem = React.useCallback( + (item: TimelineItem) => { + switch (item.kind) { + case "day-divider": + // Heading is resolved at render time (not baked into the item) so + // "Today"/"Yesterday" track the wall clock, not build time. + return ; + case "unread-divider": + return ; + case "system": + return ( + + ); + case "message": + return ( + + ); + } + }, + [ + agentPubkeys, + channelId, + currentPubkey, + followThreadById, + highlightedMessageId, + isFollowingThreadById, + messageFooters, + onDelete, + onEdit, + onMarkUnread, + onReply, + onToggleReaction, + profiles, + searchActiveMessageId, + searchMatchingMessageIds, + searchQuery, + threadUnreadCounts, + unfollowThreadById, + videoReviewContextById, + ], + ); - // The unread "New" divider only marks a read/unread boundary when there is - // a message above the first unread. When the first unread is the first - // rendered top-level entry (fresh/never-read channel), there is nothing - // above to separate from, so it is suppressed. - if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) { - currentDayGroup?.elements.push( - , - ); - } + return ( + + ); +}); + +function SystemRow({ + currentPubkey, + entry, + footer, + onToggleReaction, + profiles, +}: { + currentPubkey?: string; + entry: MainTimelineEntry; + footer: React.ReactNode; + onToggleReaction?: TimelineMessageListProps["onToggleReaction"]; + profiles?: UserProfileLookup; +}) { + return ( +
+ + {footer} +
+ ); +} - if (message.kind === KIND_SYSTEM_MESSAGE) { - const footer = messageFooters?.[message.id] ?? null; - currentDayGroup?.elements.push( -
- - {footer} -
, - ); - } else if (summary && onReply) { - const footer = messageFooters?.[message.id] ?? null; - const isHighlighted = message.id === highlightedMessageId; - currentDayGroup?.elements.push( -
- followThreadById(message.id) : undefined - } - onMarkUnread={onMarkUnread} - onToggleReaction={onToggleReaction} - onReply={onReply} - onUnfollowThread={ - unfollowThreadById - ? () => unfollowThreadById(message.id) - : undefined - } - profiles={profiles} - showDepthGuides={false} - videoReviewContext={videoReviewContextById.get(message.id)} - /> - - {footer} -
, - ); - } else { - const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false; - const isSearchActive = message.id === searchActiveMessageId; - const footer = messageFooters?.[message.id] ?? null; +type MessageRowItemProps = Pick< + TimelineMessageListProps, + | "agentPubkeys" + | "channelId" + | "currentPubkey" + | "followThreadById" + | "highlightedMessageId" + | "isFollowingThreadById" + | "onDelete" + | "onEdit" + | "onMarkUnread" + | "onReply" + | "onToggleReaction" + | "profiles" + | "searchActiveMessageId" + | "searchMatchingMessageIds" + | "searchQuery" + | "threadUnreadCounts" + | "unfollowThreadById" +> & { + entry: MainTimelineEntry; + footer: React.ReactNode; + videoReviewContext: ReturnType; +}; - currentDayGroup?.elements.push( -
- - {footer} -
, - ); - } +function MessageRowItem({ + agentPubkeys, + channelId, + currentPubkey, + entry, + followThreadById, + footer, + highlightedMessageId, + isFollowingThreadById, + onDelete, + onEdit, + onMarkUnread, + onReply, + onToggleReaction, + profiles, + searchActiveMessageId, + searchMatchingMessageIds, + searchQuery, + threadUnreadCounts, + unfollowThreadById, + videoReviewContext, +}: MessageRowItemProps) { + const { message, summary } = entry; + const canDelete = + onDelete && currentPubkey && message.pubkey === currentPubkey + ? onDelete + : undefined; + const canEdit = + onEdit && currentPubkey && message.pubkey === currentPubkey + ? onEdit + : undefined; + + if (summary && onReply) { + const isHighlighted = message.id === highlightedMessageId; + return ( +
+ followThreadById(message.id) : undefined + } + onMarkUnread={onMarkUnread} + onToggleReaction={onToggleReaction} + onReply={onReply} + onUnfollowThread={ + unfollowThreadById + ? () => unfollowThreadById(message.id) + : undefined + } + profiles={profiles} + showDepthGuides={false} + videoReviewContext={videoReviewContext} + /> + + {footer} +
+ ); } - return dayGroups.map((group) => ( -
- - {group.elements} -
- )); -}); + const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false; + const isSearchActive = message.id === searchActiveMessageId; + + return ( +
+ + {footer} +
+ ); +} diff --git a/desktop/src/features/messages/ui/useConvergentScrollToMessage.ts b/desktop/src/features/messages/ui/useConvergentScrollToMessage.ts new file mode 100644 index 000000000..084cc25a2 --- /dev/null +++ b/desktop/src/features/messages/ui/useConvergentScrollToMessage.ts @@ -0,0 +1,166 @@ +import * as React from "react"; + +import { + type ConvergenceAlign, + convergenceStep, +} from "@/features/messages/lib/scrollConvergence"; +import type { ListVirtualizer } from "@/shared/ui/VirtualizedList"; + +/** Offset (px) within which the library is considered to have reached the target. */ +const SETTLE_TOLERANCE_PX = 2; + +type ConvergentScrollOptions = { + /** Live message-id -> item-index map, rebuilt with the flattened item stream. */ + indexByMessageId: Map; + /** Where the target should land in the viewport. */ + align: ConvergenceAlign; + /** Fired on the settled frame once the target row has converged. */ + onConverged?: (messageId: string) => void; + /** Fired when the loop stops without converging (target deleted, or frame cap). */ + onAbandoned?: (messageId: string) => void; +}; + +type ConvergentScrollController = { + /** + * Begins a convergence loop toward `messageId`. Returns `true` when the id is + * present in the data (loop started), `false` when it is absent (never + * off-screen-false — only data-absent-false, matching the deep-link contract). + * A new call cancels any in-flight loop. + */ + scrollToMessage: (messageId: string) => boolean; + /** Cancels any in-flight convergence loop (e.g. on unmount or channel switch). */ + cancel: () => void; +}; + +/** + * Drives @tanstack/react-virtual to settle on an off-screen message by id. + * + * The library already converges OFFSETS: one `scrollToIndex(i)` captures index + * `i` and its `reconcileScroll` rAF loop re-aims as rows mount and measure. But + * it chases the INDEX captured at call time — a prepend/delete mid-settle leaves + * it on the wrong row. This adapter closes that gap: each frame it re-resolves + * the target's CURRENT index from the live map (the pure `convergenceStep` + * reducer owns the decision) and re-issues `scrollToIndex` ONLY when the index + * moved. In steady state it issues nothing, so it never resets the library's + * internal stable-frame counter and the library settles in one frame. + * + * Settle detection is a trivial offset-equality check (NOT the convergence math, + * which the library owns): the measured offset for the current index is within + * tolerance of where the library would place it, and the offset is unchanged + * from the prior frame. + */ +export function useConvergentScrollToMessage( + getVirtualizer: () => ListVirtualizer | null, + { + indexByMessageId, + align, + onConverged, + onAbandoned, + }: ConvergentScrollOptions, +): ConvergentScrollController { + // Mirror inputs into refs so the rAF loop closure always reads live values + // without re-subscribing the loop each render. + const mapRef = React.useRef(indexByMessageId); + mapRef.current = indexByMessageId; + const alignRef = React.useRef(align); + alignRef.current = align; + const onConvergedRef = React.useRef(onConverged); + onConvergedRef.current = onConverged; + const onAbandonedRef = React.useRef(onAbandoned); + onAbandonedRef.current = onAbandoned; + + const rafIdRef = React.useRef(null); + + const cancel = React.useCallback(() => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + }, []); + + const scrollToMessage = React.useCallback( + (messageId: string) => { + const startIndex = mapRef.current.get(messageId); + if (startIndex === undefined) { + return false; + } + + cancel(); + + let lastIssuedIndex: number | null = null; + let previousOffset: number | null = null; + let framesUsed = 0; + + const frame = () => { + rafIdRef.current = null; + const virtualizer = getVirtualizer(); + if (!virtualizer) { + return; + } + + const currentIndex = mapRef.current.get(messageId); + // The library has settled this frame when its offset reached the target + // index's offset (within tolerance) and stopped moving. `currentIndex` + // is re-read so a settle on a stale index never counts as converged. + let librarySettled = false; + if (currentIndex !== undefined && lastIssuedIndex === currentIndex) { + const offset = virtualizer.scrollOffset ?? 0; + const target = virtualizer.getOffsetForIndex( + currentIndex, + alignRef.current, + ); + const reachedTarget = + target !== undefined && + Math.abs(offset - target[0]) <= SETTLE_TOLERANCE_PX; + const offsetStable = + previousOffset !== null && + Math.abs(offset - previousOffset) <= SETTLE_TOLERANCE_PX; + librarySettled = reachedTarget && offsetStable; + previousOffset = offset; + } else { + previousOffset = virtualizer.scrollOffset ?? 0; + } + + const decision = convergenceStep({ + targetMessageId: messageId, + indexByMessageId: mapRef.current, + lastIssuedIndex, + librarySettled, + framesUsed, + }); + + if ( + decision.nextIndex !== null && + decision.nextIndex !== lastIssuedIndex + ) { + // Re-aim only when the index actually moved — re-issuing the same + // index would reset the library's stable-frame counter forever. + virtualizer.scrollToIndex(decision.nextIndex, { + align: alignRef.current, + }); + lastIssuedIndex = decision.nextIndex; + } + + if (decision.done) { + if (decision.converged) { + onConvergedRef.current?.(messageId); + } else { + onAbandonedRef.current?.(messageId); + } + return; + } + + framesUsed += 1; + rafIdRef.current = requestAnimationFrame(frame); + }; + + rafIdRef.current = requestAnimationFrame(frame); + return true; + }, + [cancel, getVirtualizer], + ); + + React.useEffect(() => cancel, [cancel]); + + return { scrollToMessage, cancel }; +} diff --git a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts index 73efbd3fc..cb36f2a86 100644 --- a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts +++ b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts @@ -1,5 +1,7 @@ import * as React from "react"; +import type { ListVirtualizer } from "@/shared/ui/VirtualizedList"; + type UseLoadOlderOnScrollOptions = { fetchOlder?: () => Promise; hasOlderMessages: boolean; @@ -7,6 +9,16 @@ type UseLoadOlderOnScrollOptions = { restoreScrollPosition: (scrollTop: number) => void; scrollContainerRef: React.RefObject; sentinelRef: React.RefObject; + /** + * When the timeline is virtualized, prepended rows shift every index and are + * mounted at an estimate (80px) before they measure, so the `scrollHeight` + * delta anchor drifts. Supplying the virtualizer switches to an index anchor: + * we hold the first-visible item across the prepend by its NEW index. + */ + virtualizer?: { + getVirtualizer: () => ListVirtualizer | null; + itemCount: number; + } | null; }; /** @@ -21,11 +33,16 @@ export function useLoadOlderOnScroll({ restoreScrollPosition, scrollContainerRef, sentinelRef, + virtualizer = null, }: UseLoadOlderOnScrollOptions) { const restoreScrollPositionRef = React.useRef(restoreScrollPosition); React.useEffect(() => { restoreScrollPositionRef.current = restoreScrollPosition; }); + // Mirror the virtualizer option into a ref so the long-lived Intersection + // observer reads the live getter + count without re-subscribing per render. + const virtualizerRef = React.useRef(virtualizer); + virtualizerRef.current = virtualizer; React.useEffect(() => { const sentinel = sentinelRef.current; @@ -56,6 +73,54 @@ export function useLoadOlderOnScroll({ currentObserver?.disconnect(); + const virt = virtualizerRef.current; + if (virt) { + // Index anchor: hold the first rendered item across the prepend. + // Capture its index + the gap between its top and the viewport top + // BEFORE the fetch; after the prepend shifts indices by N, re-aim at + // `oldIndex + N` and restore that same intra-row gap. This is immune + // to the estimate->measured height churn that makes a scrollHeight + // delta drift. + const instance = virt.getVirtualizer(); + const firstVisible = instance?.getVirtualItems()[0]; + const previousCount = virt.itemCount; + const anchorIndex = firstVisible?.index ?? null; + const anchorOffsetIntoRow = + firstVisible && instance + ? (instance.scrollOffset ?? 0) - firstVisible.start + : 0; + + void fetchOlder().then(() => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const after = virtualizerRef.current?.getVirtualizer(); + const prepended = + (virtualizerRef.current?.itemCount ?? previousCount) - + previousCount; + if (after && anchorIndex !== null && prepended > 0) { + after.scrollToIndex(anchorIndex + prepended, { + align: "start", + }); + // scrollToIndex aligns the row's top to the viewport top; + // re-apply the captured gap so the view doesn't nudge by a + // partial row. + const target = after.getOffsetForIndex( + anchorIndex + prepended, + "start", + ); + if (target !== undefined) { + restoreScrollPositionRef.current( + target[0] + anchorOffsetIntoRow, + ); + } + } + observe(); + }); + }); + }); + return; + } + const previousHeight = container.scrollHeight; const previousScrollTop = container.scrollTop; void fetchOlder().then(() => { diff --git a/desktop/src/features/messages/ui/useTimelineScrollManager.ts b/desktop/src/features/messages/ui/useTimelineScrollManager.ts index af8b66c88..f5413612a 100644 --- a/desktop/src/features/messages/ui/useTimelineScrollManager.ts +++ b/desktop/src/features/messages/ui/useTimelineScrollManager.ts @@ -7,6 +7,8 @@ import { selectLatestMessageKey, } from "@/features/messages/lib/timelineSnapshot"; import type { TimelineMessage } from "@/features/messages/types"; +import type { ListVirtualizer } from "@/shared/ui/VirtualizedList"; +import { useConvergentScrollToMessage } from "./useConvergentScrollToMessage"; type UseTimelineScrollManagerOptions = { channelId?: string | null; @@ -15,6 +17,18 @@ type UseTimelineScrollManagerOptions = { onTargetReached?: (messageId: string) => void; scrollContainerRef: React.RefObject; targetMessageId?: string | null; + /** + * When the timeline is virtualized, the caller supplies a getter for the + * virtualizer and a live message-id -> item-index map. Scroll-to-message and + * scroll-to-bottom then drive the virtualizer's index model (off-screen rows + * have no DOM node to `querySelector`). When omitted (e.g. the thread panel, + * which is not virtualized), the hook falls back to its DOM-imperative paths. + */ + virtualizer?: { + getVirtualizer: () => ListVirtualizer | null; + indexByMessageId: Map; + itemCount: number; + } | null; }; type PinToBottomOptions = { @@ -28,6 +42,7 @@ export function useTimelineScrollManager({ onTargetReached, scrollContainerRef, targetMessageId, + virtualizer = null, }: UseTimelineScrollManagerOptions) { const timelineRef = scrollContainerRef; const contentRef = React.useRef(null); @@ -194,6 +209,27 @@ export function useTimelineScrollManager({ isProgrammaticBottomScrollRef.current = true; + // Virtualized timeline: the last item lives off-screen with no DOM node, + // so aim the virtualizer at it by index ("end" align). The library's own + // reconcile loop chases the bottom as rows mount and measure, replacing + // the synchronous `scrollHeight` read that forced a full reflow on entry. + if (virtualizer) { + const lastIndex = virtualizer.itemCount - 1; + if (lastIndex >= 0) { + virtualizer + .getVirtualizer() + ?.scrollToIndex(lastIndex, { align: "end", behavior }); + } + lockedScrollTopRef.current = null; + previousScrollTopRef.current = timeline.scrollTop; + pinToBottom({ clearNewMessageCount: true }); + requestAnimationFrame(() => { + previousScrollTopRef.current = timeline.scrollTop; + syncScrollState(); + }); + return; + } + const alignToBottom = (nextBehavior: ScrollBehavior) => { bottomAnchorRef.current?.scrollIntoView({ block: "end", @@ -234,7 +270,7 @@ export function useTimelineScrollManager({ settleAlignment(2); }, - [pinToBottom, syncScrollState], + [pinToBottom, syncScrollState, virtualizer], ); // biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes @@ -373,6 +409,36 @@ export function useTimelineScrollManager({ unpinFromBottom, ]); + // Shared highlight lifecycle for both scroll paths: highlight the row, clear + // the unread count, and auto-fade the highlight after 2s. + const beginHighlight = React.useCallback((messageId: string) => { + setHighlightedMessageId(messageId); + setNewMessageCount(0); + window.setTimeout(() => { + setHighlightedMessageId((current) => + current === messageId ? null : current, + ); + }, 2_000); + }, []); + + const clearHighlight = React.useCallback((messageId: string) => { + setHighlightedMessageId((current) => + current === messageId ? null : current, + ); + }, []); + + // Virtualized scroll path: re-aim the virtualizer by index, re-resolving the + // target id every frame so a mid-settle prepend/delete can't strand it. + const convergent = useConvergentScrollToMessage( + virtualizer?.getVirtualizer ?? (() => null), + { + indexByMessageId: virtualizer?.indexByMessageId ?? new Map(), + align: "center", + onConverged: (messageId) => onTargetReached?.(messageId), + onAbandoned: clearHighlight, + }, + ); + // biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes const scrollToMessage = React.useCallback( (messageId: string) => { @@ -381,6 +447,17 @@ export function useTimelineScrollManager({ return false; } + // Virtualized timeline: off-screen rows have no DOM node, so resolve the + // target through the index map and drive the convergence loop instead of + // `querySelector` + `scrollIntoView`. + if (virtualizer) { + unpinFromBottom(timeline.scrollTop); + beginHighlight(messageId); + // Returns false only when the id is absent from the data (never merely + // off-screen), matching the deep-link effect's found-in-data contract. + return convergent.scrollToMessage(messageId); + } + const targetElement = timeline.querySelector( `[data-message-id="${messageId}"]`, ); @@ -389,8 +466,7 @@ export function useTimelineScrollManager({ } unpinFromBottom(timeline.scrollTop); - setHighlightedMessageId(messageId); - setNewMessageCount(0); + beginHighlight(messageId); const alignToTarget = (remainingFrames: number) => { targetElement.scrollIntoView({ @@ -411,15 +487,9 @@ export function useTimelineScrollManager({ alignToTarget(2); - window.setTimeout(() => { - setHighlightedMessageId((current) => - current === messageId ? null : current, - ); - }, 2_000); - return true; }, - [onTargetReached, unpinFromBottom], + [beginHighlight, convergent, onTargetReached, unpinFromBottom, virtualizer], ); React.useEffect(() => { From b0426006b5e899fda549e96f221968643bfbbae5 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Thu, 18 Jun 2026 01:42:07 -0400 Subject: [PATCH 2/3] fix(desktop): stop competing scroll writers collapsing the load-older anchor Loading older messages under virtualization let three writers fight over scrollTop on overlapping frames, so the anchored row jittered or collapsed to the top (~33% of prepends) and the library's reconcile spun the full 5s MAX_RECONCILE_MS valve. Establish a single owner of scroll position across the whole fetch+restore window: - useLoadOlderOnScroll restores by scrollTop ONLY (drop scrollToIndex), via one getOffsetForIndex(anchorIndex + prepended, "start")[0] + intra-row gap write. getOffsetForIndex is a pure measurement-cache read, so no library scrollState is set and the reconcile loop has nothing to fight. - The viewport ResizeObserver in useTimelineScrollManager no longer runs a competing restore during a fetch: it skips while isFetchingOlder is true (the spinner's clientHeight 720->590 mount-shift fires before the lock is set) and otherwise defers to lockedScrollTopRef when the load-older restore holds it. MessageTimeline threads isFetchingOlder into the manager. The defect was invisible to unit tests (jsdom getBoundingClientRect -> 0) and to static traces; the new load-older E2E drives a real prepend on six fresh page loads and asserts the anchor holds every run, the scroller genuinely grew, and the reconcile terminates. emitMockHistory now honors the relay filter's until/limit so the mock relay paginates like a real one, which the E2E needs to exercise a genuine older page. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../features/messages/ui/MessageTimeline.tsx | 1 + .../messages/ui/useLoadOlderOnScroll.ts | 17 ++- .../messages/ui/useTimelineScrollManager.ts | 30 +++- desktop/src/testing/e2eBridge.ts | 78 ++++++++++- .../e2e/virtualization-screenshots.spec.ts | 132 ++++++++++++++++++ 5 files changed, 246 insertions(+), 12 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 20286799b..ded4993ef 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -245,6 +245,7 @@ const MessageTimelineBase = React.forwardRef< syncScrollState, } = useTimelineScrollManager({ channelId, + isFetchingOlder, isLoading: showTimelineSkeleton, messages: deferredMessages, onTargetReached, diff --git a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts index cb36f2a86..bbf190111 100644 --- a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts +++ b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts @@ -98,12 +98,17 @@ export function useLoadOlderOnScroll({ (virtualizerRef.current?.itemCount ?? previousCount) - previousCount; if (after && anchorIndex !== null && prepended > 0) { - after.scrollToIndex(anchorIndex + prepended, { - align: "start", - }); - // scrollToIndex aligns the row's top to the viewport top; - // re-apply the captured gap so the view doesn't nudge by a - // partial row. + // Restore by scrollTop ONLY — a single writer. Compute the + // anchored row's top via getOffsetForIndex (a pure read of + // the measurement cache, no scrollState) and add back the + // captured intra-row gap. Calling scrollToIndex here too + // would set the library's scrollState aiming at the row TOP + // while this restore aims at row top + gap; the two write + // scrollTop to different values on overlapping rAF frames, + // so the library's reconcile never reaches approxEqual, + // never re-scrolls (its target is unchanged), and spins one + // rAF/frame for the full 5s MAX_RECONCILE_MS valve on every + // prepend. One mechanism, no fight. const target = after.getOffsetForIndex( anchorIndex + prepended, "start", diff --git a/desktop/src/features/messages/ui/useTimelineScrollManager.ts b/desktop/src/features/messages/ui/useTimelineScrollManager.ts index f5413612a..32e319a19 100644 --- a/desktop/src/features/messages/ui/useTimelineScrollManager.ts +++ b/desktop/src/features/messages/ui/useTimelineScrollManager.ts @@ -12,6 +12,7 @@ import { useConvergentScrollToMessage } from "./useConvergentScrollToMessage"; type UseTimelineScrollManagerOptions = { channelId?: string | null; + isFetchingOlder?: boolean; isLoading: boolean; messages: TimelineMessage[]; onTargetReached?: (messageId: string) => void; @@ -37,6 +38,7 @@ type PinToBottomOptions = { export function useTimelineScrollManager({ channelId, + isFetchingOlder = false, isLoading, messages, onTargetReached, @@ -63,6 +65,12 @@ export function useTimelineScrollManager({ // a streaming-in list is what makes the timeline thrash on entry. const isLoadingRef = React.useRef(isLoading); isLoadingRef.current = isLoading; + // Mirror isFetchingOlder so the viewport ResizeObserver (subscribes once) can + // see the live value: the load-older path owns scroll position across its + // whole fetch+restore window, so the observer must not run a competing + // restore while a fetch is in flight (see the resize handler below). + const isFetchingOlderRef = React.useRef(isFetchingOlder); + isFetchingOlderRef.current = isFetchingOlder; const [isAtBottom, setIsAtBottom] = React.useState(true); const [highlightedMessageId, setHighlightedMessageId] = React.useState< string | null @@ -307,7 +315,27 @@ export function useTimelineScrollManager({ return; } - restoreScrollPosition(previousScrollTopRef.current); + // The load-older path owns scroll position across its whole window. Two + // guards keep this observer from running a competing restore — without + // them the spinner's clientHeight 720->590 shift fires here and restores + // to previousScrollTopRef.current (0, since the user scrolled to the top + // to trigger), collapsing the anchor. + // + // Guard 1 — fetch in flight, lock not yet set: the spinner mounts BEFORE + // the fetch resolves and calls restoreScrollPosition, so lockedScrollTop + // is still null on this fire. Skip entirely; the load-older path restores + // once the page arrives. + if (isFetchingOlderRef.current) { + return; + } + + // Guard 2 — restore running, lock set: a later shift (e.g. spinner + // unmount) can fire while restoreScrollPosition's rAF loop holds its + // target in lockedScrollTopRef. Defer to that target so both aim at the + // same scrollTop instead of fighting frame-by-frame. + restoreScrollPosition( + lockedScrollTopRef.current ?? previousScrollTopRef.current, + ); }); observer.observe(timeline); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 743e40ded..197c89b41 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -466,6 +466,8 @@ type MockFilter = { "#h"?: string[]; authors?: string[]; kinds?: number[]; + limit?: number; + until?: number; }; type MockSocket = { @@ -1491,6 +1493,37 @@ const mockChannels: MockChannel[] = [ createMockMember(MOCK_IDENTITY_PUBKEY, "member", 700), ], }), + // Deep history channel for the load-older-under-virtualization E2E. Seeded + // with more messages than CHANNEL_HISTORY_LIMIT (200) so the initial load + // windows to the newest page and a `fetchOlder` (until-cursor) prepend has + // genuinely older rows to add — exercising the scroll-restore anchor under + // virtualization. Its own channel so existing channels' row-index and unread + // assertions stay undisturbed. + createMockChannel({ + id: "feedf00d-0000-4000-8000-000000000007", + name: "deep-history", + channel_type: "stream", + visibility: "open", + description: "Channel with paginated history for load-older tests", + 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(); @@ -2257,15 +2290,50 @@ function getMockMessageStore(channelId: string): RelayEvent[] { sig: "mocksig".repeat(20).slice(0, 128), }, ] - : []; + : channelId === "feedf00d-0000-4000-8000-000000000007" + ? // 600 messages > CHANNEL_HISTORY_LIMIT (200): the initial load + // windows to the newest 200, leaving 400 older behind the until + // cursor — enough for several full fetchOlder pages (batch 100), + // so the load-older anchor restore is exercised across REPEATED + // prepend cycles, not a single lucky pass. created_at increases + // with index (oldest first) so message N+1 is newer than N — the + // anchor restores the first-visible row across each prepend. + Array.from({ length: 600 }, (_, index) => ({ + id: `mock-deep-history-${index}`, + pubkey: index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY, + created_at: Math.floor(Date.now() / 1000) - (600 - index) * 60, + kind: 9, + tags: [["h", channelId]], + content: `Deep history message #${index}`, + sig: "mocksig".repeat(20).slice(0, 128), + })) + : []; mockMessages.set(channelId, seeded); return seeded; } -function emitMockHistory(socket: MockSocket, subId: string, channelId: string) { - const events = getMockMessageStore(channelId); - for (const event of events) { +function emitMockHistory( + socket: MockSocket, + subId: string, + channelId: string, + filter?: MockFilter, +) { + // Honor the relay window so load-older paginates instead of replaying the + // whole store. A real relay returns the newest `limit` events at or before + // `until` (inclusive — the client dedupes the boundary message by id). Cap at + // `limit` after the `until` filter so the page is genuinely older content. + const events = getMockMessageStore(channelId).filter( + (event) => filter?.until === undefined || event.created_at <= filter.until, + ); + const limit = filter?.limit ?? events.length; + const windowed = + events.length > limit + ? [...events] + .sort((left, right) => right.created_at - left.created_at) + .slice(0, limit) + : events; + for (const event of windowed) { sendWsText(socket.handler, ["EVENT", subId, event]); } sendWsText(socket.handler, ["EOSE", subId]); @@ -5847,7 +5915,7 @@ function sendToMockSocket(args: { return; } - emitMockHistory(socket, subId, channelId); + emitMockHistory(socket, subId, channelId, filter); return; } diff --git a/desktop/tests/e2e/virtualization-screenshots.spec.ts b/desktop/tests/e2e/virtualization-screenshots.spec.ts index 09a5e90dd..adef5493c 100644 --- a/desktop/tests/e2e/virtualization-screenshots.spec.ts +++ b/desktop/tests/e2e/virtualization-screenshots.spec.ts @@ -196,4 +196,136 @@ test.describe("list virtualization screenshots", () => { await page.screenshot({ path: `${SHOTS}/06b-sections-after-reorder.png` }); }); + + test("07 — load-older prepend holds the anchored row without jitter or reconcile spin", async ({ + page, + }) => { + // Install once: addInitScript re-runs on every navigation in this page, so + // each page.goto in the loop below re-applies the mock bridge. + await installMockBridge(page); + + // The deep-history channel seeds 600 messages; the initial load windows to + // the newest 200, leaving 400 older behind the until cursor — enough that + // every run lands a genuine prepend. Reads the first row at/below the + // viewport top and returns scrollTop, scrollHeight, and that row's on-screen + // VIEWPORT position in ONE settled snapshot — the position the single-writer + // restore must hold steady across the prepend. + // + // Waits inside the browser for a measurement-settled frame before reading. + // The virtualizer re-windows after a scroll: for a few rAFs the mounted rows + // can all sit above the viewport top (their absolute offsets lag the new + // scrollTop) until the library mounts rows at the current position. That is + // a measurement transient, NOT the scrollTop race — scrollTop is already + // correct on those frames. Reading on such a frame would throw "no row"; + // polling for a settled frame removes the flake without touching any + // race-detection threshold below (scrollTop value + viewportPos stability), + // and snapshots all three fields together so they can't skew across reads. + const sampleAnchor = (timeline: Locator) => + timeline.evaluate(async (scroller) => { + const s = scroller as HTMLElement; + for (let frame = 0; frame < 60; frame += 1) { + const scrollerTop = s.getBoundingClientRect().top; + const row = Array.from( + s.querySelectorAll("[data-message-id]"), + ).find((r) => r.getBoundingClientRect().top - scrollerTop >= 0); + if (row) { + return { + viewportPos: row.getBoundingClientRect().top - scrollerTop, + scrollTop: s.scrollTop, + scrollHeight: s.scrollHeight, + }; + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + throw new Error("no anchor row mounted after 60 frames"); + }); + + // Determinism is the bar, not pass-once. The original defect was a RACE: a + // second restore loop (the resize-observer restoring to the pre-fetch + // scrollTop of 0, fired by the load-older spinner's clientHeight shift) + // fought the anchor restore frame-by-frame; last writer won, so the anchor + // held only ~2 of 3 runs and on its losing runs scrollTop collapsed to ~0 + // (view stuck at the top, anchor lost). A single prepend can go green on a + // lucky scheduling order, so this drives the prepend on SIX fresh page loads + // and asserts the anchor holds on every one — a flaky-pass fails the run. + // Fresh navigation each iteration resets the virtualizer's measurement state, + // matching the run-to-run conditions under which the race surfaced. + for (let run = 0; run < 6; run += 1) { + // Force a full document reload each iteration. Navigating straight to the + // same hash route is a same-document hash change, not a reload, so the + // virtualizer + paginated history would carry over and later runs would + // exhaust the older pages — defeating the per-run fresh-prepend premise. + await page.goto("about:blank"); + await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007"); + const timeline = page.getByTestId("message-timeline"); + await expect(timeline).toBeVisible(); + await expect( + page.locator('[data-message-id^="mock-deep-history-"]').first(), + ).toBeVisible(); + + // Scroll up to mount mid-history rows while staying clear of the load-older + // sentinel zone (trips within 200px of the top), then let the windowed rows + // measure off their 80px estimate so the pre-prepend anchor reading is + // stable. The single trigger is the deliberate scrollTop = 0 below. + await timeline.evaluate((el) => { + el.scrollTop = 4000; + }); + await page.waitForTimeout(300); + await timeline.evaluate((el) => { + el.scrollTop = 4000; + }); + await page.waitForTimeout(150); + const before = await sampleAnchor(timeline); + expect(before.scrollTop).toBeGreaterThan(200); + + // Trigger exactly one prepend. Scrolling to 150 trips the load-older + // sentinel (its rootMargin reaches 200px past the top) with + // previousScrollTopRef pinned near the top — the condition under which the + // resize-observer's competing restore collapsed the anchor pre-fix. After + // the single fetchOlder lands, the anchor restore carries scrollTop deep + // into the content, clear of the 200px sentinel zone, so the observer does + // NOT re-fire: one clean prepend, not the re-trigger storm that scrollTop + // 0 produces (0 keeps the sentinel tripped across every paged window down + // to the small exhaustion-tail page, which legitimately lands the top row + // near the top — masking the hold signal). + await timeline.evaluate((el) => { + el.scrollTop = 150; + }); + + // Anchor-hold gate (the race signal): poll until the restore has carried + // scrollTop deep into the content — past where it sat before the prepend. + // Pre-fix, the competing resize-observer restore (firing on the spinner's + // clientHeight shift, restoring to previousScrollTopRef ~150) won often + // enough that scrollTop stayed pinned near the top; this poll would then + // time out, failing the run. scrollHeight grows several frames BEFORE the + // restore moves scrollTop, so a scrollHeight gate would read mid-cycle + // near the top — the race lives in scrollTop, so the gate watches it. + await expect + .poll(async () => (await sampleAnchor(timeline)).scrollTop, { + timeout: 10_000, + }) + .toBeGreaterThan(before.scrollTop); + + // One settled snapshot for the remaining checks so scrollHeight and + // viewportPos come from the same frame as the held scrollTop: + // (a) the scroller grew by the prepended rows' height (genuine prepend), + // (b) the first-visible row sits where it did before the prepend. + const after = await sampleAnchor(timeline); + expect(after.scrollHeight).toBeGreaterThan(before.scrollHeight + 800); + expect(Math.abs(after.viewportPos - before.viewportPos)).toBeLessThan(120); + + // Reconcile terminates: two equal scrollTop reads 600ms apart prove the + // rAF loop stopped. Under the double-writer bug the library re-scheduled + // one rAF per frame for the full 5s MAX_RECONCILE_MS valve — still churning + // 600ms apart. + const settled1 = await timeline.evaluate((el) => el.scrollTop); + await page.waitForTimeout(600); + const settled2 = await timeline.evaluate((el) => el.scrollTop); + expect(Math.abs(settled1 - settled2)).toBeLessThan(2); + + if (run === 0) { + await page.screenshot({ path: `${SHOTS}/07-load-older-anchor-hold.png` }); + } + } + }); }); From 2da0802fb8e28236041617401a8ad7259ea3748e Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Thu, 18 Jun 2026 02:07:18 -0400 Subject: [PATCH 3/3] style(desktop): wrap long lines in virtualization spec for biome biome check enforces line-wrapping that biome lint does not. The load-older test 07 had two over-width statements that passed local lint but failed the Desktop Core biome check gate. Format-only, no behavior change. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/tests/e2e/virtualization-screenshots.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/desktop/tests/e2e/virtualization-screenshots.spec.ts b/desktop/tests/e2e/virtualization-screenshots.spec.ts index adef5493c..30f220e3c 100644 --- a/desktop/tests/e2e/virtualization-screenshots.spec.ts +++ b/desktop/tests/e2e/virtualization-screenshots.spec.ts @@ -312,7 +312,9 @@ test.describe("list virtualization screenshots", () => { // (b) the first-visible row sits where it did before the prepend. const after = await sampleAnchor(timeline); expect(after.scrollHeight).toBeGreaterThan(before.scrollHeight + 800); - expect(Math.abs(after.viewportPos - before.viewportPos)).toBeLessThan(120); + expect(Math.abs(after.viewportPos - before.viewportPos)).toBeLessThan( + 120, + ); // Reconcile terminates: two equal scrollTop reads 600ms apart prove the // rAF loop stopped. Under the double-writer bug the library re-scheduled @@ -324,7 +326,9 @@ test.describe("list virtualization screenshots", () => { expect(Math.abs(settled1 - settled2)).toBeLessThan(2); if (run === 0) { - await page.screenshot({ path: `${SHOTS}/07-load-older-anchor-hold.png` }); + await page.screenshot({ + path: `${SHOTS}/07-load-older-anchor-hold.png`, + }); } } });