diff --git a/src/components/room/RoomChatPanel.tsx b/src/components/room/RoomChatPanel.tsx index db8dd60..961cc84 100644 --- a/src/components/room/RoomChatPanel.tsx +++ b/src/components/room/RoomChatPanel.tsx @@ -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); }, []); diff --git a/src/components/room/RoomMessageList.tsx b/src/components/room/RoomMessageList.tsx index e196fca..23443aa 100644 --- a/src/components/room/RoomMessageList.tsx +++ b/src/components/room/RoomMessageList.tsx @@ -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(null); const prevScrollHeightRef = useRef(null); const [showScrollToBottom, setShowScrollToBottom] = useState(false); - const [isUserScrolling, setIsUserScrolling] = useState(false); const scrollTimeoutRef = useRef | 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(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(); - 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(() => { 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 (
@@ -310,7 +298,6 @@ export const RoomMessageList = memo(function RoomMessageList({ width: '100%', }} > - {/* Top sentinel for load more */}
{isLoadingMore && ( @@ -379,7 +366,6 @@ export const RoomMessageList = memo(function RoomMessageList({ ); })} - {/* Bottom sentinel for auto-scroll */}
); }); + +export { RoomMessageListInner }; +export const RoomMessageList = RoomMessageListInner;