From 913facde71cb2585db890b545f5df2750cc3b286 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 23:05:19 -0400 Subject: [PATCH] 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 | 96 ++++ .../messages/ui/MessageThreadPanel.tsx | 2 +- .../features/messages/ui/MessageTimeline.tsx | 49 +- .../messages/ui/TimelineMessageList.tsx | 424 +++++++++++------- .../ui/useConvergentScrollToMessage.ts | 166 +++++++ .../messages/ui/useLoadOlderOnScroll.ts | 157 +++++++ 13 files changed, 1212 insertions(+), 163 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 create mode 100644 desktop/src/features/messages/ui/useLoadOlderOnScroll.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..02477eb97 --- /dev/null +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -0,0 +1,96 @@ +/** + * 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(); + + // Index boundaries by their start position so the walk below can look up the + // prepend-stable section key (start-of-local-day). Keying the divider by + // start-of-day, not by the first message, keeps the day section from + // remounting when older messages prepend into it. + const dayBoundariesByStartIndex = new Map( + buildDayGroupBoundaries(entries.map((entry) => entry.message)).map( + (boundary: DayGroupBoundary) => [boundary.startIndex, boundary] as const, + ), + ); + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const { message } = entry; + const renderKey = entryRenderKey(entry); + + const dayBoundary = dayBoundariesByStartIndex.get(i); + if (dayBoundary) { + items.push({ + kind: "day-divider", + key: dayBoundary.key, + 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 cb3bc0b5a..46dbf215c 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -438,7 +438,7 @@ export function MessageThreadPanel({ return (
(null); const topSentinelRef = React.useRef(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 @@ -222,6 +252,7 @@ const MessageTimelineBase = React.forwardRef< scrollContainerRef, sentinelRef: topSentinelRef, targetMessageId, + virtualizer: virtualizerOption, }); React.useImperativeHandle( @@ -266,8 +297,9 @@ const MessageTimelineBase = React.forwardRef< }, [firstUnreadMessageId, scrollToMessage]); // Scroll to the active search match when it changes. `scrollToMessage` - // updates the scroll anchor, so the post-commit restore won't yank the - // view back off the match. + // updates the scroll anchor (so the post-commit restore won't yank the view + // back off the match) and, when virtualized, resolves the target through the + // index model — the row may be windowed out of the DOM. const prevSearchActiveRef = React.useRef(null); React.useEffect(() => { if (showTimelineSkeleton) return; @@ -282,6 +314,16 @@ const MessageTimelineBase = React.forwardRef< scrollToMessage(searchActiveMessageId, { behavior: "smooth" }); }, [scrollToMessage, searchActiveMessageId, showTimelineSkeleton]); + useLoadOlderOnScroll({ + fetchOlder, + hasOlderMessages, + isLoading: showTimelineSkeleton, + restoreScrollPosition, + scrollContainerRef, + sentinelRef: topSentinelRef, + virtualizer: virtualizerOption, + }); + const timelineSkeletonRows = useTimelineSkeletonRows({ channelId, isLoading: showTimelineSkeleton, @@ -501,6 +543,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 aaf9f9f7e..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,163 +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 boundaries by start position so the - // render loop below stays a straight walk while the grouping logic — and the - // prepend-stable section key — lives in `lib/`. - const dayGroupBoundariesByStartIndex = new Map( - buildDayGroupBoundaries(entries.map((entry) => entry.message)).map( - (boundary) => [boundary.startIndex, boundary], - ), + // 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]); - const dayBoundary = dayGroupBoundariesByStartIndex.get(i); - if (dayBoundary) { - currentDayGroup = { - key: dayBoundary.key, - 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 ( + + ); +}); - 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; +function SystemRow({ + currentPubkey, + entry, + footer, + onToggleReaction, + profiles, +}: { + currentPubkey?: string; + entry: MainTimelineEntry; + footer: React.ReactNode; + onToggleReaction?: TimelineMessageListProps["onToggleReaction"]; + profiles?: UserProfileLookup; +}) { + return ( +
+ + {footer} +
+ ); +} - currentDayGroup?.elements.push( -
- - {footer} -
, - ); - } +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; +}; + +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 new file mode 100644 index 000000000..cb36f2a86 --- /dev/null +++ b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts @@ -0,0 +1,157 @@ +import * as React from "react"; + +import type { ListVirtualizer } from "@/shared/ui/VirtualizedList"; + +type UseLoadOlderOnScrollOptions = { + fetchOlder?: () => Promise; + hasOlderMessages: boolean; + isLoading: boolean; + 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; +}; + +/** + * Triggers `fetchOlder` when a sentinel element near the top of the scroll + * container enters the viewport, then restores the scroll position so the + * visible content doesn't jump. + */ +export function useLoadOlderOnScroll({ + fetchOlder, + hasOlderMessages, + isLoading, + 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; + const container = scrollContainerRef.current; + if ( + !sentinel || + !container || + !fetchOlder || + isLoading || + !hasOlderMessages + ) { + return; + } + + let disposed = false; + let currentObserver: IntersectionObserver | null = null; + + const observe = () => { + if (disposed) { + return; + } + + currentObserver = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting || disposed) { + return; + } + + 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(() => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const newHeight = container.scrollHeight; + const delta = newHeight - previousHeight; + if (delta > 0) { + restoreScrollPositionRef.current(previousScrollTop + delta); + } + observe(); + }); + }); + }); + }, + { root: container, rootMargin: "200px 0px 0px 0px" }, + ); + + currentObserver.observe(sentinel); + }; + + observe(); + return () => { + disposed = true; + currentObserver?.disconnect(); + }; + }, [ + fetchOlder, + hasOlderMessages, + isLoading, + scrollContainerRef, + sentinelRef, + ]); +}