fix(web): 修复流式期间消息重渲染、WebSocket 无谓重连与新消息计数错误

- MessageBubble 用 React.memo 包裹,流式 delta 只追加到最后一条消息,
  其余消息引用不变可被 memo 跳过,避免 N-1 条历史消息无谓重渲染
- useWebSocket 用 useRef 存储回调最新引用,connect 不再依赖回调引用,
  避免调用方回调变化触发 disconnect+connect 重连
- MessageList 用 prevMessageCountRef 追踪消息条数变化,仅当真正新增
  消息条数时才累加 newMessageCount;流式 delta 不增加条数不再误计数,
  滑到底部按钮触发的清零路径与时序经审查覆盖各种边界场景
This commit is contained in:
oudecheng 2026-08-05 08:10:44 +08:00
parent 5eec77ae10
commit 4e74d66a7c
3 changed files with 35 additions and 10 deletions

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, memo } from 'react';
import {
User,
Bot,
@ -349,7 +349,7 @@ function parseTaskResult(content: string): TaskToolResult | null {
}
}
export function MessageBubble({
export const MessageBubble = memo(function MessageBubble({
message,
onNavigateToSubAgent,
showThinking = true,
@ -964,4 +964,4 @@ export function MessageBubble({
{lightboxElement}
</div>
);
}
});

View File

@ -28,6 +28,11 @@ export function MessageList({
const viewKeyRef = useRef(viewKey);
viewKeyRef.current = viewKey;
// 追踪上次的消息条数,用于计算真正新增的消息数(而非 messages 引用变化次数)。
// 流式输出时每个 delta 都会产生新的 messages 数组引用,但消息条数不变,
// 不应计入 newMessageCount。
const prevMessageCountRef = useRef(messages.length);
// Per-view scroll position memory
const scrollPositionsRef = useRef<Map<string, number>>(new Map());
@ -86,11 +91,13 @@ export function MessageList({
if (messages.length === 0) {
isAtBottomRef.current = true;
prevMessageCountRef.current = 0;
return;
}
if (viewChanged) {
// View switched (e.g. breadcrumb navigation): restore saved scroll position
prevMessageCountRef.current = messages.length;
const key = viewKey ?? '';
const savedPos = scrollPositionsRef.current.get(key);
if (savedPos !== undefined && containerRef.current) {
@ -108,11 +115,18 @@ export function MessageList({
// Same view, messages changed: normal auto-scroll logic
const lastMessage = messages[messages.length - 1];
const newCount = messages.length - prevMessageCountRef.current;
prevMessageCountRef.current = messages.length;
if (lastMessage.role === 'user' || isAtBottomRef.current) {
bottomRef.current?.scrollIntoView({ behavior: 'instant' });
} else {
setNewMessageCount((prev) => prev + 1);
// 用户自己发消息或已在底部时,不需要计数
if (newCount > 0) {
setNewMessageCount(0);
}
} else if (newCount > 0) {
// 只有真正新增了消息条数时才累加(流式 delta 不增加条数,不计数)
setNewMessageCount((prev) => prev + newCount);
}
}, [messages, viewKey]);

View File

@ -39,6 +39,17 @@ export function useWebSocket({
const isManualDisconnectRef = useRef(false);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 用 ref 存储回调的最新引用,避免回调变化导致 connect 重建 → useEffect 重跑 → 无谓断连重连。
// 调用方即使未 useCallback 包裹回调,也不会触发重连。
const onMessageRef = useRef(onMessage);
const onConnectRef = useRef(onConnect);
const onDisconnectRef = useRef(onDisconnect);
const onErrorRef = useRef(onError);
onMessageRef.current = onMessage;
onConnectRef.current = onConnect;
onDisconnectRef.current = onDisconnect;
onErrorRef.current = onError;
const clearPing = useCallback(() => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
@ -73,13 +84,13 @@ export function useWebSocket({
}
}
}, PING_INTERVAL_MS);
onConnect?.();
onConnectRef.current?.();
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as WsOutbound;
onMessage?.(message);
onMessageRef.current?.(message);
} catch (error) {
console.error('Failed to parse message:', error);
}
@ -87,13 +98,13 @@ export function useWebSocket({
ws.onerror = (error) => {
setStatus('error');
onError?.(error);
onErrorRef.current?.(error);
};
ws.onclose = () => {
setStatus('disconnected');
clearPing();
onDisconnect?.();
onDisconnectRef.current?.();
// 指数退避自动重连3s → 6s → 12s → 24s → 60s 封顶
if (!isManualDisconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
@ -109,7 +120,7 @@ export function useWebSocket({
setStatus('error');
console.error('WebSocket connection error:', error);
}
}, [url, onMessage, onConnect, onDisconnect, onError, reconnectInterval, maxReconnectAttempts, clearPing]);
}, [url, reconnectInterval, maxReconnectAttempts, clearPing]);
const disconnect = useCallback(() => {
isManualDisconnectRef.current = true;