fix(chat): stop double scroll write on prepend that resonates into oscillation

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.
This commit is contained in:
Bohdan Triapitsyn
2026-06-24 18:29:55 +03:00
parent f2d4a7833d
commit 8c551c40aa
@@ -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<boolean> => false, []);