fix(chat): 修复视图切换滚动位置丢失与顶部按钮无法滚到首条消息

动态行高缓存按消息 id 键控,避免子智能体视图高度污染主视图;恢复后两帧 rAF 内不回写保存位置。

顶部按钮链式贴顶:动画落定显式续链,分页 prepend 时贴到新顶部,直到真正首条消息;关闭 virtualizer 行高修正的 scrollTop 自动补偿。

用户滚动输入/回底/待办高亮/视图切换/分页耗尽时清除贴顶意图。
This commit is contained in:
oudecheng 2026-08-18 15:33:59 +08:00
parent 6ac653bf51
commit 028f60557e

View File

@ -47,6 +47,13 @@ export const MessageList = memo(function MessageList({
const onLoadOlderRef = useRef(onLoadOlder);
onLoadOlderRef.current = onLoadOlder;
// hasMoreOlder 镜像:供 layout effect 的 prepend 分支判断分页是否耗尽。
// 不进依赖数组——否则 hasMoreOlder 单独变化会重跑 effect落入同视图
// 分支的滚底逻辑lastMessage 为 user 时 scrollToIndex 到底),把
// 正在顶部浏览历史的用户拽到底部。
const hasMoreOlderRef = useRef(hasMoreOlder);
hasMoreOlderRef.current = hasMoreOlder;
// 追踪上次的消息条数,用于计算真正新增的消息数(而非 messages 引用变化次数)。
// 流式输出时每个 delta 都会产生新的 messages 数组引用,但消息条数不变,
// 不应计入 newMessageCount。
@ -55,6 +62,17 @@ export const MessageList = memo(function MessageList({
// Per-view scroll position memory
const scrollPositionsRef = useRef<Map<string, number>>(new Map());
// 滚动位置恢复期标志:恢复 scrollTop 后 virtualizer 会对可见行重测并做
// 滚动纠正,期间的 scroll 事件不应回写 scrollPositions否则中间值会
// 覆盖掉原始保存位置,下次切回时锚点已丢失)。
const restoringRef = useRef(false);
// "顶部"按钮的链式贴顶意图:滚到已加载内容的顶部只是中间态——触顶
// 加载的历史消息 prepend 后不做锚定补偿,而是继续贴到新顶部,链式
// 加载直到真正首条消息hasMoreOlder 耗尽)。用户滚动输入或视图切换
// 即取消意图,恢复正常锚定语义。
const scrollToTopIntentRef = useRef(false);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [newMessageCount, setNewMessageCount] = useState(0);
@ -65,12 +83,24 @@ export const MessageList = memo(function MessageList({
getScrollElement: () => containerRef.current,
estimateSize: () => 120,
overscan: 6,
// 动态高度缓存按消息 id而非 index存取主/子智能体视图共用同一个
// virtualizer 实例,若按 index 键控,访问子智能体视图会用其行高污染主
// 视图的高度缓存,返回主视图恢复 scrollTop 时像素偏移无法映射回原消息。
getItemKey: (i) => messages[i]?.id ?? i,
measureElement:
typeof window !== 'undefined' && navigator.userAgent.includes('Firefox')
? (el) => el.getBoundingClientRect().height
: undefined,
});
// 禁用 virtualizer 在行高实测修正时对 scrollTop 的自动补偿写入
// __resizeItem → applyScrollAdjustment该写入会打断滚顶动画、把链式
// 贴顶从 scrollTop=0 推离,且其触发的 scroll 事件会被误判为用户接管。
// scrollTop 的写手全部由本组件显式管理prepend 锚定(+=)、链式贴顶
// =0、滚底scrollToIndex。注意该开关是实例属性而非 options
// 字段virtual-core 3.x 类型声明),故在此赋值。
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false;
// 消息 id → virtualizer index 映射,用于 highlight 滚动定位。
// useMemo 化:仅在 messages 变化时重建,而非每次渲染(流式期间每帧一次)都全量重建。
const messageIdToIndex = useMemo(() => {
@ -91,6 +121,7 @@ export const MessageList = memo(function MessageList({
const scrollToBottom = useCallback(
(behavior: ScrollBehavior = 'smooth') => {
stopScrollTopAnimation();
scrollToTopIntentRef.current = false;
isAtBottomRef.current = true;
setShowScrollToBottom(false);
setNewMessageCount(0);
@ -109,8 +140,13 @@ export const MessageList = memo(function MessageList({
const el = containerRef.current;
if (!el) return;
stopScrollTopAnimation();
scrollToTopIntentRef.current = true;
const from = el.scrollTop;
if (from <= 0) return;
if (from <= 0) {
// 已在已加载内容的顶部:直接补触发一次分页(有更早消息则进入链式贴顶)
onLoadOlderRef.current?.();
return;
}
const duration = Math.min(800, 250 + from / 8);
const start = performance.now();
const easeInOutCubic = (t: number) =>
@ -118,7 +154,16 @@ export const MessageList = memo(function MessageList({
const step = (now: number) => {
const p = Math.min(1, (now - start) / duration);
el.scrollTop = from * (1 - easeInOutCubic(p));
scrollTopRafRef.current = p < 1 ? requestAnimationFrame(step) : 0;
if (p < 1) {
scrollTopRafRef.current = requestAnimationFrame(step);
} else {
scrollTopRafRef.current = 0;
// 陷阱ease 末段每帧位移是亚像素,浏览器将 scrollTop snap 到 0 后,
// 最终的 scrollTop = 0 是无变化赋值,不派发 scroll 事件 → 触顶加载
// 永远不会被 handleScroll 触发。动画落定后必须显式续链。
// (与 scroll 事件路径可能双发一次requestLoadOlder 内部幂等,无害)
if (scrollToTopIntentRef.current) onLoadOlderRef.current?.();
}
};
scrollTopRafRef.current = requestAnimationFrame(step);
}, [stopScrollTopAnimation]);
@ -129,6 +174,12 @@ export const MessageList = memo(function MessageList({
const el = containerRef.current;
if (!el) return;
// 注意:此处不做"scrollTop>0 即取消贴顶意图"的启发式判断——程序化
// scrollTop 写入锚定补偿、virtualizer 纠正残留)也会产生离开顶部的
// scroll 事件,启发式无法区分用户与程序,会误杀链式贴顶。意图的取消
// 只由显式用户输入事件wheel/touchstart/pointerdown见下方监听
// scrollToBottom、待办高亮、视图切换与分页耗尽负责。
// 触顶加载更早历史:滚顶动画运行中不触发(动画会持续覆写 scrollTop
// 与 prepend 锚定补偿互相打架);动画落定在 scrollTop=0 时会发出最后一次
// scroll 事件,此刻 rafRef 已清零,可正常触发。重复触发由 hook 内
@ -141,8 +192,9 @@ export const MessageList = memo(function MessageList({
const nearBottom = distanceFromBottom < 120;
// Save scroll position for current view
// 恢复期不回写:此时 scrollTop 是刚恢复的旧值或重测纠正的中间值
const key = viewKeyRef.current;
if (key) {
if (key && !restoringRef.current) {
scrollPositionsRef.current.set(key, el.scrollTop);
}
@ -193,8 +245,21 @@ export const MessageList = memo(function MessageList({
const prevH = prevScrollHeightRef.current;
const newH = el!.scrollHeight;
if (newH > prevH) {
el!.scrollTop += newH - prevH;
if (scrollToTopIntentRef.current) {
// "顶部"链式贴顶:不做锚定补偿,直接贴到新顶部
const wasAtTop = el!.scrollTop === 0;
el!.scrollTop = 0;
// 陷阱scrollTop 已为 0 时赋值不触发 scroll 事件,触顶加载链会
// 在首批后静默中断。此处显式续链loading/hasMore 守卫在
// requestLoadOlder 内部吸收wasAtTop=false 时赋值会派发
// scroll 事件,走 handleScroll 正常续链,避免双重请求。
if (wasAtTop && hasMoreOlderRef.current) onLoadOlderRef.current?.();
} else {
el!.scrollTop += newH - prevH;
}
}
// 分页耗尽:贴顶意图已达成,清除标志恢复正常锚定语义
if (!hasMoreOlderRef.current) scrollToTopIntentRef.current = false;
prevScrollHeightRef.current = newH;
prevMessageCountRef.current = messages.length;
return;
@ -204,10 +269,19 @@ export const MessageList = memo(function MessageList({
if (viewChanged) {
// View switched (e.g. breadcrumb navigation): restore saved scroll position
stopScrollTopAnimation();
scrollToTopIntentRef.current = false;
prevMessageCountRef.current = messages.length;
const key = viewKey ?? '';
const savedPos = scrollPositionsRef.current.get(key);
if (savedPos !== undefined && containerRef.current) {
// 恢复期跨越两帧 rAF第一帧完成布局与可见行重测第二帧让
// virtualizer 的滚动纠正落定,期间 handleScroll 不回写保存位置
restoringRef.current = true;
requestAnimationFrame(() =>
requestAnimationFrame(() => {
restoringRef.current = false;
}),
);
containerRef.current.scrollTop = savedPos;
const el = containerRef.current;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
@ -267,6 +341,7 @@ export const MessageList = memo(function MessageList({
if (idx === undefined) return;
stopScrollTopAnimation();
scrollToTopIntentRef.current = false;
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
// 高亮 class 需等 DOM 渲染后操作
@ -284,18 +359,26 @@ export const MessageList = memo(function MessageList({
// ---- 滚顶动画生命周期 ----
// 用户手动滚动(滚轮/触摸)时打断滚顶动画,交还滚动控制权
// 用户手动滚动输入(滚轮/触摸/点击)时打断滚顶动画并取消链式贴顶意图,
// 交还滚动控制权
const handleUserScrollInput = useCallback(() => {
stopScrollTopAnimation();
scrollToTopIntentRef.current = false;
}, [stopScrollTopAnimation]);
const hasMessages = messages.length > 0;
useEffect(() => {
const el = containerRef.current;
if (!el) return;
el.addEventListener('wheel', stopScrollTopAnimation, { passive: true });
el.addEventListener('touchstart', stopScrollTopAnimation, { passive: true });
el.addEventListener('wheel', handleUserScrollInput, { passive: true });
el.addEventListener('touchstart', handleUserScrollInput, { passive: true });
el.addEventListener('pointerdown', handleUserScrollInput, { passive: true });
return () => {
el.removeEventListener('wheel', stopScrollTopAnimation);
el.removeEventListener('touchstart', stopScrollTopAnimation);
el.removeEventListener('wheel', handleUserScrollInput);
el.removeEventListener('touchstart', handleUserScrollInput);
el.removeEventListener('pointerdown', handleUserScrollInput);
};
}, [hasMessages, stopScrollTopAnimation]);
}, [hasMessages, handleUserScrollInput]);
// 卸载时取消未完成的动画
useEffect(() => stopScrollTopAnimation, [stopScrollTopAnimation]);