perf(desktop): virtualize message timeline to stop the cold-switch beachball

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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-18 18:30:41 -04:00
co-authored by Will Pfleger
parent 78ac6caf19
commit 913facde71
13 changed files with 1212 additions and 163 deletions
+16
View File
@@ -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;
+11 -1
View File
@@ -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,
});
+10 -1
View File
@@ -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();
@@ -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();
}
@@ -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);
});
@@ -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<string, number>;
/**
* 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 };
}
@@ -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);
});
@@ -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<string, number>;
};
/** 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<string, number>();
// 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 };
}
@@ -438,7 +438,7 @@ export function MessageThreadPanel({
return (
<div
className={cn(
"flex flex-col gap-1",
"content-visibility-auto flex flex-col gap-1",
entry.summary &&
"group/message mx-1 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
)}
@@ -9,12 +9,14 @@ import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDi
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
import type { TimelineItemsResult } from "@/features/messages/lib/timelineItems";
import { cn } from "@/shared/lib/cn";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { Spinner } from "@/shared/ui/spinner";
import { TooltipProvider } from "@/shared/ui/tooltip";
import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import type { ListVirtualizer } from "@/shared/ui/VirtualizedList";
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
import { TimelineMessageList } from "./TimelineMessageList";
import { useAnchoredScroll } from "./useAnchoredScroll";
@@ -163,6 +165,34 @@ const MessageTimelineBase = React.forwardRef<
const contentRef = React.useRef<HTMLDivElement>(null);
const topSentinelRef = React.useRef<HTMLDivElement>(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<ListVirtualizer | null>(null);
const handleVirtualizer = React.useCallback((instance: ListVirtualizer) => {
virtualizerRef.current = instance;
}, []);
const getVirtualizer = React.useCallback(() => virtualizerRef.current, []);
const [timelineItems, setTimelineItems] =
React.useState<TimelineItemsResult | null>(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<string | null>(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}
/>
</div>
) : null}
@@ -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<string, number>;
/** Caller-owned scroll container the virtualizer measures and scrolls. */
scrollContainerRef: React.RefObject<HTMLElement | null>;
/** 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 <DayDivider label={formatDayHeading(item.headingTimestamp)} />;
case "unread-divider":
return <UnreadDivider />;
case "system":
return (
<SystemRow
currentPubkey={currentPubkey}
entry={item.entry}
footer={messageFooters?.[item.entry.message.id] ?? null}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
);
case "message":
return (
<MessageRowItem
agentPubkeys={agentPubkeys}
channelId={channelId}
currentPubkey={currentPubkey}
entry={item.entry}
followThreadById={followThreadById}
footer={messageFooters?.[item.entry.message.id] ?? null}
highlightedMessageId={highlightedMessageId}
isFollowingThreadById={isFollowingThreadById}
onDelete={onDelete}
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onReply={onReply}
onToggleReaction={onToggleReaction}
profiles={profiles}
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
threadUnreadCounts={threadUnreadCounts}
unfollowThreadById={unfollowThreadById}
videoReviewContext={videoReviewContextById.get(
item.entry.message.id,
)}
/>
);
}
},
[
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(
<UnreadDivider key={`unread-${messageRenderKey}`} />,
);
}
return (
<VirtualizedList
getItemKey={getTimelineItemKey}
innerClassName="flex flex-col"
items={itemsResult.items}
onVirtualizer={onVirtualizer}
renderItem={renderItem}
scrollRef={scrollContainerRef}
/>
);
});
if (message.kind === KIND_SYSTEM_MESSAGE) {
const footer = messageFooters?.[message.id] ?? null;
currentDayGroup?.elements.push(
<div key={messageRenderKey} className="flex flex-col gap-1">
<SystemMessageRow
message={message}
currentPubkey={currentPubkey}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
{footer}
</div>,
);
} else if (summary && onReply) {
const footer = messageFooters?.[message.id] ?? null;
const isHighlighted = message.id === highlightedMessageId;
currentDayGroup?.elements.push(
<div
key={messageRenderKey}
className={cn(
"group/message relative mx-1 flex flex-col gap-0 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
isHighlighted &&
"-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-6",
)}
>
<MessageRow
agentPubkeys={agentPubkeys}
channelId={channelId}
highlighted={false}
hoverBackground={false}
isFollowingThread={
isFollowingThreadById
? isFollowingThreadById(message.id)
: undefined
}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
? onDelete
: undefined
}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined
}
onFollowThread={
followThreadById ? () => 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)}
/>
<MessageThreadSummaryRow
depth={message.depth}
message={message}
onOpenThread={onReply}
showDepthGuides={false}
summary={summary}
unreadCount={threadUnreadCounts?.get(message.id)}
/>
{footer}
</div>,
);
} 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 (
<div className="flex flex-col gap-1 pb-2.5">
<SystemMessageRow
message={entry.message}
currentPubkey={currentPubkey}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
{footer}
</div>
);
}
currentDayGroup?.elements.push(
<div key={messageRenderKey} className="flex flex-col gap-1">
<MessageRow
agentPubkeys={agentPubkeys}
channelId={channelId}
highlighted={message.id === highlightedMessageId || isSearchActive}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
? onDelete
: undefined
}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined
}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
searchQuery={isSearchMatch ? searchQuery : undefined}
showDepthGuides={false}
videoReviewContext={videoReviewContextById.get(message.id)}
/>
{footer}
</div>,
);
}
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<typeof buildVideoReviewContextForMessage>;
};
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 (
<div
className={cn(
"group/message relative mx-1 flex flex-col gap-0 rounded-2xl px-0 py-1 pb-2.5 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
isHighlighted &&
"-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-6",
)}
>
<MessageRow
agentPubkeys={agentPubkeys}
channelId={channelId}
highlighted={false}
hoverBackground={false}
isFollowingThread={
isFollowingThreadById
? isFollowingThreadById(message.id)
: undefined
}
message={message}
onDelete={canDelete}
onEdit={canEdit}
onFollowThread={
followThreadById ? () => followThreadById(message.id) : undefined
}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
onUnfollowThread={
unfollowThreadById
? () => unfollowThreadById(message.id)
: undefined
}
profiles={profiles}
showDepthGuides={false}
videoReviewContext={videoReviewContext}
/>
<MessageThreadSummaryRow
depth={message.depth}
message={message}
onOpenThread={onReply}
showDepthGuides={false}
summary={summary}
unreadCount={threadUnreadCounts?.get(message.id)}
/>
{footer}
</div>
);
}
return dayGroups.map((group) => (
<section
className="relative flex flex-col gap-2.5 before:absolute before:inset-x-0 before:top-[15px] before:h-px before:bg-border/35 before:content-['']"
key={group.key}
>
<DayDivider label={group.label} />
{group.elements}
</section>
));
});
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
const isSearchActive = message.id === searchActiveMessageId;
return (
<div className="flex flex-col gap-1 pb-2.5">
<MessageRow
agentPubkeys={agentPubkeys}
channelId={channelId}
highlighted={message.id === highlightedMessageId || isSearchActive}
message={message}
onDelete={canDelete}
onEdit={canEdit}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
searchQuery={isSearchMatch ? searchQuery : undefined}
showDepthGuides={false}
videoReviewContext={videoReviewContext}
/>
{footer}
</div>
);
}
@@ -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<string, number>;
/** 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<number | null>(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 };
}
@@ -0,0 +1,157 @@
import * as React from "react";
import type { ListVirtualizer } from "@/shared/ui/VirtualizedList";
type UseLoadOlderOnScrollOptions = {
fetchOlder?: () => Promise<void>;
hasOlderMessages: boolean;
isLoading: boolean;
restoreScrollPosition: (scrollTop: number) => void;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
sentinelRef: React.RefObject<HTMLDivElement | null>;
/**
* 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,
]);
}