fix(desktop): MessageList anchors via direct scrollTop + flex-1 height
scrollToIndex raced the virtualizer's own layout effect and depended on size estimates, leaving the list pinned at the top on open (and flickering as it settled). Drive scrollTop = scrollHeight directly for the bottom case (order-independent, true bottom) and re-pin on measure; switch the scroll root from h-full to flex-1 min-h-0 so it always has a bounded, scrollable height.
This commit is contained in:
@@ -9,7 +9,7 @@ import {
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import { isNearBottom, isNearTop, type Anchor } from '../lib/scrollController';
|
||||
import { isNearBottom, isNearTop } from '../lib/scrollController';
|
||||
import type { VirtuosoRow } from '../pages/ConversationPage';
|
||||
|
||||
export interface MessageListHandle {
|
||||
@@ -71,36 +71,57 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
||||
: { 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],
|
||||
);
|
||||
const pinToBottom = useCallback(() => {
|
||||
const el = scrollElRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, []);
|
||||
|
||||
// Deferred reveal: when ready, anchor (before paint) then reveal.
|
||||
// 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.
|
||||
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';
|
||||
const el = scrollElRef.current;
|
||||
if (!el) return;
|
||||
if (initialAnchor.type === 'bottom') {
|
||||
pinToBottom();
|
||||
atBottomRef.current = true;
|
||||
} else {
|
||||
virtualizer.scrollToIndex(Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), {
|
||||
align: 'start',
|
||||
});
|
||||
atBottomRef.current = false;
|
||||
}
|
||||
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',
|
||||
});
|
||||
}
|
||||
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.
|
||||
// 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(() => {
|
||||
if (!revealed) return;
|
||||
if (atBottomRef.current) {
|
||||
virtualizer.scrollToIndex(rows.length - 1, { align: 'end' });
|
||||
}
|
||||
if (!atBottomRef.current) return;
|
||||
pinToBottom();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows.length, virtualizer.getTotalSize()]);
|
||||
|
||||
@@ -138,7 +159,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
||||
() => ({
|
||||
scrollToBottom: () => {
|
||||
atBottomRef.current = true;
|
||||
virtualizer.scrollToIndex(rows.length - 1, { align: 'end' });
|
||||
pinToBottom();
|
||||
},
|
||||
scrollToRow: (index, align = 'center') => {
|
||||
virtualizer.scrollToIndex(index, { align });
|
||||
@@ -154,7 +175,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
||||
<div
|
||||
ref={scrollElRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-full overflow-y-auto"
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
style={{ opacity: revealed ? 1 : 0, position: 'relative' }}
|
||||
>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
|
||||
|
||||
Reference in New Issue
Block a user