From 35c8de917d7f92eed6105d94a8a7924a472524ad Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 2 Jul 2026 11:14:42 +0800 Subject: [PATCH] fix(chat): settle scroll after history load --- src/app/chat/components/chat-area.tsx | 109 ++++++++++++++++---------- 1 file changed, 68 insertions(+), 41 deletions(-) diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index 5fbb66b0..c09a9e43 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -30,6 +30,8 @@ import styles from "./chat-area.module.css"; const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96; +type RestoreState = "idle" | "checking" | "restoring" | "settlingBottom" | "done"; + export interface ChatAreaProps { messages: readonly UiMessage[]; isReplyingAI: boolean; @@ -51,9 +53,7 @@ export function ChatArea({ const scrollRef = useRef(null); const prevLengthRef = useRef(messages.length); const restoredScrollRef = useRef(false); - const restoreStateRef = useRef<"idle" | "checking" | "restoring" | "done">( - "idle", - ); + const restoreStateRef = useRef("idle"); const wasNearBottomRef = useRef(true); const saveCurrentScrollSnapshotNow = (anchorMessageId: string) => { @@ -68,7 +68,8 @@ export function ChatArea({ const scrollNode = scrollRef.current; const shouldSkipAutoScroll = restoreStateRef.current === "checking" || - restoreStateRef.current === "restoring"; + restoreStateRef.current === "restoring" || + restoreStateRef.current === "settlingBottom"; if (scrollNode && !shouldSkipAutoScroll && wasNearBottomRef.current) { scrollNode.scrollTo({ @@ -87,12 +88,7 @@ export function ChatArea({ if (!scrollNode) return; let cancelled = false; - let frame: number | null = null; - let timer: number | null = null; - let observerTimer: number | null = null; - let lateRestoreTimer: number | null = null; - let finishRestoreTimer: number | null = null; - let resizeObserver: ResizeObserver | null = null; + let stopStableScrollTask: (() => void) | null = null; restoreStateRef.current = "checking"; @@ -102,10 +98,17 @@ export function ChatArea({ if (snapshot === null) { restoredScrollRef.current = true; - restoreStateRef.current = "done"; - scrollNode.scrollTo({ - top: scrollNode.scrollHeight, - behavior: "auto", + restoreStateRef.current = "settlingBottom"; + prevLengthRef.current = messages.length; + stopStableScrollTask = startStableScrollTask({ + scrollNode, + applyScroll: () => { + scrollNode.scrollTop = scrollNode.scrollHeight; + }, + onFinish: () => { + restoreStateRef.current = "done"; + wasNearBottomRef.current = true; + }, }); return; } @@ -114,40 +117,23 @@ export function ChatArea({ restoreStateRef.current = "restoring"; prevLengthRef.current = messages.length; - const restoreScroll = () => { - scrollNode.scrollTop = getChatScrollRestoreTop(scrollNode, snapshot); - }; - - restoreScroll(); - frame = window.requestAnimationFrame(restoreScroll); - timer = window.setTimeout(restoreScroll, 120); - resizeObserver = - typeof ResizeObserver === "undefined" - ? null - : new ResizeObserver(restoreScroll); - Array.from(scrollNode.children).forEach((child) => { - resizeObserver?.observe(child); + stopStableScrollTask = startStableScrollTask({ + scrollNode, + applyScroll: () => { + scrollNode.scrollTop = getChatScrollRestoreTop(scrollNode, snapshot); + }, + onFinish: () => { + restoreStateRef.current = "done"; + wasNearBottomRef.current = isNearBottom(scrollNode); + }, }); - observerTimer = window.setTimeout(() => { - resizeObserver?.disconnect(); - }, 800); - lateRestoreTimer = window.setTimeout(restoreScroll, 600); - finishRestoreTimer = window.setTimeout(() => { - restoreStateRef.current = "done"; - wasNearBottomRef.current = isNearBottom(scrollNode); - }, 850); }; void restore(); return () => { cancelled = true; - if (frame !== null) window.cancelAnimationFrame(frame); - if (timer !== null) window.clearTimeout(timer); - if (observerTimer !== null) window.clearTimeout(observerTimer); - if (lateRestoreTimer !== null) window.clearTimeout(lateRestoreTimer); - if (finishRestoreTimer !== null) window.clearTimeout(finishRestoreTimer); - resizeObserver?.disconnect(); + stopStableScrollTask?.(); }; }, [messages.length]); @@ -191,6 +177,47 @@ function isNearBottom(scrollNode: HTMLElement): boolean { return distanceFromBottom <= CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD; } +function startStableScrollTask({ + applyScroll, + onFinish, + scrollNode, +}: { + applyScroll: () => void; + onFinish: () => void; + scrollNode: HTMLElement; +}): () => void { + let frame: number | null = null; + let earlyTimer: number | null = null; + let lateTimer: number | null = null; + let observerTimer: number | null = null; + let finishTimer: number | null = null; + const resizeObserver = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(applyScroll); + + applyScroll(); + frame = window.requestAnimationFrame(applyScroll); + earlyTimer = window.setTimeout(applyScroll, 120); + lateTimer = window.setTimeout(applyScroll, 600); + Array.from(scrollNode.children).forEach((child) => { + resizeObserver?.observe(child); + }); + observerTimer = window.setTimeout(() => { + resizeObserver?.disconnect(); + }, 800); + finishTimer = window.setTimeout(onFinish, 850); + + return () => { + if (frame !== null) window.cancelAnimationFrame(frame); + if (earlyTimer !== null) window.clearTimeout(earlyTimer); + if (lateTimer !== null) window.clearTimeout(lateTimer); + if (observerTimer !== null) window.clearTimeout(observerTimer); + if (finishTimer !== null) window.clearTimeout(finishTimer); + resizeObserver?.disconnect(); + }; +} + /** 渲染消息列表(按日期分组插入分隔条) */ function renderMessagesWithDateHeaders( messages: readonly UiMessage[],