feat(desktop): TanStack-Virtual MessageList with deferred reveal

This commit is contained in:
byGalax
2026-06-02 20:27:31 +02:00
parent 40f36cb182
commit 8b8d71bc4d
+182
View File
@@ -0,0 +1,182 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import {
forwardRef,
useCallback,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import { isNearBottom, isNearTop, type Anchor } from '../lib/scrollController';
import type { VirtuosoRow } from '../pages/ConversationPage';
export interface MessageListHandle {
scrollToBottom(behavior?: ScrollBehavior): void;
scrollToRow(index: number, align?: 'center' | 'end', behavior?: ScrollBehavior): void;
}
export interface MessageListProps {
rows: VirtuosoRow[];
renderRow: (index: number, row: VirtuosoRow) => ReactNode;
computeKey: (row: VirtuosoRow) => string;
/** Initial scroll target for a freshly-mounted list. */
initialAnchor: { type: 'bottom' } | { type: 'row'; index: number };
/** Reveal gate — the list stays hidden behind a spinner until true, so the
* post-paint height cascade (reactions/divider) is never visible. */
ready: boolean;
estimateRowHeight?: number;
atBottomThreshold?: number;
onReachTop?: () => void;
onAtBottomChange?: (atBottom: boolean) => void;
onTopRowChange?: (topIndex: number) => void;
}
export const MessageList = forwardRef<MessageListHandle, MessageListProps>(function MessageList(
{
rows,
renderRow,
computeKey,
initialAnchor,
ready,
estimateRowHeight = 64,
atBottomThreshold = 64,
onReachTop,
onAtBottomChange,
onTopRowChange,
},
ref,
) {
const scrollElRef = useRef<HTMLDivElement>(null);
const [revealed, setRevealed] = useState(false);
const atBottomRef = useRef(true);
// Load-older preservation: remember the first row key + scrollHeight so a
// prepend can be detected and the viewport restored.
const prevFirstKeyRef = useRef<string | null>(null);
const prevScrollHeightRef = useRef(0);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollElRef.current,
estimateSize: () => estimateRowHeight,
overscan: 8,
getItemKey: (index) => computeKey(rows[index]!),
});
const readMetrics = useCallback(() => {
const el = scrollElRef.current;
return el
? { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }
: { scrollTop: 0, scrollHeight: 0, clientHeight: 0 };
}, []);
const applyAnchor = useCallback(
(anchor: Anchor) => {
virtualizer.scrollToIndex(anchor.index, { align: anchor.align });
// Re-apply next frame: dynamic measurement settles after first paint, so a
// single scrollToIndex can land a few px off. Still hidden here → invisible.
requestAnimationFrame(() => virtualizer.scrollToIndex(anchor.index, { align: anchor.align }));
},
[virtualizer],
);
// Deferred reveal: when ready, anchor (before paint) then reveal.
useLayoutEffect(() => {
if (!ready || revealed || rows.length === 0) return;
const anchor: Anchor =
initialAnchor.type === 'bottom'
? { index: rows.length - 1, align: 'end' }
: { index: Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), align: 'start' };
applyAnchor(anchor);
atBottomRef.current = initialAnchor.type === 'bottom';
onAtBottomChange?.(atBottomRef.current);
requestAnimationFrame(() => setRevealed(true));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, rows.length]);
// Stick-to-bottom: when content grows and we were at the bottom, re-pin.
useLayoutEffect(() => {
if (!revealed) return;
if (atBottomRef.current) {
virtualizer.scrollToIndex(rows.length - 1, { align: 'end' });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows.length, virtualizer.getTotalSize()]);
// Load-older preservation: if rows were prepended (first key changed and the
// user is near the top), restore scrollTop by the height delta so the viewport
// stays put instead of jumping.
useLayoutEffect(() => {
const firstKey = rows.length > 0 ? computeKey(rows[0]!) : null;
const el = scrollElRef.current;
if (el && revealed && prevFirstKeyRef.current && firstKey !== prevFirstKeyRef.current) {
const delta = el.scrollHeight - prevScrollHeightRef.current;
if (delta > 0 && el.scrollTop < atBottomThreshold * 4) {
el.scrollTop += delta;
}
}
prevFirstKeyRef.current = firstKey;
prevScrollHeightRef.current = el?.scrollHeight ?? 0;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows]);
const handleScroll = useCallback(() => {
const m = readMetrics();
const atBottom = isNearBottom(m, atBottomThreshold);
if (atBottom !== atBottomRef.current) {
atBottomRef.current = atBottom;
onAtBottomChange?.(atBottom);
}
if (isNearTop(m, atBottomThreshold * 4)) onReachTop?.();
const first = virtualizer.getVirtualItems()[0];
if (first) onTopRowChange?.(first.index);
}, [atBottomThreshold, onAtBottomChange, onReachTop, onTopRowChange, readMetrics, virtualizer]);
useImperativeHandle(
ref,
() => ({
scrollToBottom: () => {
atBottomRef.current = true;
virtualizer.scrollToIndex(rows.length - 1, { align: 'end' });
},
scrollToRow: (index, align = 'center') => {
virtualizer.scrollToIndex(index, { align });
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[virtualizer, rows.length],
);
const items = virtualizer.getVirtualItems();
return (
<div
ref={scrollElRef}
onScroll={handleScroll}
className="h-full overflow-y-auto"
style={{ opacity: revealed ? 1 : 0, position: 'relative' }}
>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
{items.map((vi) => (
<div
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
}}
>
{renderRow(vi.index, rows[vi.index]!)}
</div>
))}
</div>
{/* 12px bottom breathing space (matches the old Virtuoso Footer). */}
<div style={{ height: 12 }} />
</div>
);
});