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

View File

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