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:
parent
991d86237b
commit
5cd4c66445
@ -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);
|
||||
}, []);
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user