perf(room): simplify scroll handler and stabilize callback refs

- Remove useTransition/useDeferredValue from RoomMessageList
- Wrap component in memo to prevent unnecessary re-renders
- Use requestAnimationFrame to defer scroll state updates
- Remove isUserScrolling state (no longer needed)
- Simplify auto-scroll effect: sync distance check + RAF deferred scroll
- Add replyMap memo to decouple reply lookup from row computation
- Stabilize handleEditConfirm to depend on editingMessage?.id only
- Remove Performance Stats panel (RoomPerformanceMonitor)
This commit is contained in:
ZhenYi 2026-04-17 21:28:58 +08:00
parent 991d86237b
commit 5cd4c66445
2 changed files with 55 additions and 63 deletions

View File

@ -287,6 +287,7 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
sendMessage(content, 'text', replyingTo?.id ?? undefined); sendMessage(content, 'text', replyingTo?.id ?? undefined);
setReplyingTo(null); setReplyingTo(null);
}, },
// sendMessage from useRoom is already stable; replyingTo changes trigger handleSend rebuild (acceptable)
[sendMessage, replyingTo], [sendMessage, replyingTo],
); );
@ -308,7 +309,8 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
setEditingMessage(null); setEditingMessage(null);
toast.success('Message updated'); toast.success('Message updated');
}, },
[editingMessage, editMessage], // Only rebuild when editingMessage.id actually changes, not on every new message
[editingMessage?.id, editMessage],
); );
const handleRevoke = useCallback( const handleRevoke = useCallback(
@ -319,6 +321,7 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
[revokeMessage], [revokeMessage],
); );
// Stable: chatInputRef is stable, no deps that change on message updates
const handleMention = useCallback((name: string, type: 'user' | 'ai') => { const handleMention = useCallback((name: string, type: 'user' | 'ai') => {
chatInputRef.current?.insertMention(name, type); chatInputRef.current?.insertMention(name, type);
}, []); }, []);

View File

