perf(timeline): virtualize the main message timeline

Phase 2: render only the viewport (+overscan) of the main timeline instead of
all ~200+ rows, lifting the ~2,000-row ceiling. Main timeline ONLY — the thread
pane (and its useTimelineScrollManager) is untouched (Phase 3).

What changed:

- MessageTimeline now drives @tanstack/react-virtual off the same deferred
  snapshot the rows render from, flattening day-grouped entries into
  index-addressable rows via buildVirtualTimelineRows. Rows are built from the
  FILTERED main-timeline entries (buildMainTimelineEntries), so dropped thread
  replies never desync the row->entry mapping.

- useVirtualTimelineScroll REPLACES the bespoke 427-line useTimelineScrollManager
  for the main timeline. The virtualizer owns the scroll container and all
  measurement/anchoring; this hook is a thin wrapper keeping sticky-bottom
  autoscroll, accent smooth-scroll, the newMessageCount pill, isAtBottom, and
  deep-link + find-in-page jumps (via findVirtualRowIndexForMessage ->
  scrollToIndex). No scrollTop locking, no ResizeObserver re-pinning.

- useLoadOlderOnScroll is gutted to a trigger: the double-requestAnimationFrame
  scrollTop correction and restoreScrollPosition plumbing are DELETED. Stable
  per-row keys (getItemKey) let the virtualizer hold scroll position on prepend
  natively — the band-aid is gone at the root.

- cmd+F find drives scroll-to-row through the same scrollToIndex bridge; a
  render-all-while-searching escape hatch exists as an off-by-default fallback
  (the in-app path is the default, keeping the perf win).

- The per-entry render (3 row variants + video-review context) is extracted to
  renderTimelineEntry + useVideoReviewContextById, a single source of truth
  injected into VirtualizedTimelineList. The old nested TimelineMessageList is
  deleted (orphaned by the rewire).

Must-keeps preserved: sticky-bottom autoscroll, day dividers (first-class
variable-height flat rows), jump-to-message deep links, no-tearing (same
deferredMessages snapshot drives both rows and scroll logic).

