diff --git a/apps/desktop/src/pages/ConversationPage.tsx b/apps/desktop/src/pages/ConversationPage.tsx index 4a2572c..72aa4bf 100644 --- a/apps/desktop/src/pages/ConversationPage.tsx +++ b/apps/desktop/src/pages/ConversationPage.tsx @@ -3,7 +3,7 @@ import { extractErrorCode } from '@chat-app/shared/i18n'; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; -import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; +import { Virtuoso, type VirtuosoHandle, type IndexLocationWithAlign } from 'react-virtuoso'; import { ComposerActionsMenu } from '../components/ComposerActionsMenu'; import { ConversationHeader } from '../components/ConversationHeader'; @@ -471,16 +471,28 @@ export function ConversationPage() { // previous visit to this chat AND the user wasn't sticking to the // bottom, restore the saved row index (clamped to the current row // count in case the cache was trimmed). - const initialTopMostIndex = useMemo(() => { + const initialTopMostIndex = useMemo(() => { const saved = savedPositionRef.current; if (saved && !saved.stickToBottom) { - return Math.max(0, Math.min(saved.topmostIndex, virtuosoRows.length - 1)); + // Restore the row the user was reading, pinned to the TOP of the + // viewport — that's the anchor the index was captured at + // (handleRangeChanged stores range.startIndex). + const idx = Math.max(0, Math.min(saved.topmostIndex, virtuosoRows.length - 1)); + return { index: idx, align: 'start' }; } - return virtuosoRows.length - 1; - // virtuosoRows.length changes when the conversation loads — that's the - // intentional trigger so a freshly-loaded chat anchors to the bottom - // on first paint. We deliberately don't re-derive this on every row - // append; Virtuoso owns scroll position from that point on. + // Bottom case (the common one): anchor the LAST row to the END (bottom) + // edge of the viewport. This is the fix for the "jumps once on chat + // switch" bug: a plain numeric index aligns the row to the TOP, so + // react-virtuoso paints with estimated row heights, then measures the + // real (taller) heights of the dynamic bubbles (avatars, attachments, + // multi-line text, reactions) and corrects scrollTop — a visible jump on + // every mount. `align: 'end'` pins the bottom edge instead, so the + // post-measurement height growth happens above the fold and the viewport + // stays put. This is react-virtuoso's canonical "start at the bottom" form. + return { index: 'LAST', align: 'end' }; + // virtuosoRows.length flipping 0 -> >0 is the intentional trigger so a + // freshly-loaded chat anchors on first paint; we deliberately don't + // re-derive on every row append — Virtuoso owns scroll position after. // eslint-disable-next-line react-hooks/exhaustive-deps }, [virtuosoRows.length > 0]);