fix(chat): keep historical session entry an instant snap to bottom, not a smooth scroll
Entering a historical session sometimes showed a smooth scroll from a mid
position instead of landing instantly at the bottom. The single-writer change
had startFollowLoop stop the settle burst, so a content-measurement
ResizeObserver tick during restore would downgrade the authoritative instant
pin into an easing follow loop that scrolled in from the partially-measured
position.
- startFollowLoop now yields to an active settle burst instead of stopping it.
The asymmetry is intentional: the settle burst is the authoritative instant
pin (session restore / goToBottom 'instant') and must not be preempted by the
easing loop. startSettleBurst still stops the follow loop, so the two never
write scrollTop in the same frame.
- tickFollow snaps deltas larger than a full viewport (discrete jumps: late
history measurement, session entry, a big block in one commit) instead of
easing them; only small streaming-sized deltas ease.
- restoreSnapshot mirrors goToBottom('instant') — instant write + settle burst,
no startFollowLoop.
This commit is contained in:
@@ -204,6 +204,21 @@ export const useChatAutoFollow = ({
|
|||||||
const current = container.scrollTop;
|
const current = container.scrollTop;
|
||||||
const delta = target - current;
|
const delta = target - current;
|
||||||
|
|
||||||
|
// A delta larger than a full viewport is a DISCRETE jump (late history
|
||||||
|
// measurement, session entry, a big block rendering in one commit) — not
|
||||||
|
// incremental streaming growth. Easing it produces a visible smooth scroll
|
||||||
|
// from a mid position to the bottom, which felt inconsistent when entering
|
||||||
|
// historical sessions. Snap such jumps; only ease small, streaming-sized
|
||||||
|
// deltas below.
|
||||||
|
if (Math.abs(delta) > container.clientHeight) {
|
||||||
|
markProgrammaticWrite();
|
||||||
|
container.scrollTop = target;
|
||||||
|
lastScrollTopRef.current = target;
|
||||||
|
settledFramesRef.current = 0;
|
||||||
|
followRafRef.current = window.requestAnimationFrame(tickFollow);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (Math.abs(delta) <= SETTLE_EPSILON) {
|
if (Math.abs(delta) <= SETTLE_EPSILON) {
|
||||||
if (current !== target) {
|
if (current !== target) {
|
||||||
markProgrammaticWrite();
|
markProgrammaticWrite();
|
||||||
@@ -230,17 +245,20 @@ export const useChatAutoFollow = ({
|
|||||||
const startFollowLoop = React.useCallback(() => {
|
const startFollowLoop = React.useCallback(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
if (stateRef.current !== 'following') return;
|
if (stateRef.current !== 'following') return;
|
||||||
// Single-writer invariant: never let the easing follow loop run alongside
|
// Single-writer invariant, asymmetric on purpose: the settle burst is the
|
||||||
// the instant settle burst. They both write scrollTop every frame but aim
|
// AUTHORITATIVE instant pin (session restore / goToBottom 'instant'). While
|
||||||
// at different positions (the burst snaps to the exact bottom, this loop
|
// it is snapping to the bottom, YIELD — never preempt it with the easing
|
||||||
// eases toward it), so concurrently they fight frame-to-frame and produce
|
// follow loop. Preempting it let a content-measurement ResizeObserver tick
|
||||||
// the visible up/down jiggle during pinned content growth and sends.
|
// downgrade an instant restore into a visible smooth scroll from a mid
|
||||||
stopSettleBurst();
|
// position when entering a historical session. When the burst ends, the
|
||||||
|
// next content kick starts the follow loop. (startSettleBurst still stops
|
||||||
|
// this loop, so the two never write scrollTop in the same frame.)
|
||||||
|
if (settleBurstRafRef.current !== null) return;
|
||||||
if (followRafRef.current !== null) return;
|
if (followRafRef.current !== null) return;
|
||||||
settledFramesRef.current = 0;
|
settledFramesRef.current = 0;
|
||||||
setIsFollowingProgrammatically(true);
|
setIsFollowingProgrammatically(true);
|
||||||
followRafRef.current = window.requestAnimationFrame(tickFollow);
|
followRafRef.current = window.requestAnimationFrame(tickFollow);
|
||||||
}, [stopSettleBurst, tickFollow]);
|
}, [tickFollow]);
|
||||||
|
|
||||||
const writeScrollTopInstant = React.useCallback((target: number) => {
|
const writeScrollTopInstant = React.useCallback((target: number) => {
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
@@ -405,11 +423,14 @@ export const useChatAutoFollow = ({
|
|||||||
setStateValue('following');
|
setStateValue('following');
|
||||||
lastUserReleaseAtRef.current = 0;
|
lastUserReleaseAtRef.current = 0;
|
||||||
const target = Math.max(0, container.scrollHeight - container.clientHeight);
|
const target = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||||
|
// Mirror goToBottom('instant'): jump to the bottom now, then hold it with the
|
||||||
|
// settle burst while late history content measures in. Do NOT also start the
|
||||||
|
// easing follow loop here — that is what produced the smooth scroll-from-mid
|
||||||
|
// position on session entry.
|
||||||
writeScrollTopInstant(target);
|
writeScrollTopInstant(target);
|
||||||
startFollowLoop();
|
|
||||||
startSettleBurst();
|
startSettleBurst();
|
||||||
return false;
|
return false;
|
||||||
}, [setStateValue, startFollowLoop, startSettleBurst, writeScrollTopInstant]);
|
}, [setStateValue, startSettleBurst, writeScrollTopInstant]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
|
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
|
||||||
|
|||||||
Reference in New Issue
Block a user