From bdc017e60963013d84c96392aa6a39d0a913e646 Mon Sep 17 00:00:00 2001 From: byGalax Date: Tue, 2 Jun 2026 21:47:15 +0200 Subject: [PATCH] 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. --- apps/desktop/src/components/MessageList.tsx | 116 ++++++++++++-------- 1 file changed, 68 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/components/MessageList.tsx b/apps/desktop/src/components/MessageList.tsx index 6e6051b..50e1b61 100644 --- a/apps/desktop/src/components/MessageList.tsx +++ b/apps/desktop/src/components/MessageList.tsx @@ -54,6 +54,13 @@ export const MessageList = forwardRef(funct const scrollElRef = useRef(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(null); @@ -77,71 +84,78 @@ export const MessageList = forwardRef(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(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(funct ref, () => ({ scrollToBottom: () => { + stickRef.current = true; atBottomRef.current = true; pinToBottom(); }, @@ -201,7 +221,7 @@ export const MessageList = forwardRef(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' }} >
{items.map((vi) => (