From 8c551c40aa559bd52fc8831f34ad2dec26e3541b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 24 Jun 2026 18:29:55 +0300 Subject: [PATCH] fix(chat): stop double scroll write on prepend that resonates into oscillation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When older history is prepended while the viewport is pinned to the bottom, the timeline controller wrote the re-pin manually (scrollTop += delta). That write is not flagged as programmatic, so useChatAutoFollow's scroll handler treated it as movement and issued its own correcting scroll — a redundant up/down move on every prepend. On most setups it settles after one move, but with different virtualizer measurement/timing it never converges, producing the reported infinite up/down scroll glitch. When pinned, delegate the prepend re-pin to auto-follow's goToBottom('instant'): a single authoritative write to the bottom that IS marked programmatic, so auto-follow ignores it instead of fighting it. The released case (user reading back through history) is unchanged and still preserves the read position. This also covers the on-open history auto-load (loadEarlierIfPinnedViewport- Underfilled), which only runs while pinned, so its prepends now go through the single writer too. --- .../chat/hooks/useChatTimelineController.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index cf437015..6cad11bb 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -350,6 +350,25 @@ export const useChatTimelineController = ({ const container = scrollRef.current; if (!container) return; + // Bottom-pinned: auto-follow is the single owner of the scroll position. + // Route the prepend re-pin through goToBottom (a programmatic, authoritative + // instant write to the bottom) rather than a manual scrollTop adjustment. A + // manual write here is NOT marked programmatic, so auto-follow's scroll + // handler treats it as movement and issues its own correcting scroll — a + // redundant up/down move on every prepend that, on some setups, resonates + // into the reported infinite oscillation. Delegating keeps exactly one + // writer and no fight. + if (isPinnedRef.current) { + prePrependScrollRef.current = null; + goToBottom('instant'); + prependTrackingRef.current = { + oldestId: renderedMessages[0]?.info?.id ?? null, + newestId: renderedMessages[renderedMessages.length - 1]?.info?.id ?? null, + scrollHeight: container.scrollHeight, + }; + return; + } + const snap = prePrependScrollRef.current; if (snap) { prePrependScrollRef.current = null; @@ -395,7 +414,7 @@ export const useChatTimelineController = ({ newestId: renderedMessages[renderedMessages.length - 1]?.info?.id ?? null, scrollHeight: container.scrollHeight, }; - }, [renderedMessages, scrollRef, restoreViewportAnchor]); + }, [renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]); const revealBufferedTurns = React.useCallback(async (): Promise => false, []);