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);
|
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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user