fix(desktop): MessageList sticks to bottom via ResizeObserver + scroll guard
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.
This commit is contained in:
@@ -54,6 +54,13 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
||||
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);
|
||||
@@ -77,71 +84,78 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
||||
const pinToBottom = useCallback(() => {
|
||||
const el = scrollElRef.current;
|
||||
if (!el) return;
|
||||
const before = el.scrollTop;
|
||||
programmaticRef.current = performance.now();
|
||||
el.scrollTop = el.scrollHeight;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[scroll] pin sh=${el.scrollHeight} ch=${el.clientHeight} top:${Math.round(before)}->${Math.round(el.scrollTop)}`,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Deferred reveal: when ready, position at the anchor (before paint), let one
|
||||
// measure cycle settle (just-rendered rows get their real heights), re-pin,
|
||||
// then reveal — so what appears is already final.
|
||||
//
|
||||
// For the bottom case we drive scrollTop = scrollHeight DIRECTLY rather than
|
||||
// virtualizer.scrollToIndex: scrollToIndex depends on the virtualizer's own
|
||||
// layout effect having run first (effect ordering is not guaranteed) and on its
|
||||
// size estimates — when it lost that race the list stayed pinned at the TOP.
|
||||
// Driving the DOM scrollTop is order-independent and always lands at the true
|
||||
// bottom; the stick-to-bottom effect re-pins as the heights settle.
|
||||
// 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;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[scroll] reveal anchor=${initialAnchor.type} rows=${rows.length} total=${Math.round(virtualizer.getTotalSize())} sh=${el.scrollHeight} ch=${el.clientHeight}`,
|
||||
const rowIdx = Math.max(
|
||||
0,
|
||||
Math.min(initialAnchor.type === 'row' ? initialAnchor.index : 0, rows.length - 1),
|
||||
);
|
||||
if (initialAnchor.type === 'bottom') {
|
||||
pinToBottom();
|
||||
stickRef.current = true;
|
||||
atBottomRef.current = true;
|
||||
pinToBottom();
|
||||
} else {
|
||||
virtualizer.scrollToIndex(Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), {
|
||||
align: 'start',
|
||||
});
|
||||
stickRef.current = false;
|
||||
atBottomRef.current = false;
|
||||
virtualizer.scrollToIndex(rowIdx, { align: 'start' });
|
||||
}
|
||||
onAtBottomChange?.(atBottomRef.current);
|
||||
requestAnimationFrame(() => {
|
||||
if (atBottomRef.current) {
|
||||
pinToBottom();
|
||||
} else if (initialAnchor.type === 'row') {
|
||||
// Re-apply after the virtualizer's own layout effect has run + measured,
|
||||
// so the saved scrolled-up row lands accurately (same ordering caveat as
|
||||
// the bottom case, handled here by deferring a frame).
|
||||
virtualizer.scrollToIndex(Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), {
|
||||
align: 'start',
|
||||
});
|
||||
let prevSH = -1;
|
||||
const settle = (attempts: number): void => {
|
||||
const e = scrollElRef.current;
|
||||
if (!e) {
|
||||
setRevealed(true);
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => setRevealed(true));
|
||||
});
|
||||
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]);
|
||||
|
||||
// Stick-to-bottom: whenever content grows (new row OR a measured row got
|
||||
// taller) and we were at the bottom, re-pin to the true bottom. Runs while
|
||||
// hidden too, so the list stays pinned through the initial measure settle.
|
||||
useLayoutEffect(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[scroll] stick? atBottom=${atBottomRef.current} total=${Math.round(virtualizer.getTotalSize())} revealed=${revealed}`,
|
||||
);
|
||||
if (!atBottomRef.current) return;
|
||||
pinToBottom();
|
||||
// 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.
|
||||
@@ -160,8 +174,13 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
||||
}, [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);
|
||||
@@ -175,6 +194,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
||||
ref,
|
||||
() => ({
|
||||
scrollToBottom: () => {
|
||||
stickRef.current = true;
|
||||
atBottomRef.current = true;
|
||||
pinToBottom();
|
||||
},
|
||||
@@ -201,7 +221,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
||||
ref={scrollElRef}
|
||||
onScroll={handleScroll}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
style={{ opacity: revealed ? 1 : 0, position: 'relative' }}
|
||||
style={{ opacity: revealed ? 1 : 0, position: 'relative', overflowAnchor: 'none' }}
|
||||
>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
|
||||
{items.map((vi) => (
|
||||
|
||||
Reference in New Issue
Block a user