import { useVirtualizer } from '@tanstack/react-virtual'; import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, type MutableRefObject, type ReactNode, type RefObject, } from 'react'; import { isNearBottom, isNearTop } 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(function MessageList( { rows, renderRow, computeKey, initialAnchor, ready, estimateRowHeight = 64, atBottomThreshold = 64, onReachTop, onAtBottomChange, onTopRowChange, }, ref, ) { const scrollElRef = useRef(null); const [revealed, setRevealed] = useState(false); const atBottomRef = useRef(true); // Intent: keep the view pinned to the bottom? Only a genuine user scroll flips // this — measurement reflows must not. const stickRef = useRef(true); // Guard: scroll events we cause (pin / measure re-pin) fire a tick after we set // scrollTop. handleScroll ignores events within this window so a measurement // reflow is never mistaken for the user scrolling up. const programmaticRef = useRef(0); // Load-older preservation: remember the first row key + scrollHeight so a // prepend can be detected and the viewport restored. const prevFirstKeyRef = useRef(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 pinToBottom = useCallback(() => { const el = scrollElRef.current; if (!el) return; programmaticRef.current = performance.now(); el.scrollTop = el.scrollHeight; }, []); // Re-pin to the true bottom whenever the content (or viewport) resizes while // sticking. ResizeObserver fires after layout / before paint, so as rows measure // and the list grows the bottom stays pinned with no stale frame — THIS is what // carries the list to the bottom through the measure settle (the old // getTotalSize effect lagged a frame, letting onScroll wrongly flip atBottom). useEffect(() => { const el = scrollElRef.current; if (!el) return; const ro = new ResizeObserver(() => { const e = scrollElRef.current; if (stickRef.current && e) { programmaticRef.current = performance.now(); e.scrollTop = e.scrollHeight; } }); ro.observe(el); const inner = el.firstElementChild; if (inner) ro.observe(inner); return () => ro.disconnect(); }, []); // Deferred reveal: when ready, pin to the anchor and keep pinning each frame // until the list height has SETTLED (rows measure over 1-2 frames and grow it), // THEN reveal — so what appears is already at the final bottom, with no // top-then-jump flicker. The ResizeObserver above keeps it pinned afterwards. useLayoutEffect(() => { if (!ready || revealed || rows.length === 0) return; const el = scrollElRef.current; if (!el) return; const rowIdx = Math.max( 0, Math.min(initialAnchor.type === 'row' ? initialAnchor.index : 0, rows.length - 1), ); if (initialAnchor.type === 'bottom') { stickRef.current = true; atBottomRef.current = true; pinToBottom(); } else { stickRef.current = false; atBottomRef.current = false; virtualizer.scrollToIndex(rowIdx, { align: 'start' }); } onAtBottomChange?.(atBottomRef.current); let prevSH = -1; const settle = (attempts: number): void => { const e = scrollElRef.current; if (!e) { setRevealed(true); return; } if (stickRef.current) { programmaticRef.current = performance.now(); e.scrollTop = e.scrollHeight; } else { virtualizer.scrollToIndex(rowIdx, { align: 'start' }); } const sh = e.scrollHeight; if (sh === prevSH || attempts <= 0) { setRevealed(true); } else { prevSH = sh; requestAnimationFrame(() => settle(attempts - 1)); } }; requestAnimationFrame(() => settle(10)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, rows.length]); // 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(() => { // Ignore scroll events we triggered (pin / measure re-pin); they fire a tick // after we set scrollTop. Only a genuine user scroll updates the stick intent — // otherwise a measurement reflow wrongly flips atBottom and stops the pinning. if (performance.now() - programmaticRef.current < 120) return; const m = readMetrics(); const atBottom = isNearBottom(m, atBottomThreshold); stickRef.current = atBottom; 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: () => { stickRef.current = true; atBottomRef.current = true; pinToBottom(); }, scrollToRow: (index, align = 'center') => { virtualizer.scrollToIndex(index, { align }); }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [virtualizer, rows.length], ); const items = virtualizer.getVirtualItems(); return ( <> virtualizer.getTotalSize()} atBottomRef={atBottomRef} revealed={revealed} ready={ready} />
{items.map((vi) => (
{renderRow(vi.index, rows[vi.index]!)}
))}
{/* 12px bottom breathing space (matches the old Virtuoso Footer). */}
); }); interface ScrollDebugOverlayProps { scrollElRef: RefObject; getTotal: () => number; atBottomRef: MutableRefObject; revealed: boolean; ready: boolean; } // TEMP on-screen debug readout — remove after the chat-switch scroll bug is fixed. function ScrollDebugOverlay({ scrollElRef, getTotal, atBottomRef, revealed, ready, }: ScrollDebugOverlayProps) { const [, force] = useState(0); useEffect(() => { let raf = 0; const loop = () => { force((n) => (n + 1) % 1_000_000); raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); return () => cancelAnimationFrame(raf); }, []); const el = scrollElRef.current; const sh = el?.scrollHeight ?? 0; const ch = el?.clientHeight ?? 0; const st = el ? Math.round(el.scrollTop) : 0; return (
{`scrollHeight=${sh} clientHeight=${ch}\nscrollTop=${st}\ntotalSize=${Math.round(getTotal())}\nSCROLLABLE=${sh > ch + 4 ? 'YES' : 'NO'}\natBottom=${atBottomRef.current} revealed=${revealed} ready=${ready}`}
); }