fix(chat): stop scroll jiggle and double-scroll on send while pinned

Two scroll owners were writing the chat container's scrollTop concurrently
during pinned content growth and on send, fighting frame-to-frame and
producing the reported flicker/jiggle (after a pause, from the queue, on user
interruptions) plus a visible double scroll on a normal user send.

Enforce a single-writer invariant in useChatAutoFollow:
- The easing follow loop and the instant settle burst now mutually exclude:
  starting one stops the other, so they can never write scrollTop in the same
  frame. The isFollowingProgrammatically flag (which suppresses the overlay
  scrollbar) is owned by whichever loop is active and cleared only when both
  are idle, including the settle burst's natural 280ms end.

Stop the redundant re-pin storm in useChatTimelineController:
- While pinned, route goToBottom('instant') only for a prepend (history loaded
  above), not on every bottom append / streaming part. Normal growth is owned
  by the follow loop (kicked by the content ResizeObserver and chunk handlers).

Remove the double movement on send:
- Add scrollToBottomOnSend: when already following, just (re)kick the follow
  loop for a single smooth movement instead of also firing an instant
  goToBottom that raced the ResizeObserver-driven loop. When released (scrolled
  up), keep the instant jump to the just-sent message.
This commit is contained in:
Bohdan Triapitsyn
2026-06-27 00:06:19 +03:00
parent 903e8ecbb0
commit b384deb812
3 changed files with 115 additions and 60 deletions
+62 -13
View File
@@ -36,6 +36,7 @@ export interface UseChatAutoFollowResult {
notifyContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
goToBottom: (mode?: 'instant' | 'smooth') => void;
scrollToBottomOnSend: () => void;
releaseAutoFollow: () => void;
saveSnapshotNow: () => void;
restoreSnapshot: () => Promise<boolean>;
@@ -170,7 +171,21 @@ export const useChatAutoFollow = ({
}
followRafRef.current = null;
settledFramesRef.current = 0;
setIsFollowingProgrammatically(false);
// Only the active scroll-writer owns the "programmatic follow" flag. If the
// settle burst is still running it remains the owner, so don't clear here.
if (settleBurstRafRef.current === null) {
setIsFollowingProgrammatically(false);
}
}, []);
const stopSettleBurst = React.useCallback(() => {
if (settleBurstRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(settleBurstRafRef.current);
}
settleBurstRafRef.current = null;
if (followRafRef.current === null) {
setIsFollowingProgrammatically(false);
}
}, []);
const tickFollow = React.useCallback(() => {
@@ -214,12 +229,18 @@ export const useChatAutoFollow = ({
const startFollowLoop = React.useCallback(() => {
if (typeof window === 'undefined') return;
if (followRafRef.current !== null) return;
if (stateRef.current !== 'following') return;
// Single-writer invariant: never let the easing follow loop run alongside
// the instant settle burst. They both write scrollTop every frame but aim
// at different positions (the burst snaps to the exact bottom, this loop
// eases toward it), so concurrently they fight frame-to-frame and produce
// the visible up/down jiggle during pinned content growth and sends.
stopSettleBurst();
if (followRafRef.current !== null) return;
settledFramesRef.current = 0;
setIsFollowingProgrammatically(true);
followRafRef.current = window.requestAnimationFrame(tickFollow);
}, [tickFollow]);
}, [stopSettleBurst, tickFollow]);
const writeScrollTopInstant = React.useCallback((target: number) => {
const container = scrollRef.current;
@@ -231,22 +252,32 @@ export const useChatAutoFollow = ({
lastScrollTopRef.current = container.scrollTop;
}, [markProgrammaticWrite]);
const stopSettleBurst = React.useCallback(() => {
if (settleBurstRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(settleBurstRafRef.current);
}
settleBurstRafRef.current = null;
}, []);
const startSettleBurst = React.useCallback(() => {
if (typeof window === 'undefined') return;
// Single-writer invariant (mirror of startFollowLoop): the settle burst is
// taking over scroll ownership, so stop the easing follow loop first. The
// two must never write scrollTop in the same frame.
stopFollowLoop();
stopSettleBurst();
setIsFollowingProgrammatically(true);
const until = (typeof performance !== 'undefined' ? performance.now() : Date.now()) + SETTLE_BURST_DURATION_MS;
const finish = () => {
settleBurstRafRef.current = null;
if (followRafRef.current === null) {
setIsFollowingProgrammatically(false);
}
};
const tick = () => {
settleBurstRafRef.current = null;
if (stateRef.current !== 'following') return;
if (stateRef.current !== 'following') {
finish();
return;
}
const c = scrollRef.current;
if (!c) return;
if (!c) {
finish();
return;
}
const target = Math.max(0, c.scrollHeight - c.clientHeight);
if (Math.abs(c.scrollTop - target) > SETTLE_EPSILON) {
markProgrammaticWrite();
@@ -256,10 +287,12 @@ export const useChatAutoFollow = ({
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
if (now < until) {
settleBurstRafRef.current = window.requestAnimationFrame(tick);
} else {
finish();
}
};
settleBurstRafRef.current = window.requestAnimationFrame(tick);
}, [markProgrammaticWrite, stopSettleBurst]);
}, [markProgrammaticWrite, stopFollowLoop, stopSettleBurst]);
const releaseAutoFollow = React.useCallback(() => {
stopFollowLoop();
@@ -293,6 +326,21 @@ export const useChatAutoFollow = ({
startSettleBurst();
}, [setStateValue, startFollowLoop, startSettleBurst, writeScrollTopInstant]);
const scrollToBottomOnSend = React.useCallback(() => {
// Keep a SINGLE movement to the just-sent message.
// If we're already following the bottom, the optimistic message is eased
// into view by the follow loop (kicked by the content ResizeObserver). Just
// (re)kick that one owner — do NOT also fire an instant goToBottom here, or
// the instant snap races the easing loop and you see a visible double scroll
// (ease, then snap).
if (stateRef.current === 'following') {
startFollowLoop();
return;
}
// Scrolled up (released): bring the user down to the message they just sent.
goToBottom('instant');
}, [goToBottom, startFollowLoop]);
const flushSave = React.useCallback(() => {
if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current);
@@ -676,6 +724,7 @@ export const useChatAutoFollow = ({
notifyContentChange,
getAnimationHandlers,
goToBottom,
scrollToBottomOnSend,
releaseAutoFollow,
saveSnapshotNow,
restoreSnapshot,