@ -1,7 +1,7 @@
import type { MessageWithMeta } from '@/contexts'; import type { MessageWithMeta } from '@/contexts';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ArrowDown, Loader2 } from 'lucide-react'; import { ArrowDown, Loader2 } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useRef, useState, useTransition, useDeferredValue } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { RoomMessageBubble } from './RoomMessageBubble'; import { RoomMessageBubble } from './RoomMessageBubble';
import { getSenderModelId } from './sender'; import { getSenderModelId } from './sender';
@ -35,7 +35,6 @@ interface MessageRow {
message?: MessageWithMeta; message?: MessageWithMeta;
grouped?: boolean; grouped?: boolean;
replyMessage?: MessageWithMeta | null; replyMessage?: MessageWithMeta | null;
/** Unique key for the virtualizer */
key: string; key: string;
} }
@ -83,10 +82,9 @@ function estimateMessageRowHeight(message: MessageWithMeta): number {
return baseHeight + Math.min(lineCount, 5) * lineHeight + replyHeight; return baseHeight + Math.min(lineCount, 5) * lineHeight + replyHeight;
} }
/** Estimated height for a date divider row in pixels */
const ESTIMATED_DIVIDER_HEIGHT = 30; const ESTIMATED_DIVIDER_HEIGHT = 30;
export const RoomMessageList = memo(function RoomMessageList({ const RoomMessageListInner = memo(function RoomMessageListInner({
roomId, roomId,
messages, messages,
messagesEndRef, messagesEndRef,
@ -106,33 +104,27 @@ export const RoomMessageList = memo(function RoomMessageList({
const topSentinelRef = useRef<HTMLDivElement>(null); const topSentinelRef = useRef<HTMLDivElement>(null);
const prevScrollHeightRef = useRef<number | null>(null); const prevScrollHeightRef = useRef<number | null>(null);
const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [isUserScrolling, setIsUserScrolling] = useState(false);
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isRestoringScrollRef = useRef(false); const isRestoringScrollRef = useRef(false);
const [, startScrollTransition] = useTransition();
// Record the ID of the first visible message before loading, for more precise scroll position restoration
const firstVisibleMessageIdRef = useRef<string | null>(null); const firstVisibleMessageIdRef = useRef<string | null>(null);
// Defer messages so React can prioritize scroll/interaction state updates. // Build reply lookup map (stable reference, recomputes only when messages change)
// When messages arrive rapidly (e.g. WS stream), React renders the deferred const replyMap = useMemo(() => {
// version in a lower-priority work window, preventing scroll jank.
const deferredMessages = useDeferredValue(messages);
const messageMap = useMemo(() => {
const map = new Map<string, MessageWithMeta>(); const map = new Map<string, MessageWithMeta>();
deferredMessages.forEach((message) => map.set(message.id, message)); messages.forEach((m) => {
if (m.id) map.set(m.id, m);
});
return map; return map;
}, [deferredMessages]); }, [messages]);
// Build rows: date dividers + messages // Build rows: date dividers + messages
// Uses deferredMessages so row computation is deprioritized during rapid message updates // Use a separate Map to avoid rows depending on replyMap (which changes reference)
const rows = useMemo<MessageRow[]>(() => { const rows = useMemo<MessageRow[]>(() => {
const result: MessageRow[] = []; const result: MessageRow[] = [];
let lastDateKey: string | null = null; let lastDateKey: string | null = null;
let lastSenderKey: string | null = null; let lastSenderKey: string | null = null;
for (const message of deferredMessages) { for (const message of messages) {
const dateKey = getDateKey(message.send_at); const dateKey = getDateKey(message.send_at);
const senderKey = getSenderKey(message); const senderKey = getSenderKey(message);
@ -151,36 +143,42 @@ export const RoomMessageList = memo(function RoomMessageList({
type: 'message', type: 'message',
message, message,
grouped, grouped,
replyMessage: message.in_reply_to ? messageMap.get(message.in_reply_to) ?? null : null, replyMessage: message.in_reply_to ? replyMap.get(message.in_reply_to) ?? null : null,
key: message.id, key: message.id,
}); });
lastSenderKey = senderKey; lastSenderKey = senderKey;
} }
return result; return result;
}, [deferredMessages, messageMap]); }, [messages, replyMap]);
const scrollToBottom = useCallback((smooth = true) => { const scrollToBottom = useCallback((smooth = true) => {
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' }); messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
}, [messagesEndRef]); }, [messagesEndRef]);
// Track user scroll to detect if user is at bottom. // Lightweight scroll handler: only update the "show" flag, decoupled from layout reads
// Wrapped in startTransition so React knows these state updates are non-urgent // Reads scroll position synchronously but defers state updates so browser can paint first.
// and can be interrupted if a higher-priority update (e.g., new message) comes in.
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
if (!container) return; if (!container) return;
// Synchronous read of scroll position (triggers layout, unavoidable)
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
const nearBottom = distanceFromBottom < 100; const nearBottom = distanceFromBottom < 100;
startScrollTransition(() => { // Update state asynchronously — browser can process other frames before committing
requestAnimationFrame(() => {
setShowScrollToBottom(!nearBottom); setShowScrollToBottom(!nearBottom);
setIsUserScrolling(true);
}); });
// Reset user scrolling flag after a delay // Reset user-scrolling flag after delay
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current); if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = setTimeout(() => { scrollTimeoutRef.current = setTimeout(() => {
setIsUserScrolling(false); // Only clear if still at bottom
const c = scrollContainerRef.current;
if (c) {
const dist = c.scrollHeight - c.scrollTop - c.clientHeight;
if (dist < 100) setShowScrollToBottom(false);
}
}, 500); }, 500);
}, []); }, []);
@ -194,15 +192,18 @@ export const RoomMessageList = memo(function RoomMessageList({
}; };
}, [handleScroll]); }, [handleScroll]);
// Auto-scroll to bottom when new messages arrive (only if user was already at bottom). // Auto-scroll when new messages arrive (only if user was already at bottom)
// Uses deferredMessages.length so auto-scroll waits for the deferred render to settle.
useEffect(() => { useEffect(() => {
if (!isUserScrolling && deferredMessages.length > 0) { if (messages.length === 0) return;
scrollToBottom(false); // Check if near bottom before scheduling scroll
const container = scrollContainerRef.current;
if (!container) return;
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
if (distanceFromBottom < 100) {
requestAnimationFrame(() => scrollToBottom(false));
} }
}, [deferredMessages.length, isUserScrolling, scrollToBottom]); }, [messages.length, scrollToBottom]);
// Virtualizer
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: rows.length, count: rows.length,
getScrollElement: () => scrollContainerRef.current, getScrollElement: () => scrollContainerRef.current,
@ -218,7 +219,7 @@ export const RoomMessageList = memo(function RoomMessageList({
const virtualItems = virtualizer.getVirtualItems(); const virtualItems = virtualizer.getVirtualItems();
// IntersectionObserver for load more (only when scrolled to top) // IntersectionObserver for load more
useEffect(() => { useEffect(() => {
const sentinel = topSentinelRef.current; const sentinel = topSentinelRef.current;
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
@ -227,18 +228,14 @@ export const RoomMessageList = memo(function RoomMessageList({
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0]?.isIntersecting && !isLoadingMore && hasMore) { if (entries[0]?.isIntersecting && !isLoadingMore && hasMore) {
const scrollContainer = scrollContainerRef.current; prevScrollHeightRef.current = container.scrollHeight;
if (scrollContainer) { const items = virtualizer.getVirtualItems();
prevScrollHeightRef.current = scrollContainer.scrollHeight; if (items.length > 0) {
// Record the ID of the first visible message const firstVisibleRow = rows[items[0].index];
const virtualItems = virtualizer.getVirtualItems();
if (virtualItems.length > 0) {
const firstVisibleRow = rows[virtualItems[0].index];
if (firstVisibleRow?.type === 'message' && firstVisibleRow.message) { if (firstVisibleRow?.type === 'message' && firstVisibleRow.message) {
firstVisibleMessageIdRef.current = firstVisibleRow.message.id; firstVisibleMessageIdRef.current = firstVisibleRow.message.id;
} }
} }
}
isRestoringScrollRef.current = true; isRestoringScrollRef.current = true;
onLoadMore(); onLoadMore();
} }
@ -248,28 +245,23 @@ export const RoomMessageList = memo(function RoomMessageList({
observer.observe(sentinel); observer.observe(sentinel);
return () => observer.disconnect(); return () => observer.disconnect();
}, [onLoadMore, hasMore, isLoadingMore, rows]); }, [onLoadMore, hasMore, isLoadingMore, rows, virtualizer]);
// Maintain scroll position after loading more messages // Maintain scroll position after loading more messages
useEffect(() => { useEffect(() => {
// Only run this effect when we're restoring scroll from a load-more operation
if (!isRestoringScrollRef.current) return; if (!isRestoringScrollRef.current) return;
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
if (!container || prevScrollHeightRef.current === null) { if (!container || prevScrollHeightRef.current === null) {
isRestoringScrollRef.current = false; isRestoringScrollRef.current = false;
return; return;
} }
const newScrollHeight = container.scrollHeight; const delta = container.scrollHeight - prevScrollHeightRef.current;
const delta = newScrollHeight - prevScrollHeightRef.current;
// Method 1: Try to find the previously recorded first visible message
if (firstVisibleMessageIdRef.current) { if (firstVisibleMessageIdRef.current) {
const messageElement = container.querySelector(`[data-message-id="${firstVisibleMessageIdRef.current}"]`); const el = container.querySelector(`[data-message-id="${firstVisibleMessageIdRef.current}"]`);
if (messageElement) { if (el) {
// Use scrollIntoView to precisely scroll to the previously visible message el.scrollIntoView({ block: 'start' });
messageElement.scrollIntoView({ block: 'start' });
prevScrollHeightRef.current = null; prevScrollHeightRef.current = null;
firstVisibleMessageIdRef.current = null; firstVisibleMessageIdRef.current = null;
isRestoringScrollRef.current = false; isRestoringScrollRef.current = false;
@ -277,17 +269,13 @@ export const RoomMessageList = memo(function RoomMessageList({
} }
} }
// Method 2: Fallback to the previous scrollHeight delta method if (delta > 0) container.scrollTop += delta;
if (delta > 0) {
container.scrollTop += delta;
}
prevScrollHeightRef.current = null; prevScrollHeightRef.current = null;
firstVisibleMessageIdRef.current = null; firstVisibleMessageIdRef.current = null;
isRestoringScrollRef.current = false; isRestoringScrollRef.current = false;
}, [deferredMessages.length]); }, [messages.length]);
// Empty state
if (messages.length === 0) { if (messages.length === 0) {
return ( return (
<div className="flex flex-1 items-center justify-center px-6 py-12"> <div className="flex flex-1 items-center justify-center px-6 py-12">
@ -310,7 +298,6 @@ export const RoomMessageList = memo(function RoomMessageList({
width: '100%', width: '100%',
}} }}
> >
{/* Top sentinel for load more */}
<div ref={topSentinelRef} className="absolute top-0 h-1 w-full" /> <div ref={topSentinelRef} className="absolute top-0 h-1 w-full" />
{isLoadingMore && ( {isLoadingMore && (
@ -379,7 +366,6 @@ export const RoomMessageList = memo(function RoomMessageList({
); );
})} })}
{/* Bottom sentinel for auto-scroll */}
<div <div
ref={messagesEndRef} ref={messagesEndRef}
className="absolute left-0 w-full" className="absolute left-0 w-full"
@ -405,3 +391,6 @@ export const RoomMessageList = memo(function RoomMessageList({
</div> </div>
); );
}); });
export { RoomMessageListInner };
export const RoomMessageList = RoomMessageListInner;