fix(conversation): anchor message list to bottom on chat switch
initialTopMostItemIndex was a plain index (top-aligned), so react-virtuoso painted with estimated row heights then corrected scrollTop after measuring the real (taller) dynamic bubbles — a visible jump on every chat switch. Use { index: 'LAST', align: 'end' } to pin the bottom edge instead, matching react-virtuoso's canonical chat pattern; the restore-to-saved-row path stays align: 'start'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<number | IndexLocationWithAlign>(() => {
|
||||
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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user