From b4ed3aced0bf4a067cfc187de189e9037388f6ee Mon Sep 17 00:00:00 2001 From: byGalax Date: Tue, 2 Jun 2026 21:00:19 +0200 Subject: [PATCH] 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. --- apps/desktop/src/components/MessageList.tsx | 71 +++++++++++++-------- 1 file changed, 46 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/components/MessageList.tsx b/apps/desktop/src/components/MessageList.tsx index b083dad..1ac7ba1 100644 --- a/apps/desktop/src/components/MessageList.tsx +++ b/apps/desktop/src/components/MessageList.tsx @@ -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(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(() => setRevealed(true)); + 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(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(funct