Coverage: 8 jsdom/testing-library DOM tests cover row dispatch (divider vs
message, flat-index->entry mapping), init autoscroll, accent smooth-scroll, and
deep-link scrollToIndex. jsdom has no layout engine, so react-virtual's pixel
measurement/scroll math stays on a manual/visual verification pass.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-06-15 19:35:40 -07:00
co-authored by Taylor Ho
parent fc2302b5e4
commit 374c829619
11 changed files with 1041 additions and 358 deletions
@@ -1,7 +1,10 @@
import * as React from "react";
import { ArrowDown, Hash } from "lucide-react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
import { buildVirtualTimelineRows } from "@/features/messages/lib/buildVirtualTimelineRows";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
@@ -13,9 +16,28 @@ import { SkeletonReveal } from "@/shared/ui/skeleton";
import { TooltipProvider } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
import { TimelineMessageList } from "./TimelineMessageList";
import {
renderTimelineEntry,
type TimelineEntryRenderContext,
} from "./timelineEntryRender";
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
import { useTimelineScrollManager } from "./useTimelineScrollManager";
import { useVideoReviewContextById } from "./useVideoReviewContextById";
import { useVirtualTimelineScroll } from "./useVirtualTimelineScroll";
import { VirtualizedTimelineList } from "./VirtualizedTimelineList";
// Initial row-size guesses only — `measureElement` corrects each row to its
// real height after first paint, so variable-height messages and dividers need
// no fixed-height assumption.
const ESTIMATED_MESSAGE_HEIGHT = 64;
const ESTIMATED_DIVIDER_HEIGHT = 32;
const VIRTUAL_OVERSCAN = 8;
// Fallback escape hatch for find-in-page: when find is open, optionally bypass
// virtualization and render every row so native browser cmd+F can see all
// matches. OFF by default — the in-app find path (scroll-to-row via
// `findVirtualRowIndexForMessage`) is the default and keeps the perf win.
// Flip via the `renderAllWhileSearching` prop only if QA finds a gap.
const RENDER_ALL_WHILE_SEARCHING_DEFAULT = false;
type MessageTimelineProps = {
agentPubkeys?: ReadonlySet<string>;
@@ -70,6 +92,9 @@ type MessageTimelineProps = {
searchMatchingMessageIds?: Set<string>;
/** The current find-in-channel query string. */
searchQuery?: string;
/** Escape hatch: render all rows while find-in-page is open so native
* browser cmd+F can see every match. Defaults off (in-app find is default). */
renderAllWhileSearching?: boolean;
targetMessageId?: string | null;
onTargetReached?: (messageId: string) => void;
};
@@ -134,6 +159,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
searchActiveMessageId = null,
searchMatchingMessageIds,
searchQuery,
renderAllWhileSearching = RENDER_ALL_WHILE_SEARCHING_DEFAULT,
targetMessageId = null,
onTargetReached,
}: MessageTimelineProps) {
@@ -158,53 +184,115 @@ export const MessageTimeline = React.memo(function MessageTimeline({
? `message-timeline:${channelId ?? "none"}:target:${targetMessageId}`
: `message-timeline:${channelId ?? "none"}`;
// Filtered main-timeline entries + flat virtual rows, both off the SAME
// deferred snapshot the rows render from (the no-tearing property from
// Phase 1). `entryMessages` is what the virtual rows index into, so dropped
// thread replies never desync the row→entry mapping.
const entries = React.useMemo(
() => buildMainTimelineEntries(deferredMessages),
[deferredMessages],
);
const entryMessages = React.useMemo(
() => entries.map((entry) => entry.message),
[entries],
);
const rows = React.useMemo(
() => buildVirtualTimelineRows(entryMessages),
[entryMessages],
);
// When the render-all escape hatch is enabled AND find is open, expand the
// overscan to the whole list so native browser cmd+F can see every match.
// Default path keeps the lean fixed overscan.
const isSearchOpen = Boolean(searchQuery || searchActiveMessageId);
const overscan =
renderAllWhileSearching && isSearchOpen && rows.length > 0
? rows.length
: VIRTUAL_OVERSCAN;
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: (index) =>
rows[index]?.kind === "day-divider"
? ESTIMATED_DIVIDER_HEIGHT
: ESTIMATED_MESSAGE_HEIGHT,
// Stable per-row identity. THIS is what lets a top-prepend (older page)
// retain scroll position natively — surviving rows keep their key, so the
// measurement cache survives and the virtualizer re-anchors itself. No
// before/after scrollHeight delta math, no double-rAF correction.
getItemKey: (index) => rows[index]?.key ?? index,
overscan,
});
const {
bottomAnchorRef,
contentRef,
highlightedMessageId,
isAtBottom,
newMessageCount,
restoreScrollPosition,
scrollToBottom,
syncScrollState,
} = useTimelineScrollManager({
} = useVirtualTimelineScroll({
channelId,
isLoading,
messages: deferredMessages,
onTargetReached,
messages: entryMessages,
rows,
scrollContainerRef,
virtualizer,
targetMessageId,
onTargetReached,
searchActiveMessageId,
});
// Scroll to the active search match when it changes.
const prevSearchActiveRef = React.useRef<string | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: scrollContainerRef is a stable React ref
React.useEffect(() => {
if (
!searchActiveMessageId ||
searchActiveMessageId === prevSearchActiveRef.current
) {
prevSearchActiveRef.current = searchActiveMessageId;
return;
}
prevSearchActiveRef.current = searchActiveMessageId;
const videoReviewContextById = useVideoReviewContextById({
channelId,
channelName,
channelType,
isSendingVideoReviewComment,
messages: deferredMessages,
onSendVideoReviewComment,
onToggleReaction,
profiles,
});
const container = scrollContainerRef.current;
if (!container) return;
const renderContext: TimelineEntryRenderContext = {
agentPubkeys,
channelId,
channelType,
currentPubkey,
followThreadById,
highlightedMessageId,
isFollowingThreadById,
messageFooters,
onDelete,
onEdit,
onMarkUnread,
onReply,
personaLookup,
onToggleReaction,
profiles,
searchActiveMessageId,
searchMatchingMessageIds,
searchQuery,
unfollowThreadById,
videoReviewContextById,
};
const el = container.querySelector<HTMLElement>(
`[data-message-id="${searchActiveMessageId}"]`,
);
if (el) {
el.scrollIntoView({ block: "center", behavior: "smooth" });
}
}, [searchActiveMessageId]);
const renderEntry = React.useCallback(
(entry: Parameters<typeof renderTimelineEntry>[0]) =>
renderTimelineEntry(entry, renderContext),
// renderContext is rebuilt every render from the same inputs; the entry
// render reads its current values, so depending on the bundle directly
// keeps the callback in sync without a stale closure.
// biome-ignore lint/correctness/useExhaustiveDependencies: renderContext fields are the real deps
[renderContext],
);
// Pagination trigger only — the virtualizer holds scroll position on prepend
// natively (stable keys), so there is no position-restore plumbing to pass.
useLoadOlderOnScroll({
fetchOlder,
hasOlderMessages,
isLoading,
restoreScrollPosition,
scrollContainerRef,
sentinelRef: topSentinelRef,
});
@@ -244,7 +332,6 @@ export const MessageTimeline = React.memo(function MessageTimeline({
channelChrome.contentPadding,
(showIntro || showGenericEmpty) && "min-h-full",
)}
ref={contentRef}
>
<div ref={topSentinelRef} aria-hidden className="h-px" />
@@ -398,7 +485,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
{showMessageList ? (
<div
className={cn(
"flex flex-col gap-2",
"flex flex-col",
!showIntro && "mt-auto",
// While a deferred render is in flight the painted
// list lags the latest `messages`. Dim it slightly so the
@@ -407,36 +494,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({
)}
data-render-pending={isRenderPending ? "true" : undefined}
>
<TimelineMessageList
agentPubkeys={agentPubkeys}
channelId={channelId}
channelName={channelName}
channelType={channelType}
currentPubkey={currentPubkey}
followThreadById={followThreadById}
highlightedMessageId={highlightedMessageId}
isFollowingThreadById={isFollowingThreadById}
messageFooters={messageFooters}
messages={deferredMessages}
onDelete={onDelete}
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onReply={onReply}
isSendingVideoReviewComment={isSendingVideoReviewComment}
onSendVideoReviewComment={onSendVideoReviewComment}
onToggleReaction={onToggleReaction}
personaLookup={personaLookup}
profiles={profiles}
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
unfollowThreadById={unfollowThreadById}
<VirtualizedTimelineList
entries={entries}
renderEntry={renderEntry}
rows={rows}
virtualizer={virtualizer}
/>
</div>
) : null}
</SkeletonReveal>
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
</div>
</div>
@@ -1,279 +0,0 @@
import * as React from "react";
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
import { buildMainTimelineEntries } 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 { DayDivider } from "./DayDivider";
import { MessageRow } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
import { SystemMessageRow } from "./SystemMessageRow";
type TimelineMessageListProps = {
agentPubkeys?: ReadonlySet<string>;
channelId?: string | null;
channelName?: string;
channelType?: ChannelType | null;
currentPubkey?: string;
followThreadById?: (rootId: string) => void;
highlightedMessageId?: string | null;
isFollowingThreadById?: (rootId: string) => boolean;
messageFooters?: Record<string, React.ReactNode>;
messages: TimelineMessage[];
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onMarkUnread?: (message: TimelineMessage) => void;
onReply?: (message: TimelineMessage) => void;
isSendingVideoReviewComment?: boolean;
onSendVideoReviewComment?: (
message: TimelineMessage,
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
parentEventId?: string,
) => Promise<void>;
unfollowThreadById?: (rootId: string) => void;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
/** The message ID of the currently active find-in-channel match. */
searchActiveMessageId?: string | null;
/** Set of message IDs that match the current find-in-channel query. */
searchMatchingMessageIds?: Set<string>;
/** The current find-in-channel query string. */
searchQuery?: string;
};
export const TimelineMessageList = React.memo(function TimelineMessageList({
agentPubkeys,
channelId,
channelName,
channelType,
currentPubkey,
followThreadById,
highlightedMessageId = null,
isFollowingThreadById,
messageFooters,
messages,
onDelete,
onEdit,
onMarkUnread,
onReply,
isSendingVideoReviewComment = false,
onSendVideoReviewComment,
onToggleReaction,
personaLookup,
profiles,
searchActiveMessageId = null,
searchMatchingMessageIds,
searchQuery,
unfollowThreadById,
}: TimelineMessageListProps) {
const entries = React.useMemo(
() => buildMainTimelineEntries(messages),
[messages],
);
const reviewCommentsByRootId = React.useMemo(
() => buildVideoReviewCommentsByRootId(messages),
[messages],
);
// Contexts are memoized per message id so MessageRow/Markdown memo
// comparisons hold across unrelated timeline re-renders (typing
// indicators, presence updates) — a fresh context object per render would
// defeat the memo and re-render every video message on every pass.
const videoReviewContextById = React.useMemo(() => {
const contexts = new Map<
string,
NonNullable<ReturnType<typeof buildVideoReviewContextForMessage>>
>();
for (const message of messages) {
const comments = reviewCommentsByRootId.get(message.id) ?? [];
const context = buildVideoReviewContextForMessage({
channelId,
channelName,
channelType,
comments,
isSendingVideoReviewComment,
message,
onSendVideoReviewComment,
onToggleReaction,
profiles,
});
if (context) {
contexts.set(message.id, context);
}
}
return contexts;
}, [
channelId,
channelName,
channelType,
isSendingVideoReviewComment,
messages,
onSendVideoReviewComment,
onToggleReaction,
profiles,
reviewCommentsByRootId,
]);
const dayGroups: Array<{
key: string;
label: string;
elements: React.ReactNode[];
}> = [];
let currentDayGroup: (typeof dayGroups)[number] | null = null;
// Day-divider decision delegated to a pure, lib-tested helper: a new group
// starts at index 0 and whenever a message falls on a different calendar day
// than the one before it. We index the boundary start positions so the render
// loop below stays a straight walk while the grouping logic lives in `lib/`.
const dayGroupStartIndices = new Set(
buildDayGroupBoundaries(entries.map((entry) => entry.message)).map(
(boundary) => boundary.startIndex,
),
);
for (let i = 0; i < entries.length; i++) {
const { message, summary } = entries[i];
const messageRenderKey = message.renderKey ?? message.id;
if (dayGroupStartIndices.has(i)) {
currentDayGroup = {
key: `day-${message.createdAt}`,
label: formatDayHeading(message.createdAt),
elements: [],
};
dayGroups.push(currentDayGroup);
}
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}
agentPubkeys={agentPubkeys}
currentPubkey={currentPubkey}
onToggleReaction={onToggleReaction}
personaLookup={personaLookup}
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-1 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}
/>
{footer}
</div>,
);
} else {
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
const isSearchActive = message.id === searchActiveMessageId;
const footer = messageFooters?.[message.id] ?? null;
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>,
);
}
}
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>
));
});
@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { cleanup, render, screen } from "@testing-library/react";
import type { Virtualizer } from "@tanstack/react-virtual";
import type { VirtualTimelineRow } from "@/features/messages/lib/buildVirtualTimelineRows";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import { VirtualizedTimelineList } from "./VirtualizedTimelineList";
afterEach(cleanup);
const DAY = 24 * 60 * 60;
const DAY_1 = Math.floor(new Date(2026, 0, 1, 12, 0, 0).getTime() / 1000);
const DAY_2 = DAY_1 + DAY;
// jsdom has no layout engine, so we inject a fake virtualizer that reports all
// rows as "in view". This exercises the real row-dispatch logic — divider vs
// message, flat-index → entry mapping, the absolutely-positioned row wrappers,
// and that the injected renderEntry receives the correct entry — at the DOM
// layer, which is the part VirtualizedTimelineList owns. Pixel measurement and
// scroll math stay on the manual verification pass.
function fakeVirtualizer(
rows: VirtualTimelineRow[],
): Virtualizer<HTMLDivElement, Element> {
return {
getTotalSize: () => rows.length * 64,
getVirtualItems: () =>
rows.map((_row, index) => ({
index,
key: rows[index].key,
start: index * 64,
size: 64,
end: index * 64 + 64,
lane: 0,
})),
measureElement: () => {},
} as unknown as Virtualizer<HTMLDivElement, Element>;
}
function messageEntry(id: string, createdAt: number): MainTimelineEntry {
return {
message: {
id,
createdAt,
author: "tester",
time: "",
body: `body-${id}`,
depth: 0,
kind: 9,
},
summary: null,
};
}
// Trivial injected renderer — proves the dispatch without dragging in the heavy
// real MessageRow subtree (emoji-mart, shiki, tiptap). MessageTimeline binds the
// real `renderTimelineEntry` here in production.
function renderEntryStub(entry: MainTimelineEntry) {
return <span data-testid="entry">{entry.message.body}</span>;
}
function divider(timestamp: number): VirtualTimelineRow {
return {
kind: "day-divider",
key: `day-${timestamp}`,
headingTimestamp: timestamp,
messageIndex: -1,
};
}
test("renders a day divider row with its formatted label", () => {
const rows = [divider(DAY_1)];
render(
<VirtualizedTimelineList
entries={[]}
renderEntry={renderEntryStub}
rows={rows}
virtualizer={fakeVirtualizer(rows)}
/>,
);
assert.equal(screen.getAllByTestId("message-timeline-day-divider").length, 1);
});
test("dispatches message rows to their mapped entry, interleaved with dividers", () => {
const entries = [messageEntry("a", DAY_1), messageEntry("b", DAY_2)];
// layout: [div(day1), a, div(day2), b]
const rows: VirtualTimelineRow[] = [
divider(DAY_1),
{ kind: "message", key: "a", messageIndex: 0 },
divider(DAY_2),
{ kind: "message", key: "b", messageIndex: 1 },
];
const { container } = render(
<VirtualizedTimelineList
entries={entries}
renderEntry={renderEntryStub}
rows={rows}
virtualizer={fakeVirtualizer(rows)}
/>,
);
// Two dividers, strictly interleaved, and each message routed to its entry.
assert.equal(screen.getAllByTestId("message-timeline-day-divider").length, 2);
assert.ok(screen.getByText("body-a"));
assert.ok(screen.getByText("body-b"));
// One absolutely-positioned wrapper per virtual row (4 total).
assert.equal(container.querySelectorAll("[data-index]").length, 4);
});
test("renders nothing for an empty row list", () => {
const { container } = render(
<VirtualizedTimelineList
entries={[]}
renderEntry={renderEntryStub}
rows={[]}
virtualizer={fakeVirtualizer([])}
/>,
);
assert.equal(container.querySelectorAll("[data-index]").length, 0);
});
test("renders a message row's wrapper even if its entry is missing (no throw)", () => {
// A message row whose messageIndex outruns the entries array (transient
// snapshot skew) must not throw — it renders an empty wrapper.
const rows: VirtualTimelineRow[] = [
{ kind: "message", key: "ghost", messageIndex: 0 },
];
const { container } = render(
<VirtualizedTimelineList
entries={[]}
renderEntry={renderEntryStub}
rows={rows}
virtualizer={fakeVirtualizer(rows)}
/>,
);
assert.equal(container.querySelectorAll("[data-index]").length, 1);
assert.equal(screen.queryByTestId("entry"), null);
});
@@ -0,0 +1,80 @@
import * as React from "react";
import type { Virtualizer } from "@tanstack/react-virtual";
import type { VirtualTimelineRow } from "@/features/messages/lib/buildVirtualTimelineRows";
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import { DayDivider } from "./DayDivider";
type VirtualizedTimelineListProps = {
virtualizer: Virtualizer<HTMLDivElement, Element>;
rows: VirtualTimelineRow[];
/** Filtered main-timeline entries, indexed by `VirtualMessageRow.messageIndex`. */
entries: MainTimelineEntry[];
/**
* Renders one message entry's content. Injected (rather than imported) so the
* heavy `MessageRow` subtree stays out of this component's concern and the
* list is testable in isolation. `MessageTimeline` passes the real
* `renderTimelineEntry` bound to its render context.
*/
renderEntry: (entry: MainTimelineEntry) => React.ReactNode;
};
/**
* Presentational virtualized main timeline. Renders only the rows in view
* (+overscan) from the flat `rows` list, reusing `DayDivider` and the injected
* `renderEntry`, so the painted output matches the classic nested list exactly.
*
* The virtualizer and its scroll behavior are owned by the parent
* (`MessageTimeline` + `useVirtualTimelineScroll`); this component just paints
* the spacer and absolutely-positioned rows the virtualizer reports.
*/
export const VirtualizedTimelineList = React.memo(
function VirtualizedTimelineList({
virtualizer,
rows,
entries,
renderEntry,
}: VirtualizedTimelineListProps) {
const virtualItems = virtualizer.getVirtualItems();
return (
<div
style={{
height: virtualizer.getTotalSize(),
width: "100%",
position: "relative",
}}
>
{virtualItems.map((virtualItem) => {
const row = rows[virtualItem.index];
if (!row) {
return null;
}
const entry =
row.kind === "message" ? entries[row.messageIndex] : undefined;
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItem.start}px)`,
}}
>
{row.kind === "day-divider" ? (
<DayDivider label={formatDayHeading(row.headingTimestamp)} />
) : entry ? (
renderEntry(entry)
) : null}
</div>
);
})}
</div>
);
},
);
@@ -0,0 +1,180 @@
import type * as React from "react";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { buildVideoReviewContextForMessage } from "@/features/messages/lib/videoReviewContext";
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 { MessageRow } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
import { SystemMessageRow } from "./SystemMessageRow";
type VideoReviewContext = NonNullable<
ReturnType<typeof buildVideoReviewContextForMessage>
>;
/**
* Everything a single timeline entry needs to render itself, independent of how
* the surrounding list is laid out. Both the nested day-grouped list
* (`TimelineMessageList`) and the flat virtualized list
* (`VirtualizedTimelineList`) build this once and reuse `renderTimelineEntry`,
* so the three row variants (system / thread-summary / plain) and their
* highlight + search styling stay a single source of truth.
*/
export type TimelineEntryRenderContext = {
agentPubkeys?: ReadonlySet<string>;
channelId?: string | null;
currentPubkey?: string;
followThreadById?: (rootId: string) => void;
highlightedMessageId?: string | null;
isFollowingThreadById?: (rootId: string) => boolean;
messageFooters?: Record<string, React.ReactNode>;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onMarkUnread?: (message: TimelineMessage) => void;
onReply?: (message: TimelineMessage) => void;
personaLookup?: Map<string, string>;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
profiles?: UserProfileLookup;
searchActiveMessageId?: string | null;
searchMatchingMessageIds?: Set<string>;
searchQuery?: string;
unfollowThreadById?: (rootId: string) => void;
videoReviewContextById: Map<string, VideoReviewContext>;
/** Present only when the list passes `channelType` through to video review. */
channelType?: ChannelType | null;
};
/**
* Render the row content for one main-timeline entry. Returns the inner element
* (no day-group wrapper) so callers control list layout — nested `<section>`s in
* the classic list, absolutely-positioned virtual rows in the virtualized list.
*/
export function renderTimelineEntry(
entry: MainTimelineEntry,
ctx: TimelineEntryRenderContext,
): React.ReactNode {
const { message, summary } = entry;
const footer = ctx.messageFooters?.[message.id] ?? null;
if (message.kind === KIND_SYSTEM_MESSAGE) {
return (
<div className="flex flex-col gap-1">
<SystemMessageRow
message={message}
agentPubkeys={ctx.agentPubkeys}
currentPubkey={ctx.currentPubkey}
onToggleReaction={ctx.onToggleReaction}
personaLookup={ctx.personaLookup}
profiles={ctx.profiles}
/>
{footer}
</div>
);
}
if (summary && ctx.onReply) {
const isHighlighted = message.id === ctx.highlightedMessageId;
return (
<div
className={cn(
"group/message relative -mx-1 flex flex-col gap-0 rounded-2xl px-1 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={ctx.agentPubkeys}
channelId={ctx.channelId}
highlighted={false}
hoverBackground={false}
isFollowingThread={
ctx.isFollowingThreadById
? ctx.isFollowingThreadById(message.id)
: undefined
}
message={message}
onDelete={
ctx.onDelete &&
ctx.currentPubkey &&
message.pubkey === ctx.currentPubkey
? ctx.onDelete
: undefined
}
onEdit={
ctx.onEdit &&
ctx.currentPubkey &&
message.pubkey === ctx.currentPubkey
? ctx.onEdit
: undefined
}
onFollowThread={
ctx.followThreadById
? () => ctx.followThreadById?.(message.id)
: undefined
}
onMarkUnread={ctx.onMarkUnread}
onToggleReaction={ctx.onToggleReaction}
onReply={ctx.onReply}
onUnfollowThread={
ctx.unfollowThreadById
? () => ctx.unfollowThreadById?.(message.id)
: undefined
}
profiles={ctx.profiles}
showDepthGuides={false}
videoReviewContext={ctx.videoReviewContextById.get(message.id)}
/>
<MessageThreadSummaryRow
depth={message.depth}
message={message}
onOpenThread={ctx.onReply}
showDepthGuides={false}
summary={summary}
/>
{footer}
</div>
);
}
const isSearchMatch = ctx.searchMatchingMessageIds?.has(message.id) ?? false;
const isSearchActive = message.id === ctx.searchActiveMessageId;
return (
<div className="flex flex-col gap-1">
<MessageRow
agentPubkeys={ctx.agentPubkeys}
channelId={ctx.channelId}
highlighted={message.id === ctx.highlightedMessageId || isSearchActive}
message={message}
onDelete={
ctx.onDelete &&
ctx.currentPubkey &&
message.pubkey === ctx.currentPubkey
? ctx.onDelete
: undefined
}
onEdit={
ctx.onEdit &&
ctx.currentPubkey &&
message.pubkey === ctx.currentPubkey
? ctx.onEdit
: undefined
}
onMarkUnread={ctx.onMarkUnread}
onToggleReaction={ctx.onToggleReaction}
onReply={ctx.onReply}
profiles={ctx.profiles}
searchQuery={isSearchMatch ? ctx.searchQuery : undefined}
showDepthGuides={false}
videoReviewContext={ctx.videoReviewContextById.get(message.id)}
/>
{footer}
</div>
);
}
@@ -4,29 +4,28 @@ type UseLoadOlderOnScrollOptions = {
fetchOlder?: () => Promise<void>;
hasOlderMessages: boolean;
isLoading: boolean;
restoreScrollPosition: (scrollTop: number) => void;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
sentinelRef: React.RefObject<HTMLDivElement | 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.
* container enters the viewport, then re-arms once the fetch settles.
*
* No scroll-position restoration: the virtualizer keys every row by stable
* identity, so when an older page splices in at the top the surviving rows keep
* their measurement and the viewport re-anchors natively. The old
* `previousHeight`/`previousScrollTop` snapshot + double-`requestAnimationFrame`
* `scrollTop` correction is GONE — stable-key retention replaces it (see
* `buildVirtualTimelineRows`).
*/
export function useLoadOlderOnScroll({
fetchOlder,
hasOlderMessages,
isLoading,
restoreScrollPosition,
scrollContainerRef,
sentinelRef,
}: UseLoadOlderOnScrollOptions) {
const restoreScrollPositionRef = React.useRef(restoreScrollPosition);
React.useEffect(() => {
restoreScrollPositionRef.current = restoreScrollPosition;
});
React.useEffect(() => {
const sentinel = sentinelRef.current;
const container = scrollContainerRef.current;
@@ -56,19 +55,10 @@ export function useLoadOlderOnScroll({
currentObserver?.disconnect();
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();
});
});
// Re-arm for the next page. The virtualizer holds scroll position
// across the prepend on its own, so there is nothing to restore.
observe();
});
},
{ root: container, rootMargin: "200px 0px 0px 0px" },
@@ -0,0 +1,91 @@
import * as React from "react";
import {
buildVideoReviewCommentsByRootId,
buildVideoReviewContextForMessage,
} from "@/features/messages/lib/videoReviewContext";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
type VideoReviewContext = NonNullable<
ReturnType<typeof buildVideoReviewContextForMessage>
>;
type UseVideoReviewContextByIdOptions = {
channelId?: string | null;
channelName?: string;
channelType?: ChannelType | null;
isSendingVideoReviewComment?: boolean;
messages: TimelineMessage[];
onSendVideoReviewComment?: (
message: TimelineMessage,
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
parentEventId?: string,
) => Promise<void>;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
profiles?: UserProfileLookup;
};
/**
* Build the per-message video-review context map, memoized by message id so
* MessageRow/Markdown memo comparisons hold across unrelated timeline
* re-renders (typing indicators, presence). A fresh context object per render
* would defeat the memo and re-render every video message on every pass.
*
* Extracted so the classic nested list and the virtualized list build it
* identically — one source of truth for the video-review wiring.
*/
export function useVideoReviewContextById({
channelId,
channelName,
channelType,
isSendingVideoReviewComment = false,
messages,
onSendVideoReviewComment,
onToggleReaction,
profiles,
}: UseVideoReviewContextByIdOptions): Map<string, VideoReviewContext> {
const reviewCommentsByRootId = React.useMemo(
() => buildVideoReviewCommentsByRootId(messages),
[messages],
);
return React.useMemo(() => {
const contexts = new Map<string, VideoReviewContext>();
for (const message of messages) {
const comments = reviewCommentsByRootId.get(message.id) ?? [];
const context = buildVideoReviewContextForMessage({
channelId,
channelName,
channelType,
comments,
isSendingVideoReviewComment,
message,
onSendVideoReviewComment,
onToggleReaction,
profiles,
});
if (context) {
contexts.set(message.id, context);
}
}
return contexts;
}, [
channelId,
channelName,
channelType,
isSendingVideoReviewComment,
messages,
onSendVideoReviewComment,
onToggleReaction,
profiles,
reviewCommentsByRootId,
]);
}
@@ -0,0 +1,143 @@
import assert from "node:assert/strict";
import { afterEach, mock, test } from "node:test";
import type * as React from "react";
import { act, cleanup, renderHook } from "@testing-library/react";
import type { Virtualizer } from "@tanstack/react-virtual";
import { buildVirtualTimelineRows } from "@/features/messages/lib/buildVirtualTimelineRows";
import type { TimelineMessage } from "@/features/messages/types";
import { useVirtualTimelineScroll } from "./useVirtualTimelineScroll";
afterEach(cleanup);
const DAY_1 = Math.floor(new Date(2026, 0, 1, 12, 0, 0).getTime() / 1000);
function message(
overrides: Partial<TimelineMessage> & { id: string },
): TimelineMessage {
return {
createdAt: DAY_1,
author: "tester",
time: "",
body: `body-${overrides.id}`,
depth: 0,
kind: 9,
...overrides,
};
}
// Records scrollToIndex calls so we can assert the virtualizer is driven
// correctly. The hook never reads layout off the virtualizer directly.
function makeVirtualizer() {
const scrollToIndex =
mock.fn<
(index: number, opts?: { align?: string; behavior?: string }) => void
>();
return {
scrollToIndex,
virtualizer: { scrollToIndex } as unknown as Virtualizer<
HTMLDivElement,
Element
>,
};
}
// A fake scroll container whose scroll metrics we control. `isNearBottom`
// reads scrollHeight/clientHeight/scrollTop; defaults (0) read as "at bottom".
function makeContainerRef(atBottom: boolean) {
const el = {
scrollHeight: atBottom ? 100 : 1000,
clientHeight: 100,
scrollTop: atBottom ? 0 : 0,
} as unknown as HTMLDivElement;
return { current: el } as React.RefObject<HTMLDivElement | null>;
}
test("on init with no deep-link target, scrolls to the last row (sticky bottom)", () => {
const messages = [message({ id: "a" }), message({ id: "b" })];
const rows = buildVirtualTimelineRows(messages);
const { scrollToIndex, virtualizer } = makeVirtualizer();
renderHook(() =>
useVirtualTimelineScroll({
channelId: "c1",
isLoading: false,
messages,
rows,
scrollContainerRef: makeContainerRef(true),
virtualizer,
}),
);
// layout: [div, a, b] -> last index 2
assert.ok(scrollToIndex.mock.calls.length >= 1);
const [index, opts] = scrollToIndex.mock.calls[0].arguments;
assert.equal(index, rows.length - 1);
assert.equal(opts?.align, "end");
});
test("a new latest message while pinned autoscrolls; accent uses smooth", () => {
const initial = [message({ id: "a" })];
const rows1 = buildVirtualTimelineRows(initial);
const { scrollToIndex, virtualizer } = makeVirtualizer();
const { rerender } = renderHook(
({ messages, rows }) =>
useVirtualTimelineScroll({
channelId: "c1",
isLoading: false,
messages,
rows,
scrollContainerRef: makeContainerRef(true),
virtualizer,
}),
{ initialProps: { messages: initial, rows: rows1 } },
);
scrollToIndex.mock.resetCalls();
const next = [message({ id: "a" }), message({ id: "b", accent: true })];
const rows2 = buildVirtualTimelineRows(next);
act(() => {
rerender({ messages: next, rows: rows2 });
});
assert.ok(scrollToIndex.mock.calls.length >= 1);
const lastCall = scrollToIndex.mock.calls.at(-1);
assert.equal(lastCall?.arguments[0], rows2.length - 1);
assert.equal(lastCall?.arguments[1]?.behavior, "smooth");
});
test("a deep-link target scrolls to that message's flat row and centers it", () => {
const messages = [
message({ id: "a" }),
message({ id: "b" }),
message({ id: "c" }),
];
const rows = buildVirtualTimelineRows(messages);
const { scrollToIndex, virtualizer } = makeVirtualizer();
const onTargetReached = mock.fn();
renderHook(() =>
useVirtualTimelineScroll({
channelId: "c1",
isLoading: false,
messages,
rows,
scrollContainerRef: makeContainerRef(false),
virtualizer,
targetMessageId: "b",
onTargetReached,
}),
);
// layout: [div, a, b, c] -> 'b' is flat index 2
const centerCall = scrollToIndex.mock.calls.find(
(call) => call.arguments[1]?.align === "center",
);
assert.ok(centerCall, "expected a centered scroll to the deep-link target");
assert.equal(centerCall?.arguments[0], 2);
assert.equal(onTargetReached.mock.calls.length, 1);
assert.equal(onTargetReached.mock.calls[0].arguments[0], "b");
});
@@ -0,0 +1,260 @@
import * as React from "react";
import type { Virtualizer } from "@tanstack/react-virtual";
import {
findVirtualRowIndexForMessage,
type VirtualTimelineRow,
} from "@/features/messages/lib/buildVirtualTimelineRows";
import {
isNearBottom,
resolveDeepLinkTarget,
selectLatestMessageKey,
} from "@/features/messages/lib/timelineSnapshot";
import type { TimelineMessage } from "@/features/messages/types";
type UseVirtualTimelineScrollOptions = {
channelId?: string | null;
isLoading: boolean;
/** The deferred message snapshot the virtual rows are built from. */
messages: TimelineMessage[];
/** Flat virtual rows (dividers + messages) the virtualizer renders. */
rows: VirtualTimelineRow[];
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
virtualizer: Virtualizer<HTMLDivElement, Element>;
targetMessageId?: string | null;
onTargetReached?: (messageId: string) => void;
/** The currently active find-in-channel match, drives scroll-to-row. */
searchActiveMessageId?: string | null;
};
/**
* Scroll behavior for the virtualized main timeline. This REPLACES the bespoke
* `useTimelineScrollManager`: the virtualizer owns the scroll container and all
* measurement/anchoring, so the 400-line scrollTop-locking machinery
* (`lockedScrollTopRef`, double-rAF restore, ResizeObserver re-pinning) is gone.
*
* What this hook keeps as a THIN wrapper layered on the virtualizer:
* - sticky-bottom autoscroll (`scrollToIndex(last, end)` when pinned)
* - `accent` smooth-scroll for highlighted messages
* - the `newMessageCount` "N new messages" pill when scrolled up
* - `isAtBottom` for the jump-to-latest affordance
* - deep-link + find-in-page jumps via `findVirtualRowIndexForMessage`
*
* Native key-stable retention (see `buildVirtualTimelineRows`) handles
* scroll-up prepend, so there is no position-restore plumbing here.
*/
export function useVirtualTimelineScroll({
channelId,
isLoading,
messages,
rows,
scrollContainerRef,
virtualizer,
targetMessageId,
onTargetReached,
searchActiveMessageId,
}: UseVirtualTimelineScrollOptions) {
const stickToBottomRef = React.useRef(true);
const hasInitializedRef = React.useRef(false);
const previousLastMessageKeyRef = React.useRef<string | undefined>(undefined);
const previousMessageCountRef = React.useRef(0);
const handledTargetMessageIdRef = React.useRef<string | null>(null);
const handledSearchActiveIdRef = React.useRef<string | null>(null);
const [isAtBottom, setIsAtBottom] = React.useState(true);
const [newMessageCount, setNewMessageCount] = React.useState(0);
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
string | null
>(null);
const lastRowIndex = rows.length - 1;
const scrollToBottom = React.useCallback(
(behavior: ScrollBehavior) => {
if (lastRowIndex < 0) {
return;
}
stickToBottomRef.current = true;
setNewMessageCount(0);
setIsAtBottom(true);
virtualizer.scrollToIndex(lastRowIndex, { align: "end", behavior });
},
[lastRowIndex, virtualizer],
);
// Reset all scroll state when the channel changes.
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger
React.useLayoutEffect(() => {
hasInitializedRef.current = false;
stickToBottomRef.current = true;
previousLastMessageKeyRef.current = undefined;
previousMessageCountRef.current = 0;
handledTargetMessageIdRef.current = null;
handledSearchActiveIdRef.current = null;
setIsAtBottom(true);
setNewMessageCount(0);
setHighlightedMessageId(null);
}, [channelId]);
// Track bottom-pinned state off the native scroll event. The virtualizer owns
// the scrollTop; we only read it to decide whether to keep auto-following.
const syncScrollState = React.useCallback(() => {
const container = scrollContainerRef.current;
if (!container) {
return;
}
const atBottom = isNearBottom(container);
stickToBottomRef.current = atBottom;
setIsAtBottom((current) => (current === atBottom ? current : atBottom));
if (atBottom) {
setNewMessageCount(0);
}
}, [scrollContainerRef]);
const latestMessage =
messages.length > 0 ? messages[messages.length - 1] : undefined;
const latestMessageKey = selectLatestMessageKey(messages);
// Initial pin + new-message autoscroll. On first commit, jump to bottom (or
// stay put for a deep-link). Afterwards, when a NEW latest message arrives:
// autoscroll if pinned or accented, otherwise bump the "N new messages" pill.
React.useLayoutEffect(() => {
if (!hasInitializedRef.current) {
if (isLoading) {
return;
}
if (!targetMessageId) {
scrollToBottom("auto");
}
hasInitializedRef.current = true;
previousLastMessageKeyRef.current = latestMessageKey;
previousMessageCountRef.current = messages.length;
return;
}
const previousLastMessageKey = previousLastMessageKeyRef.current;
const previousMessageCount = previousMessageCountRef.current;
const hasNewLatestMessage =
latestMessage !== undefined &&
latestMessageKey !== previousLastMessageKey;
if (!hasNewLatestMessage) {
previousLastMessageKeyRef.current = latestMessageKey;
previousMessageCountRef.current = messages.length;
return;
}
if (
!targetMessageId &&
(stickToBottomRef.current || latestMessage.accent)
) {
scrollToBottom(latestMessage.accent ? "smooth" : "auto");
} else {
setNewMessageCount((current) => {
const addedMessages = Math.max(
1,
messages.length - previousMessageCount,
);
return current + addedMessages;
});
}
previousLastMessageKeyRef.current = latestMessageKey;
previousMessageCountRef.current = messages.length;
}, [
isLoading,
latestMessage,
latestMessageKey,
messages.length,
scrollToBottom,
targetMessageId,
]);
// Deep-link jump-to-message. Drives the virtualizer to mount and center the
// target row, replacing the bespoke querySelector + scrollIntoView path that
// breaks under virtualization (the row may be unmounted).
React.useEffect(() => {
if (!targetMessageId) {
handledTargetMessageIdRef.current = null;
setHighlightedMessageId(null);
return;
}
if (handledTargetMessageIdRef.current === targetMessageId || isLoading) {
return;
}
// Only attempt once the target exists in THIS snapshot — same "bail and wait
// for the next snapshot" contract the classic manager honored, which closes
// the tearing race.
if (!resolveDeepLinkTarget(messages, targetMessageId).resolved) {
return;
}
const rowIndex = findVirtualRowIndexForMessage(
rows,
targetMessageId,
messages,
);
if (rowIndex === -1) {
return;
}
handledTargetMessageIdRef.current = targetMessageId;
stickToBottomRef.current = false;
setIsAtBottom(false);
setHighlightedMessageId(targetMessageId);
setNewMessageCount(0);
virtualizer.scrollToIndex(rowIndex, { align: "center" });
onTargetReached?.(targetMessageId);
const timeout = window.setTimeout(() => {
setHighlightedMessageId((current) =>
current === targetMessageId ? null : current,
);
}, 2_000);
return () => {
window.clearTimeout(timeout);
};
}, [
isLoading,
messages,
onTargetReached,
rows,
targetMessageId,
virtualizer,
]);
// Find-in-page: scroll the active search match into existence. Same
// scrollToIndex bridge as deep-link; the row may be unmounted under
// virtualization, so native browser find can't reach it.
React.useEffect(() => {
if (
!searchActiveMessageId ||
searchActiveMessageId === handledSearchActiveIdRef.current
) {
handledSearchActiveIdRef.current = searchActiveMessageId ?? null;
return;
}
handledSearchActiveIdRef.current = searchActiveMessageId;
const rowIndex = findVirtualRowIndexForMessage(
rows,
searchActiveMessageId,
messages,
);
if (rowIndex === -1) {
return;
}
stickToBottomRef.current = false;
virtualizer.scrollToIndex(rowIndex, {
align: "center",
behavior: "smooth",
});
}, [messages, rows, searchActiveMessageId, virtualizer]);
return {
highlightedMessageId,
isAtBottom,
newMessageCount,
scrollToBottom,
syncScrollState,
};
}
+13
View File
@@ -53,6 +53,19 @@ export function resolve(specifier, context, nextResolve) {
}
export async function load(url, context, nextLoad) {
if (url.endsWith(".json")) {
// Node's ESM loader requires an explicit `with { type: "json" }` import
// attribute for JSON, but esbuild emits bare `import x from "./f.json"`.
// Rather than rewrite every importer, synthesize an ESM module that
// default-exports the parsed JSON so the bare import resolves.
const filename = fileURLToPath(url);
const json = await readFile(filename, "utf8");
return {
format: "module",
source: `export default ${json};`,
shortCircuit: true,
};
}
if (url.endsWith(".ts") || url.endsWith(".tsx")) {
const filename = fileURLToPath(url);
const source = await readFile(filename, "utf8");
+1
View File
@@ -25,5 +25,6 @@
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"exclude": ["src/**/*.dom.test.tsx"],
"references": [{ "path": "./tsconfig.node.json" }]
}