bdc017e609
Data from the on-screen overlay showed SCROLLABLE=YES but scrollTop=84/0 and atBottom=false: the initial pin happened before rows finished measuring, then a measurement reflow fired onScroll with the stale (top) scrollTop, flipping atBottom=false and disabling re-pinning, so the list never reached the bottom. Fix: a ResizeObserver re-pins to the true bottom as the content measures/grows; a programmatic-scroll guard makes onScroll ignore the scrolls we cause (so measurement reflows no longer flip the stick intent); overflow-anchor:none so the browser doesn't fight us; reveal waits for the height to settle. Overlay kept for one more verification pass.
301 lines
9.9 KiB
TypeScript
301 lines
9.9 KiB
TypeScript
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<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);
|
|
// 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<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 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 (
|
|
<>
|
|
<ScrollDebugOverlay
|
|
scrollElRef={scrollElRef}
|
|
getTotal={() => virtualizer.getTotalSize()}
|
|
atBottomRef={atBottomRef}
|
|
revealed={revealed}
|
|
ready={ready}
|
|
/>
|
|
<div
|
|
ref={scrollElRef}
|
|
onScroll={handleScroll}
|
|
className="min-h-0 flex-1 overflow-y-auto"
|
|
style={{ opacity: revealed ? 1 : 0, position: 'relative', overflowAnchor: 'none' }}
|
|
>
|
|
<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>
|
|
</>
|
|
);
|
|
});
|
|
|
|
interface ScrollDebugOverlayProps {
|
|
scrollElRef: RefObject<HTMLDivElement>;
|
|
getTotal: () => number;
|
|
atBottomRef: MutableRefObject<boolean>;
|
|
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 (
|
|
<div
|
|
style={{
|
|
position: 'fixed',
|
|
top: 8,
|
|
right: 8,
|
|
zIndex: 99999,
|
|
background: 'rgba(0,0,0,0.82)',
|
|
color: '#19ff8a',
|
|
font: '11px/1.4 monospace',
|
|
padding: '6px 9px',
|
|
borderRadius: 5,
|
|
pointerEvents: 'none',
|
|
whiteSpace: 'pre',
|
|
}}
|
|
>
|
|
{`scrollHeight=${sh} clientHeight=${ch}\nscrollTop=${st}\ntotalSize=${Math.round(getTotal())}\nSCROLLABLE=${sh > ch + 4 ? 'YES' : 'NO'}\natBottom=${atBottomRef.current} revealed=${revealed} ready=${ready}`}
|
|
</div>
|
|
);
|
|
}
|