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:
@@ -568,6 +568,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
notifyContentChange: handleMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
goToBottom,
|
||||
scrollToBottomOnSend,
|
||||
releaseAutoFollow,
|
||||
restoreSnapshot,
|
||||
isPinned,
|
||||
@@ -792,7 +793,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
: 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]'
|
||||
)}
|
||||
>
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -852,7 +853,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
: 'bg-background'
|
||||
)}
|
||||
>
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -885,7 +886,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
: 'bg-background'
|
||||
)}
|
||||
>
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -933,7 +934,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
onClick={navigation.resumeToLatest}
|
||||
/>
|
||||
)}
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
|
||||
<TimelineDialog
|
||||
|
||||
@@ -350,26 +350,52 @@ 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');
|
||||
const snap = prePrependScrollRef.current;
|
||||
const prev = prependTrackingRef.current;
|
||||
const currentOldestId = renderedMessages[0]?.info?.id ?? null;
|
||||
const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null;
|
||||
// A prepend = content inserted ABOVE the viewport: the oldest message id
|
||||
// changed while the newest stayed the same. This distinguishes a history
|
||||
// load from a bottom append, a streaming part growing, or a session switch.
|
||||
const isPrepend = Boolean(
|
||||
prev
|
||||
&& prev.oldestId
|
||||
&& currentOldestId
|
||||
&& currentOldestId !== prev.oldestId
|
||||
&& prev.newestId
|
||||
&& currentNewestId
|
||||
&& currentNewestId === prev.newestId,
|
||||
);
|
||||
|
||||
const updateTracking = () => {
|
||||
prependTrackingRef.current = {
|
||||
oldestId: renderedMessages[0]?.info?.id ?? null,
|
||||
newestId: renderedMessages[renderedMessages.length - 1]?.info?.id ?? null,
|
||||
oldestId: currentOldestId,
|
||||
newestId: currentNewestId,
|
||||
scrollHeight: container.scrollHeight,
|
||||
};
|
||||
};
|
||||
|
||||
if (isPinnedRef.current) {
|
||||
// Bottom-pinned. Only content inserted ABOVE (a prepend / history load)
|
||||
// needs an explicit re-pin: with overflow-anchor:none the browser leaves
|
||||
// scrollTop unchanged, so the viewport would visibly jump. Route that
|
||||
// through goToBottom — the single programmatic writer.
|
||||
//
|
||||
// A normal bottom APPEND (a sent message, a streaming part) must NOT
|
||||
// re-pin here. Auto-follow's own follow loop — kicked by the content
|
||||
// ResizeObserver and the streaming chunk handlers — already eases to the
|
||||
// new bottom. Calling goToBottom on every append layered its settle burst
|
||||
// on top of that loop: two writers aiming at different positions, which
|
||||
// is exactly the up/down jiggle reported on send / from the queue / while
|
||||
// streaming. So for an append we do nothing and let the follow loop own it.
|
||||
if (snap || isPrepend) {
|
||||
prePrependScrollRef.current = null;
|
||||
goToBottom('instant');
|
||||
}
|
||||
updateTracking();
|
||||
return;
|
||||
}
|
||||
|
||||
const snap = prePrependScrollRef.current;
|
||||
if (snap) {
|
||||
prePrependScrollRef.current = null;
|
||||
// When a viewport anchor is available, delegate to MessageList
|
||||
@@ -382,38 +408,17 @@ export const useChatTimelineController = ({
|
||||
container.scrollTop = snap.top + delta;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Auto-detect a prepend: the oldest message changed while the newest
|
||||
// stayed the same (distinguishes a real prepend from a session
|
||||
// switch, a bottom append, or a streaming part growing). Compensate
|
||||
// synchronously by the exact height delta — for a bottom-pinned
|
||||
// viewport this keeps it pinned, for a released one it preserves the
|
||||
// read position, with no intermediate frame for auto-follow to fight.
|
||||
const prev = prependTrackingRef.current;
|
||||
const currentOldestId = renderedMessages[0]?.info?.id ?? null;
|
||||
const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null;
|
||||
const isPrepend = Boolean(
|
||||
prev
|
||||
&& prev.oldestId
|
||||
&& currentOldestId
|
||||
&& currentOldestId !== prev.oldestId
|
||||
&& prev.newestId
|
||||
&& currentNewestId
|
||||
&& currentNewestId === prev.newestId,
|
||||
);
|
||||
if (isPrepend && prev) {
|
||||
const delta = container.scrollHeight - prev.scrollHeight;
|
||||
if (delta > 0) {
|
||||
container.scrollTop = container.scrollTop + delta;
|
||||
}
|
||||
} else if (isPrepend && prev) {
|
||||
// Released viewport: preserve the read position by compensating for the
|
||||
// exact height the prepend added above, with no intermediate frame for
|
||||
// auto-follow to fight.
|
||||
const delta = container.scrollHeight - prev.scrollHeight;
|
||||
if (delta > 0) {
|
||||
container.scrollTop = container.scrollTop + delta;
|
||||
}
|
||||
}
|
||||
|
||||
prependTrackingRef.current = {
|
||||
oldestId: renderedMessages[0]?.info?.id ?? null,
|
||||
newestId: renderedMessages[renderedMessages.length - 1]?.info?.id ?? null,
|
||||
scrollHeight: container.scrollHeight,
|
||||
};
|
||||
updateTracking();
|
||||
}, [renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]);
|
||||
|
||||
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => false, []);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user