Message List: Suppress spurious infinite scroll triggers on Safari

In Safari the `IntersectionObserver` can deliver entries computed between
DOM mutation and the deferred scroll restore, causing the history trigger
to fire repeatedly and the viewport to jump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alexander Zinchuk 2026-05-15 18:38:01 +02:00
parent 569082537f
commit 6cff49e8b7
3 changed files with 20 additions and 1 deletions

View File

@ -271,6 +271,9 @@ const MessageList = ({
const memoUnreadDividerBeforeIdRef = useRef<number | undefined>();
const memoFocusingIdRef = useRef<number>();
const isScrollTopJustUpdatedRef = useRef(false);
// Suppresses spurious load-more triggers caused by Safari delivering stale
// `IntersectionObserver` entries between DOM mutation and scroll restore
const isReplacingHistoryRef = useRef(false);
const shouldAnimateAppearanceRef = useRef(Boolean(lastMessage));
const scrollSnapDisabledTimerRef = useRef<number>();
const typingDraftSnapTriggeredIdRef = useRef<number>();
@ -636,7 +639,10 @@ const MessageList = ({
});
useSyncEffect(
() => forceMeasure(() => rememberScrollPositionRef.current()),
() => {
isReplacingHistoryRef.current = true;
forceMeasure(() => rememberScrollPositionRef.current());
},
// This will run before modifying content and should match deps for `useLayoutEffectWithPrevDeps` below
[messageIds, isViewportNewest, rememberScrollPositionRef],
);
@ -770,6 +776,9 @@ const MessageList = ({
return () => {
resetScroll(container, Math.ceil(newScrollTop));
requestMeasure(() => {
isReplacingHistoryRef.current = false;
});
restartCurrentScrollAnimation();
scrollOffsetRef.current = Math.max(Math.ceil(scrollHeight - newScrollTop), offsetHeight);
@ -907,6 +916,7 @@ const MessageList = ({
anchorIdRef={anchorIdRef}
memoUnreadDividerBeforeIdRef={memoUnreadDividerBeforeIdRef}
memoFirstUnreadIdRef={memoFirstUnreadIdRef}
isReplacingHistoryRef={isReplacingHistoryRef}
threadId={threadId}
type={type}
isReady={isReady}

View File

@ -69,6 +69,7 @@ interface OwnProps {
anchorIdRef: { current: string | undefined };
memoUnreadDividerBeforeIdRef: { current: number | undefined };
memoFirstUnreadIdRef: { current: number | undefined };
isReplacingHistoryRef: { current: boolean };
type: MessageListType;
isReady: boolean;
hasLinkedChat: boolean | undefined;
@ -109,6 +110,7 @@ const MessageListContent = ({
anchorIdRef,
memoUnreadDividerBeforeIdRef,
memoFirstUnreadIdRef,
isReplacingHistoryRef,
type,
isReady,
hasLinkedChat,
@ -164,6 +166,7 @@ const MessageListContent = ({
isViewportNewest,
isUnread,
isReady,
isReplacingHistoryRef,
onScrollDownToggle,
onNotchToggle,
});

View File

@ -32,6 +32,7 @@ export default function useScrollHooks({
isViewportNewest,
isUnread,
isReady,
isReplacingHistoryRef,
onScrollDownToggle,
onNotchToggle,
}: {
@ -42,6 +43,7 @@ export default function useScrollHooks({
isViewportNewest: boolean;
isUnread: boolean;
isReady: boolean;
isReplacingHistoryRef: { current: boolean };
onScrollDownToggle: BooleanToVoidFunction | undefined;
onNotchToggle: AnyToVoidFunction | undefined;
}) {
@ -109,6 +111,10 @@ export default function useScrollHooks({
return;
}
if (isReplacingHistoryRef.current) {
return;
}
entries.forEach(({ isIntersecting, target }) => {
if (!isIntersecting) return;