import { useVirtualizer } from '@tanstack/react-virtual'; import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, type ReactNode, } from 'react'; import { isNearBottom, isNearTop, nextStickIntent } 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 until reactions/heights are loaded, so * the post-paint height cascade 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); // THE single source of truth: should the view stay pinned to the bottom? // Only a genuine user up-input (wheel / key / touch / scrollbar drag) turns // this OFF; only reaching the bottom turns it ON. A measurement reflow must // never flip it — that was the root cause of the chat-switch bug. const stickRef = useRef(true); // Debounce for onAtBottomChange — fire the parent only on a real transition. const lastReportedAtBottomRef = useRef(null); // Guard: scrolls WE cause (pin / measure re-pin / scrollToIndex) fire onScroll // a tick later. Within this window we don't treat a scrollTop decrease as the // user dragging up. const programmaticRef = useRef(0); // Previous scrollTop, to detect a genuine scrollbar/keyboard up-drag. const lastScrollTopRef = 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); // Latest onAtBottomChange, read through a ref so the input-listener effect // can stay mounted once (deps []) without capturing a stale callback. const onAtBottomChangeRef = useRef(onAtBottomChange); onAtBottomChangeRef.current = onAtBottomChange; 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; lastScrollTopRef.current = el.scrollTop; }, []); // Report at-bottom to the parent only on a true transition, always driven by // the INTENT (stickRef) — never the raw position. This is what kills the // feedback loop: a transient "not at bottom" mid-reflow is never persisted. const reportAtBottom = useCallback((atBottom: boolean) => { if (lastReportedAtBottomRef.current === atBottom) return; lastReportedAtBottomRef.current = atBottom; onAtBottomChangeRef.current?.(atBottom); }, []); // A genuine user up-input: drop the stick intent immediately. const markUserMovedUp = useCallback(() => { if (!stickRef.current) return; stickRef.current = false; reportAtBottom(false); }, [reportAtBottom]); // 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. 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; lastScrollTopRef.current = e.scrollTop; } }); ro.observe(el); const inner = el.firstElementChild; if (inner) ro.observe(inner); return () => ro.disconnect(); }, []); // Genuine-user-intent listeners. These are the ONLY way (besides reaching the // bottom) the stick intent turns off, so a reflow can never unstick the list. useEffect(() => { const el = scrollElRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { if (e.deltaY < 0) markUserMovedUp(); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'PageUp' || e.key === 'Home' || e.key === 'ArrowUp') markUserMovedUp(); }; let touchStartY = 0; const onTouchStart = (e: TouchEvent) => { touchStartY = e.touches[0]?.clientY ?? 0; }; const onTouchMove = (e: TouchEvent) => { const y = e.touches[0]?.clientY ?? 0; // Finger dragged DOWN (content scrolls up toward older messages). Guard on // scrollTop>0 so an overscroll bounce at the bottom doesn't unstick. if (y - touchStartY > 8 && (scrollElRef.current?.scrollTop ?? 0) > 0) markUserMovedUp(); }; el.addEventListener('wheel', onWheel, { passive: true }); el.addEventListener('keydown', onKeyDown); el.addEventListener('touchstart', onTouchStart, { passive: true }); el.addEventListener('touchmove', onTouchMove, { passive: true }); return () => { el.removeEventListener('wheel', onWheel); el.removeEventListener('keydown', onKeyDown); el.removeEventListener('touchstart', onTouchStart); el.removeEventListener('touchmove', onTouchMove); }; }, [markUserMovedUp]); // Deferred reveal: when ready, pin to the anchor and keep pinning each frame // until the list height has SETTLED over two consecutive frames, THEN reveal — // so what appears is already at its final position with no top-then-jump. 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; pinToBottom(); } else { stickRef.current = false; programmaticRef.current = performance.now(); virtualizer.scrollToIndex(rowIdx, { align: 'start' }); } reportAtBottom(stickRef.current); let prevSH = -1; let stableFrames = 0; const settle = (attempts: number): void => { const e = scrollElRef.current; if (!e) { setRevealed(true); return; } programmaticRef.current = performance.now(); if (stickRef.current) { e.scrollTop = e.scrollHeight; lastScrollTopRef.current = e.scrollTop; } else { virtualizer.scrollToIndex(rowIdx, { align: 'start' }); } const sh = e.scrollHeight; // Require TWO consecutive stable-height frames: a single stable frame can // land mid-cascade (between reactions and the unread divider measuring) // and reveal a not-yet-final layout that then jumps. stableFrames = sh === prevSH ? stableFrames + 1 : 0; prevSH = sh; if (stableFrames >= 2 || attempts <= 0) { setRevealed(true); } else { requestAnimationFrame(() => settle(attempts - 1)); } }; requestAnimationFrame(() => settle(12)); // 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; lastScrollTopRef.current = el.scrollTop; } } prevFirstKeyRef.current = firstKey; prevScrollHeightRef.current = el?.scrollHeight ?? 0; // eslint-disable-next-line react-hooks/exhaustive-deps }, [rows]); // Re-pin on any rows change while sticking. Covers the two-phase data swap // (the cached array is replaced by the freshly-decrypted one ~100ms after // reveal) which the ResizeObserver can miss when the new content happens to // measure to the same height. useLayoutEffect(() => { if (revealed && stickRef.current) pinToBottom(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [rows]); const handleScroll = useCallback(() => { const m = readMetrics(); const programmatic = performance.now() - programmaticRef.current < 120; const nearBottom = isNearBottom(m, atBottomThreshold); // A scrollbar drag or keyboard scroll surfaces here as a scrollTop decrease. // Suppress it inside the programmatic window so our own re-pin / settle is // never mistaken for the user moving up. 2px deadzone absorbs sub-pixel jitter. const userMovedUp = !programmatic && m.scrollTop < lastScrollTopRef.current - 2; lastScrollTopRef.current = m.scrollTop; stickRef.current = nextStickIntent(stickRef.current, { nearBottom, userMovedUp }); reportAtBottom(stickRef.current); if (isNearTop(m, atBottomThreshold * 4)) onReachTop?.(); const first = virtualizer.getVirtualItems()[0]; if (first) onTopRowChange?.(first.index); }, [atBottomThreshold, onReachTop, onTopRowChange, readMetrics, reportAtBottom, virtualizer]); useImperativeHandle( ref, () => ({ scrollToBottom: () => { stickRef.current = true; reportAtBottom(true); pinToBottom(); }, scrollToRow: (index, align = 'center') => { // The user is jumping to a specific row — drop the stick intent first so // the ResizeObserver doesn't immediately drag the target back to the bottom. stickRef.current = false; reportAtBottom(false); programmaticRef.current = performance.now(); virtualizer.scrollToIndex(index, { align }); }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [virtualizer, rows.length, reportAtBottom, pinToBottom], ); const items = virtualizer.getVirtualItems(); return (
{items.map((vi) => (
{renderRow(vi.index, rows[vi.index]!)}
))}
{/* 12px bottom breathing space (matches the old Virtuoso Footer). */}
); });