From 6cff49e8b770f3f5a58daafbbb72a71ca602dfa0 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 15 May 2026 18:38:01 +0200 Subject: [PATCH] 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) --- src/components/middle/MessageList.tsx | 12 +++++++++++- src/components/middle/MessageListContent.tsx | 3 +++ src/components/middle/hooks/useScrollHooks.ts | 6 ++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index be5d7a41a..c7be15ac6 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -271,6 +271,9 @@ const MessageList = ({ const memoUnreadDividerBeforeIdRef = useRef(); const memoFocusingIdRef = useRef(); 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(); const typingDraftSnapTriggeredIdRef = useRef(); @@ -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} diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index cde3b2ce9..aa2281a5f 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -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, }); diff --git a/src/components/middle/hooks/useScrollHooks.ts b/src/components/middle/hooks/useScrollHooks.ts index 02c078c0c..831747590 100644 --- a/src/components/middle/hooks/useScrollHooks.ts +++ b/src/components/middle/hooks/useScrollHooks.ts @@ -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;