diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 85044bb2bc..6ca408685f 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -355,6 +355,28 @@ export type TOptions = { export type TAskFunction = (props: TAskProps, options?: TOptions) => void; +/** + * Stable context object passed from non-memo'd wrapper components (Message, MessageContent) + * to memo'd inner components (MessageRender, ContentRender) via props. + * + * This avoids subscribing to ChatContext inside memo'd components, which would bypass React.memo + * and cause unnecessary re-renders when `isSubmitting` changes during streaming. + * + * The `isSubmitting` property should use a getter backed by a ref so it returns the current + * value at call-time (for callback guards) without being a reactive dependency. + */ +export type TMessageChatContext = { + ask: (...args: Parameters) => void; + index: number; + regenerate: (message: t.TMessage, options?: { addedConvo?: t.TConversation | null }) => void; + conversation: t.TConversation | null; + latestMessageId: string | undefined; + latestMessageDepth: number | undefined; + handleContinue: (e: React.MouseEvent) => void; + /** Should be a getter backed by a ref — reads current value without triggering re-renders */ + readonly isSubmitting: boolean; +}; + export type TMessageProps = { conversation?: t.TConversation | null; messageId?: string | null; diff --git a/client/src/components/Chat/Input/AudioRecorder.tsx b/client/src/components/Chat/Input/AudioRecorder.tsx index dbf2c29d83..e9e19d0904 100644 --- a/client/src/components/Chat/Input/AudioRecorder.tsx +++ b/client/src/components/Chat/Input/AudioRecorder.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react'; +import { memo, useCallback, useRef } from 'react'; import { MicOff } from 'lucide-react'; import { useToastContext, TooltipAnchor, ListeningIcon, Spinner } from '@librechat/client'; import { useLocalize, useSpeechToText, useGetAudioSettings } from '~/hooks'; @@ -7,7 +7,7 @@ import { globalAudioId } from '~/common'; import { cn } from '~/utils'; const isExternalSTT = (speechToTextEndpoint: string) => speechToTextEndpoint === 'external'; -export default function AudioRecorder({ +export default memo(function AudioRecorder({ disabled, ask, methods, @@ -26,10 +26,12 @@ export default function AudioRecorder({ const { speechToTextEndpoint } = useGetAudioSettings(); const existingTextRef = useRef(''); + const isSubmittingRef = useRef(isSubmitting); + isSubmittingRef.current = isSubmitting; const onTranscriptionComplete = useCallback( (text: string) => { - if (isSubmitting) { + if (isSubmittingRef.current) { showToast({ message: localize('com_ui_speech_while_submitting'), status: 'error', @@ -52,7 +54,7 @@ export default function AudioRecorder({ existingTextRef.current = ''; } }, - [ask, reset, showToast, localize, isSubmitting, speechToTextEndpoint], + [ask, reset, showToast, localize, speechToTextEndpoint], ); const setText = useCallback( @@ -125,4 +127,4 @@ export default function AudioRecorder({ } /> ); -} +}); diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index fed355dcb3..9e0ad7f382 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -3,6 +3,8 @@ import { useWatch } from 'react-hook-form'; import { TextareaAutosize } from '@librechat/client'; import { useRecoilState, useRecoilValue } from 'recoil'; import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider'; +import type { TConversation } from 'librechat-data-provider'; +import type { ExtendedFile, FileSetter, ConvoGenerator } from '~/common'; import { useChatContext, useChatFormContext, @@ -35,7 +37,30 @@ import BadgeRow from './BadgeRow'; import Mention from './Mention'; import store from '~/store'; -const ChatForm = memo(({ index = 0 }: { index?: number }) => { +interface ChatFormProps { + index: number; + /** From ChatContext — individual values so memo can compare them */ + files: Map; + setFiles: FileSetter; + conversation: TConversation | null; + isSubmitting: boolean; + filesLoading: boolean; + setFilesLoading: React.Dispatch>; + newConversation: ConvoGenerator; + handleStopGenerating: (e: React.MouseEvent) => void; +} + +const ChatForm = memo(function ChatForm({ + index, + files, + setFiles, + conversation, + isSubmitting, + filesLoading, + setFilesLoading, + newConversation, + handleStopGenerating, +}: ChatFormProps) { const submitButtonRef = useRef(null); const textAreaRef = useRef(null); useFocusChatEffect(textAreaRef); @@ -65,15 +90,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => { const { requiresKey } = useRequiresKey(); const methods = useChatFormContext(); - const { - files, - setFiles, - conversation, - isSubmitting, - filesLoading, - newConversation, - handleStopGenerating, - } = useChatContext(); const { generateConversation, conversation: addedConvo, @@ -120,6 +136,15 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => { } }, [isCollapsed]); + const handleTextareaFocus = useCallback(() => { + handleFocusOrClick(); + setIsTextAreaFocused(true); + }, [handleFocusOrClick]); + + const handleTextareaBlur = useCallback(() => { + setIsTextAreaFocused(false); + }, []); + useAutoSave({ files, setFiles, @@ -253,7 +278,12 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => { handleSaveBadges={handleSaveBadges} setBadges={setBadges} /> - + {endpoint && (
{ tabIndex={0} data-testid="text-input" rows={1} - onFocus={() => { - handleFocusOrClick(); - setIsTextAreaFocused(true); - }} - onBlur={setIsTextAreaFocused.bind(null, false)} + onFocus={handleTextareaFocus} + onBlur={handleTextareaBlur} aria-label={localize('com_ui_message_input')} onClick={handleFocusOrClick} style={{ height: 44, overflowY: 'auto' }} @@ -315,7 +342,13 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => { )} >
- +
{ ); }); +ChatForm.displayName = 'ChatForm'; -export default ChatForm; +/** + * Wrapper that subscribes to ChatContext and passes stable individual values + * to the memo'd ChatForm. This prevents ChatForm from re-rendering on every + * streaming chunk — it only re-renders when the specific values it uses change. + */ +function ChatFormWrapper({ index = 0 }: { index?: number }) { + const { + files, + setFiles, + conversation, + isSubmitting, + filesLoading, + setFilesLoading, + newConversation, + handleStopGenerating, + } = useChatContext(); + + /** + * Stabilize conversation reference: only update when rendering-relevant fields change, + * not on every metadata update (e.g., title generation during streaming). + */ + const hasMessages = (conversation?.messages?.length ?? 0) > 0; + const stableConversation = useMemo( + () => conversation, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + conversation?.conversationId, + conversation?.endpoint, + conversation?.endpointType, + conversation?.agent_id, + conversation?.assistant_id, + conversation?.spec, + conversation?.useResponsesApi, + conversation?.model, + hasMessages, + ], + ); + + /** Stabilize function refs so they never trigger ChatForm re-renders */ + const handleStopRef = useRef(handleStopGenerating); + handleStopRef.current = handleStopGenerating; + const stableHandleStop = useCallback( + (e: React.MouseEvent) => handleStopRef.current(e), + [], + ); + + const newConvoRef = useRef(newConversation); + newConvoRef.current = newConversation; + const stableNewConversation: ConvoGenerator = useCallback( + (...args: Parameters): ReturnType => + newConvoRef.current(...args), + [], + ); + + return ( + + ); +} + +ChatFormWrapper.displayName = 'ChatFormWrapper'; + +export default ChatFormWrapper; diff --git a/client/src/components/Chat/Input/CollapseChat.tsx b/client/src/components/Chat/Input/CollapseChat.tsx index ea099ed69b..7efe52dc8d 100644 --- a/client/src/components/Chat/Input/CollapseChat.tsx +++ b/client/src/components/Chat/Input/CollapseChat.tsx @@ -52,4 +52,4 @@ const CollapseChat = ({ ); }; -export default CollapseChat; +export default React.memo(CollapseChat); diff --git a/client/src/components/Chat/Input/Files/AttachFile.tsx b/client/src/components/Chat/Input/Files/AttachFile.tsx index 38a3fa8c6f..098fa2c4c3 100644 --- a/client/src/components/Chat/Input/Files/AttachFile.tsx +++ b/client/src/components/Chat/Input/Files/AttachFile.tsx @@ -1,14 +1,33 @@ import React, { useRef } from 'react'; import { FileUpload, TooltipAnchor, AttachmentIcon } from '@librechat/client'; -import { useLocalize, useFileHandling } from '~/hooks'; +import type { TConversation } from 'librechat-data-provider'; +import type { ExtendedFile, FileSetter } from '~/common'; +import { useFileHandlingNoChatContext, useLocalize } from '~/hooks'; import { cn } from '~/utils'; -const AttachFile = ({ disabled }: { disabled?: boolean | null }) => { +const AttachFile = ({ + disabled, + files, + setFiles, + setFilesLoading, + conversation, +}: { + disabled?: boolean | null; + files: Map; + setFiles: FileSetter; + setFilesLoading: React.Dispatch>; + conversation: TConversation | null; +}) => { const localize = useLocalize(); const inputRef = useRef(null); const isUploadDisabled = disabled ?? false; - const { handleFileChange } = useFileHandling(); + const { handleFileChange } = useFileHandlingNoChatContext(undefined, { + files, + setFiles, + setFilesLoading, + conversation, + }); return ( diff --git a/client/src/components/Chat/Input/Files/AttachFileChat.tsx b/client/src/components/Chat/Input/Files/AttachFileChat.tsx index 2f954d01d5..7eb9b0c474 100644 --- a/client/src/components/Chat/Input/Files/AttachFileChat.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileChat.tsx @@ -9,6 +9,7 @@ import { getEndpointFileConfig, } from 'librechat-data-provider'; import type { TConversation } from 'librechat-data-provider'; +import type { ExtendedFile, FileSetter } from '~/common'; import { useGetFileConfig, useGetEndpointsQuery, useGetAgentByIdQuery } from '~/data-provider'; import { useAgentsMapContext } from '~/Providers'; import AttachFileMenu from './AttachFileMenu'; @@ -17,9 +18,15 @@ import AttachFile from './AttachFile'; function AttachFileChat({ disableInputs, conversation, + files, + setFiles, + setFilesLoading, }: { disableInputs: boolean; conversation: TConversation | null; + files: Map; + setFiles: FileSetter; + setFilesLoading: React.Dispatch>; }) { const conversationId = conversation?.conversationId ?? Constants.NEW_CONVO; const { endpoint } = conversation ?? { endpoint: null }; @@ -90,7 +97,15 @@ function AttachFileChat({ ); if (isAssistants && endpointSupportsFiles && !isUploadDisabled) { - return ; + return ( + + ); } else if ((isAgents || endpointSupportsFiles) && !isUploadDisabled) { return ( ); } diff --git a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx index 62072e49e5..181d219c08 100644 --- a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx @@ -23,15 +23,16 @@ import { bedrockDocumentExtensions, isDocumentSupportedProvider, } from 'librechat-data-provider'; -import type { EndpointFileConfig } from 'librechat-data-provider'; +import type { EndpointFileConfig, TConversation } from 'librechat-data-provider'; +import type { ExtendedFile, FileSetter } from '~/common'; import { useAgentToolPermissions, useAgentCapabilities, useGetAgentsConfig, - useFileHandling, + useFileHandlingNoChatContext, useLocalize, } from '~/hooks'; -import useSharePointFileHandling from '~/hooks/Files/useSharePointFileHandling'; +import { useSharePointFileHandlingNoChatContext } from '~/hooks/Files/useSharePointFileHandling'; import { SharePointPickerDialog } from '~/components/SharePoint'; import { useGetStartupConfig } from '~/data-provider'; import { ephemeralAgentByConvoId } from '~/store'; @@ -53,6 +54,10 @@ interface AttachFileMenuProps { endpointType?: EModelEndpoint | string; endpointFileConfig?: EndpointFileConfig; useResponsesApi?: boolean; + files: Map; + setFiles: FileSetter; + setFilesLoading: React.Dispatch>; + conversation: TConversation | null; } const AttachFileMenu = ({ @@ -63,6 +68,10 @@ const AttachFileMenu = ({ conversationId, endpointFileConfig, useResponsesApi, + files, + setFiles, + setFilesLoading, + conversation, }: AttachFileMenuProps) => { const localize = useLocalize(); const isUploadDisabled = disabled ?? false; @@ -72,10 +81,17 @@ const AttachFileMenu = ({ ephemeralAgentByConvoId(conversationId), ); const [toolResource, setToolResource] = useState(); - const { handleFileChange } = useFileHandling(); - const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({ - toolResource, + const { handleFileChange } = useFileHandlingNoChatContext(undefined, { + files, + setFiles, + setFilesLoading, + conversation, }); + const { handleSharePointFiles, isProcessing, downloadProgress } = + useSharePointFileHandlingNoChatContext( + { toolResource }, + { files, setFiles, setFilesLoading, conversation }, + ); const { agentsConfig } = useGetAgentsConfig(); const { data: startupConfig } = useGetStartupConfig(); diff --git a/client/src/components/Chat/Input/Files/FileFormChat.tsx b/client/src/components/Chat/Input/Files/FileFormChat.tsx index 3c01f2d642..4d37938c5d 100644 --- a/client/src/components/Chat/Input/Files/FileFormChat.tsx +++ b/client/src/components/Chat/Input/Files/FileFormChat.tsx @@ -1,16 +1,30 @@ import { memo } from 'react'; import { useRecoilValue } from 'recoil'; import type { TConversation } from 'librechat-data-provider'; -import { useChatContext } from '~/Providers'; -import { useFileHandling } from '~/hooks'; +import type { ExtendedFile, FileSetter } from '~/common'; +import { useFileHandlingNoChatContext } from '~/hooks'; import FileRow from './FileRow'; import store from '~/store'; -function FileFormChat({ conversation }: { conversation: TConversation | null }) { - const { files, setFiles, setFilesLoading } = useChatContext(); +function FileFormChat({ + conversation, + files, + setFiles, + setFilesLoading, +}: { + conversation: TConversation | null; + files: Map; + setFiles: FileSetter; + setFilesLoading: React.Dispatch>; +}) { const chatDirection = useRecoilValue(store.chatDirection).toLowerCase(); const { endpoint: _endpoint } = conversation ?? { endpoint: null }; - const { abortUpload } = useFileHandling(); + const { abortUpload } = useFileHandlingNoChatContext(undefined, { + files, + setFiles, + setFilesLoading, + conversation, + }); const isRTL = chatDirection === 'rtl'; diff --git a/client/src/components/Chat/Input/Files/__tests__/AttachFileChat.spec.tsx b/client/src/components/Chat/Input/Files/__tests__/AttachFileChat.spec.tsx index cea55f5ce8..80f06a1b89 100644 --- a/client/src/components/Chat/Input/Files/__tests__/AttachFileChat.spec.tsx +++ b/client/src/components/Chat/Input/Files/__tests__/AttachFileChat.spec.tsx @@ -59,7 +59,13 @@ function renderComponent(conversation: Record | null, disableIn return render( - + {}} + setFilesLoading={() => {}} + /> , ); diff --git a/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx b/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx index cf08721207..c2710d4ef8 100644 --- a/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx +++ b/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx @@ -9,13 +9,14 @@ jest.mock('~/hooks', () => ({ useAgentToolPermissions: jest.fn(), useAgentCapabilities: jest.fn(), useGetAgentsConfig: jest.fn(), - useFileHandling: jest.fn(), + useFileHandlingNoChatContext: jest.fn(), useLocalize: jest.fn(), })); jest.mock('~/hooks/Files/useSharePointFileHandling', () => ({ __esModule: true, default: jest.fn(), + useSharePointFileHandlingNoChatContext: jest.fn(), })); jest.mock('~/data-provider', () => ({ @@ -52,6 +53,7 @@ jest.mock('@librechat/client', () => { ), AttachmentIcon: () => R.createElement('span', { 'data-testid': 'attachment-icon' }), SharePointIcon: () => R.createElement('span', { 'data-testid': 'sharepoint-icon' }), + useToastContext: () => ({ showToast: jest.fn() }), }; }); @@ -66,11 +68,14 @@ jest.mock('@ariakit/react', () => { const mockUseAgentToolPermissions = jest.requireMock('~/hooks').useAgentToolPermissions; const mockUseAgentCapabilities = jest.requireMock('~/hooks').useAgentCapabilities; const mockUseGetAgentsConfig = jest.requireMock('~/hooks').useGetAgentsConfig; -const mockUseFileHandling = jest.requireMock('~/hooks').useFileHandling; +const mockUseFileHandlingNoChatContext = jest.requireMock('~/hooks').useFileHandlingNoChatContext; const mockUseLocalize = jest.requireMock('~/hooks').useLocalize; const mockUseSharePointFileHandling = jest.requireMock( '~/hooks/Files/useSharePointFileHandling', ).default; +const mockUseSharePointFileHandlingNoChatContext = jest.requireMock( + '~/hooks/Files/useSharePointFileHandling', +).useSharePointFileHandlingNoChatContext; const mockUseGetStartupConfig = jest.requireMock('~/data-provider').useGetStartupConfig; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -92,12 +97,15 @@ function setupMocks(overrides: { provider?: string } = {}) { codeEnabled: false, }); mockUseGetAgentsConfig.mockReturnValue({ agentsConfig: {} }); - mockUseFileHandling.mockReturnValue({ handleFileChange: jest.fn() }); - mockUseSharePointFileHandling.mockReturnValue({ + mockUseFileHandlingNoChatContext.mockReturnValue({ handleFileChange: jest.fn() }); + const sharePointReturnValue = { handleSharePointFiles: jest.fn(), isProcessing: false, downloadProgress: 0, - }); + error: null, + }; + mockUseSharePointFileHandling.mockReturnValue(sharePointReturnValue); + mockUseSharePointFileHandlingNoChatContext.mockReturnValue(sharePointReturnValue); mockUseGetStartupConfig.mockReturnValue({ data: { sharePointFilePickerEnabled: false } }); mockUseAgentToolPermissions.mockReturnValue({ fileSearchAllowedByAgent: false, @@ -110,7 +118,14 @@ function renderMenu(props: Record = {}) { return render( - + {}} + setFilesLoading={() => {}} + conversation={null} + {...props} + /> , ); diff --git a/client/src/components/Chat/Input/StopButton.tsx b/client/src/components/Chat/Input/StopButton.tsx index 4a058777f1..fd94ba806c 100644 --- a/client/src/components/Chat/Input/StopButton.tsx +++ b/client/src/components/Chat/Input/StopButton.tsx @@ -1,8 +1,15 @@ +import { memo } from 'react'; import { TooltipAnchor } from '@librechat/client'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; -export default function StopButton({ stop, setShowStopButton }) { +export default memo(function StopButton({ + stop, + setShowStopButton, +}: { + stop: (e: React.MouseEvent) => void; + setShowStopButton: (value: boolean) => void; +}) { const localize = useLocalize(); return ( @@ -34,4 +41,4 @@ export default function StopButton({ stop, setShowStopButton }) { } > ); -} +}); diff --git a/client/src/components/Chat/Input/TextareaHeader.tsx b/client/src/components/Chat/Input/TextareaHeader.tsx index 9e67252efe..06c1802585 100644 --- a/client/src/components/Chat/Input/TextareaHeader.tsx +++ b/client/src/components/Chat/Input/TextareaHeader.tsx @@ -1,8 +1,9 @@ +import { memo } from 'react'; import AddedConvo from './AddedConvo'; import type { TConversation } from 'librechat-data-provider'; import type { SetterOrUpdater } from 'recoil'; -export default function TextareaHeader({ +export default memo(function TextareaHeader({ addedConvo, setAddedConvo, }: { @@ -17,4 +18,4 @@ export default function TextareaHeader({
); -} +}); diff --git a/client/src/components/Chat/Messages/Message.tsx b/client/src/components/Chat/Messages/Message.tsx index f9db38fdab..53aef812fc 100644 --- a/client/src/components/Chat/Messages/Message.tsx +++ b/client/src/components/Chat/Messages/Message.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useMessageProcess } from '~/hooks'; +import { useMessageProcess, useMemoizedChatContext } from '~/hooks'; import type { TMessageProps } from '~/common'; import MessageRender from './ui/MessageRender'; import MultiMessage from './MultiMessage'; @@ -23,10 +23,11 @@ const MessageContainer = React.memo(function MessageContainer({ }); export default function Message(props: TMessageProps) { - const { conversation, handleScroll } = useMessageProcess({ + const { conversation, handleScroll, isSubmitting } = useMessageProcess({ message: props.message, }); const { message, currentEditId, setCurrentEditId } = props; + const { chatContext, effectiveIsSubmitting } = useMemoizedChatContext(message, isSubmitting); if (!message || typeof message !== 'object') { return null; @@ -38,7 +39,11 @@ export default function Message(props: TMessageProps) { <>
- +
; +/** + * Custom comparator for React.memo: compares `message` by key fields instead of reference + * because `buildTree` creates new message objects on every streaming update for ALL messages, + * even when only the latest message's text changed. + */ +function areMessageRenderPropsEqual(prev: MessageRenderProps, next: MessageRenderProps): boolean { + if (prev.isSubmitting !== next.isSubmitting) { + return false; + } + if (prev.chatContext !== next.chatContext) { + return false; + } + if (prev.siblingIdx !== next.siblingIdx) { + return false; + } + if (prev.siblingCount !== next.siblingCount) { + return false; + } + if (prev.currentEditId !== next.currentEditId) { + return false; + } + if (prev.setSiblingIdx !== next.setSiblingIdx) { + return false; + } + if (prev.setCurrentEditId !== next.setCurrentEditId) { + return false; + } + + const prevMsg = prev.message; + const nextMsg = next.message; + if (prevMsg === nextMsg) { + return true; + } + if (!prevMsg || !nextMsg) { + return prevMsg === nextMsg; + } + + return ( + prevMsg.messageId === nextMsg.messageId && + prevMsg.text === nextMsg.text && + prevMsg.error === nextMsg.error && + prevMsg.unfinished === nextMsg.unfinished && + prevMsg.depth === nextMsg.depth && + prevMsg.isCreatedByUser === nextMsg.isCreatedByUser && + (prevMsg.children?.length ?? 0) === (nextMsg.children?.length ?? 0) && + prevMsg.content === nextMsg.content && + prevMsg.model === nextMsg.model && + prevMsg.endpoint === nextMsg.endpoint && + prevMsg.iconURL === nextMsg.iconURL && + prevMsg.feedback?.rating === nextMsg.feedback?.rating && + (prevMsg.files?.length ?? 0) === (nextMsg.files?.length ?? 0) + ); +} + const MessageRender = memo(function MessageRender({ message: msg, siblingIdx, @@ -31,6 +92,7 @@ const MessageRender = memo(function MessageRender({ currentEditId, setCurrentEditId, isSubmitting = false, + chatContext, }: MessageRenderProps) { const localize = useLocalize(); const { @@ -52,6 +114,7 @@ const MessageRender = memo(function MessageRender({ message: msg, currentEditId, setCurrentEditId, + chatContext, }); const fontSize = useAtomValue(fontSizeAtom); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); @@ -63,8 +126,6 @@ const MessageRender = memo(function MessageRender({ [hasNoChildren, msg?.depth, latestMessageDepth], ); const isLatestMessage = msg?.messageId === latestMessageId; - /** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */ - const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; const iconData: TMessageIcon = useMemo( () => ({ @@ -92,10 +153,10 @@ const MessageRender = memo(function MessageRender({ messageId, isLatestMessage, isExpanded: false as const, - isSubmitting: effectiveIsSubmitting, + isSubmitting, conversationId: conversation?.conversationId, }), - [messageId, conversation?.conversationId, effectiveIsSubmitting, isLatestMessage], + [messageId, conversation?.conversationId, isSubmitting, isLatestMessage], ); if (!msg) { @@ -165,7 +226,7 @@ const MessageRender = memo(function MessageRender({ message={msg} enterEdit={enterEdit} error={!!(msg.error ?? false)} - isSubmitting={effectiveIsSubmitting} + isSubmitting={isSubmitting} unfinished={msg.unfinished ?? false} isCreatedByUser={msg.isCreatedByUser ?? true} siblingIdx={siblingIdx ?? 0} @@ -173,7 +234,7 @@ const MessageRender = memo(function MessageRender({ />
- {hasNoChildren && effectiveIsSubmitting ? ( + {hasNoChildren && isSubmitting ? ( ) : ( @@ -187,7 +248,7 @@ const MessageRender = memo(function MessageRender({ isEditing={edit} message={msg} enterEdit={enterEdit} - isSubmitting={isSubmitting} + isSubmitting={chatContext.isSubmitting} conversation={conversation ?? null} regenerate={handleRegenerateMessage} copyToClipboard={copyToClipboard} @@ -202,7 +263,7 @@ const MessageRender = memo(function MessageRender({ ); -}); +}, areMessageRenderPropsEqual); MessageRender.displayName = 'MessageRender'; export default MessageRender; diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index 6b3f05ce5d..4ba8db36f8 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, memo } from 'react'; import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; -import type { TMessageProps, TMessageIcon } from '~/common'; +import type { TMessageProps, TMessageIcon, TMessageChatContext } from '~/common'; import { useAttachments, useLocalize, useMessageActions, useContentMetadata } from '~/hooks'; import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils'; import ContentParts from '~/components/Chat/Messages/Content/ContentParts'; @@ -16,12 +16,72 @@ import store from '~/store'; type ContentRenderProps = { message?: TMessage; + /** + * Effective isSubmitting: false for non-latest messages, real value for latest. + * Computed by the wrapper (MessageContent.tsx) so this memo'd component only re-renders + * when the value actually matters. + */ isSubmitting?: boolean; + /** Stable context object from wrapper — avoids ChatContext subscription inside memo */ + chatContext: TMessageChatContext; } & Pick< TMessageProps, 'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount' >; +/** + * Custom comparator for React.memo: compares `message` by key fields instead of reference + * because `buildTree` creates new message objects on every streaming update for ALL messages. + */ +function areContentRenderPropsEqual(prev: ContentRenderProps, next: ContentRenderProps): boolean { + if (prev.isSubmitting !== next.isSubmitting) { + return false; + } + if (prev.chatContext !== next.chatContext) { + return false; + } + if (prev.siblingIdx !== next.siblingIdx) { + return false; + } + if (prev.siblingCount !== next.siblingCount) { + return false; + } + if (prev.currentEditId !== next.currentEditId) { + return false; + } + if (prev.setSiblingIdx !== next.setSiblingIdx) { + return false; + } + if (prev.setCurrentEditId !== next.setCurrentEditId) { + return false; + } + + const prevMsg = prev.message; + const nextMsg = next.message; + if (prevMsg === nextMsg) { + return true; + } + if (!prevMsg || !nextMsg) { + return prevMsg === nextMsg; + } + + return ( + prevMsg.messageId === nextMsg.messageId && + prevMsg.text === nextMsg.text && + prevMsg.error === nextMsg.error && + prevMsg.unfinished === nextMsg.unfinished && + prevMsg.depth === nextMsg.depth && + prevMsg.isCreatedByUser === nextMsg.isCreatedByUser && + (prevMsg.children?.length ?? 0) === (nextMsg.children?.length ?? 0) && + prevMsg.content === nextMsg.content && + prevMsg.model === nextMsg.model && + prevMsg.endpoint === nextMsg.endpoint && + prevMsg.iconURL === nextMsg.iconURL && + prevMsg.feedback?.rating === nextMsg.feedback?.rating && + (prevMsg.attachments?.length ?? 0) === (nextMsg.attachments?.length ?? 0) + ); +} + const ContentRender = memo(function ContentRender({ message: msg, siblingIdx, @@ -30,6 +90,7 @@ const ContentRender = memo(function ContentRender({ currentEditId, setCurrentEditId, isSubmitting = false, + chatContext, }: ContentRenderProps) { const localize = useLocalize(); const { attachments, searchResults } = useAttachments({ @@ -55,6 +116,7 @@ const ContentRender = memo(function ContentRender({ searchResults, currentEditId, setCurrentEditId, + chatContext, }); const fontSize = useAtomValue(fontSizeAtom); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); @@ -66,8 +128,6 @@ const ContentRender = memo(function ContentRender({ ); const hasNoChildren = !(msg?.children?.length ?? 0); const isLatestMessage = msg?.messageId === latestMessageId; - /** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */ - const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; const iconData: TMessageIcon = useMemo( () => ({ @@ -158,13 +218,13 @@ const ContentRender = memo(function ContentRender({ searchResults={searchResults} setSiblingIdx={setSiblingIdx} isLatestMessage={isLatestMessage} - isSubmitting={effectiveIsSubmitting} + isSubmitting={isSubmitting} isCreatedByUser={msg.isCreatedByUser} conversationId={conversation?.conversationId} content={msg.content as Array} /> - {hasNoChildren && effectiveIsSubmitting ? ( + {hasNoChildren && isSubmitting ? ( ) : ( @@ -178,7 +238,7 @@ const ContentRender = memo(function ContentRender({ message={msg} isEditing={edit} enterEdit={enterEdit} - isSubmitting={isSubmitting} + isSubmitting={chatContext.isSubmitting} conversation={conversation ?? null} regenerate={handleRegenerateMessage} copyToClipboard={copyToClipboard} @@ -193,7 +253,7 @@ const ContentRender = memo(function ContentRender({ ); -}); +}, areContentRenderPropsEqual); ContentRender.displayName = 'ContentRender'; export default ContentRender; diff --git a/client/src/components/Messages/MessageContent.tsx b/client/src/components/Messages/MessageContent.tsx index 0e53b1c840..977e397022 100644 --- a/client/src/components/Messages/MessageContent.tsx +++ b/client/src/components/Messages/MessageContent.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useMessageProcess } from '~/hooks'; +import { useMessageProcess, useMemoizedChatContext } from '~/hooks'; import type { TMessageProps } from '~/common'; import MultiMessage from '~/components/Chat/Messages/MultiMessage'; @@ -28,6 +28,7 @@ export default function MessageContent(props: TMessageProps) { message: props.message, }); const { message, currentEditId, setCurrentEditId } = props; + const { chatContext, effectiveIsSubmitting } = useMemoizedChatContext(message, isSubmitting); if (!message || typeof message !== 'object') { return null; @@ -39,7 +40,11 @@ export default function MessageContent(props: TMessageProps) { <>
- +
({ + conversation, + setConversation, + generateConversation, + }), + [conversation, setConversation, generateConversation], + ); } diff --git a/client/src/hooks/Files/index.ts b/client/src/hooks/Files/index.ts index df86c02a96..499572f0e0 100644 --- a/client/src/hooks/Files/index.ts +++ b/client/src/hooks/Files/index.ts @@ -1,6 +1,6 @@ export { default as useDeleteFilesFromTable } from './useDeleteFilesFromTable'; export { default as useSetFilesToDelete } from './useSetFilesToDelete'; -export { default as useFileHandling } from './useFileHandling'; +export { default as useFileHandling, useFileHandlingNoChatContext } from './useFileHandling'; export { default as useFileDeletion } from './useFileDeletion'; export { default as useUpdateFiles } from './useUpdateFiles'; export { default as useDragHelpers } from './useDragHelpers'; diff --git a/client/src/hooks/Messages/index.ts b/client/src/hooks/Messages/index.ts index a78a1ef553..439b7e152e 100644 --- a/client/src/hooks/Messages/index.ts +++ b/client/src/hooks/Messages/index.ts @@ -5,6 +5,7 @@ export { default as useSubmitMessage } from './useSubmitMessage'; export type { ContentMetadataResult } from './useContentMetadata'; export { default as useExpandCollapse } from './useExpandCollapse'; export { default as useMessageActions } from './useMessageActions'; +export { default as useMemoizedChatContext } from './useMemoizedChatContext'; export { default as useMessageProcess } from './useMessageProcess'; export { default as useMessageHelpers } from './useMessageHelpers'; export { default as useCopyToClipboard } from './useCopyToClipboard'; diff --git a/client/src/hooks/Messages/useMemoizedChatContext.ts b/client/src/hooks/Messages/useMemoizedChatContext.ts new file mode 100644 index 0000000000..aa35372a8e --- /dev/null +++ b/client/src/hooks/Messages/useMemoizedChatContext.ts @@ -0,0 +1,80 @@ +import { useRef, useMemo } from 'react'; +import type { TMessage } from 'librechat-data-provider'; +import type { TMessageChatContext } from '~/common/types'; +import { useChatContext } from '~/Providers'; + +/** + * Creates a stable `TMessageChatContext` object for memo'd message components. + * + * Subscribes to `useChatContext()` internally (intended to be called from non-memo'd + * wrapper components like `Message` and `MessageContent`), then produces: + * - A `chatContext` object that stays referentially stable during streaming + * (uses a getter for `isSubmitting` backed by a ref) + * - A stable `conversation` reference that only updates when rendering-relevant fields change + * - An `effectiveIsSubmitting` value (false for non-latest messages) + */ +export default function useMemoizedChatContext( + message: TMessage | null | undefined, + isSubmitting: boolean, +) { + const chatCtx = useChatContext(); + + const isSubmittingRef = useRef(isSubmitting); + isSubmittingRef.current = isSubmitting; + + /** + * Stabilize conversation: only update when rendering-relevant fields change, + * not on every metadata update (e.g., title generation). + */ + const stableConversation = useMemo( + () => chatCtx.conversation, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + chatCtx.conversation?.conversationId, + chatCtx.conversation?.endpoint, + chatCtx.conversation?.endpointType, + chatCtx.conversation?.model, + chatCtx.conversation?.agent_id, + chatCtx.conversation?.assistant_id, + ], + ); + + /** + * `isSubmitting` is included in deps so that chatContext gets a new reference + * when streaming starts/ends (2x per session). This ensures HoverButtons + * re-renders to update regenerate/edit button visibility via useGenerationsByLatest. + * The getter pattern is still valuable: callbacks reading chatContext.isSubmitting + * at call-time always get the current value even between these re-renders. + */ + const chatContext: TMessageChatContext = useMemo( + () => ({ + ask: chatCtx.ask, + index: chatCtx.index, + regenerate: chatCtx.regenerate, + conversation: stableConversation, + latestMessageId: chatCtx.latestMessageId, + latestMessageDepth: chatCtx.latestMessageDepth, + handleContinue: chatCtx.handleContinue, + get isSubmitting() { + return isSubmittingRef.current; + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + chatCtx.ask, + chatCtx.index, + chatCtx.regenerate, + stableConversation, + chatCtx.latestMessageId, + chatCtx.latestMessageDepth, + chatCtx.handleContinue, + isSubmitting, // intentional: forces new reference on streaming start/end so HoverButtons re-renders + ], + ); + + const messageId = message?.messageId ?? null; + const isLatestMessage = messageId === chatCtx.latestMessageId; + const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; + + return { chatContext, effectiveIsSubmitting }; +} diff --git a/client/src/hooks/Messages/useMessageActions.tsx b/client/src/hooks/Messages/useMessageActions.tsx index e8946b895b..590ba6a40e 100644 --- a/client/src/hooks/Messages/useMessageActions.tsx +++ b/client/src/hooks/Messages/useMessageActions.tsx @@ -11,7 +11,8 @@ import { TUpdateFeedbackRequest, } from 'librechat-data-provider'; import type { TMessageProps } from '~/common'; -import { useChatContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers'; +import type { TMessageChatContext } from '~/common/types'; +import { useAssistantsMapContext, useAgentsMapContext } from '~/Providers'; import useCopyToClipboard from './useCopyToClipboard'; import { useAuthContext } from '~/hooks/AuthContext'; import { useGetAddedConvo } from '~/hooks/Chat'; @@ -23,24 +24,33 @@ export type TMessageActions = Pick< 'message' | 'currentEditId' | 'setCurrentEditId' > & { searchResults?: { [key: string]: SearchResultData }; + /** + * Stable context object passed from wrapper components to avoid subscribing + * to ChatContext inside memo'd components (which would bypass React.memo). + * The `isSubmitting` property uses a getter backed by a ref, so it always + * returns the current value at call-time without triggering re-renders. + */ + chatContext: TMessageChatContext; }; export default function useMessageActions(props: TMessageActions) { const localize = useLocalize(); const { user } = useAuthContext(); const UsernameDisplay = useRecoilValue(store.UsernameDisplay); - const { message, currentEditId, setCurrentEditId, searchResults } = props; + const { message, currentEditId, setCurrentEditId, searchResults, chatContext } = props; const { ask, index, regenerate, - isSubmitting, conversation, latestMessageId, latestMessageDepth, handleContinue, - } = useChatContext(); + // NOTE: isSubmitting is intentionally NOT destructured here. + // chatContext.isSubmitting is a getter backed by a ref — destructuring + // would capture a one-time snapshot. Always access via chatContext.isSubmitting. + } = chatContext; const getAddedConvo = useGetAddedConvo(); @@ -98,13 +108,18 @@ export default function useMessageActions(props: TMessageActions) { } }, [agentsMap, conversation?.agent_id, conversation?.endpoint, message?.model]); + /** + * chatContext.isSubmitting is a getter backed by the wrapper's ref, + * so it always returns the current value at call-time — even for + * non-latest messages that don't re-render during streaming. + */ const regenerateMessage = useCallback(() => { - if ((isSubmitting && isCreatedByUser === true) || !message) { + if ((chatContext.isSubmitting && isCreatedByUser === true) || !message) { return; } regenerate(message, { addedConvo: getAddedConvo() }); - }, [isSubmitting, isCreatedByUser, message, regenerate, getAddedConvo]); + }, [chatContext, isCreatedByUser, message, regenerate, getAddedConvo]); const copyToClipboard = useCopyToClipboard({ text, content, searchResults }); diff --git a/client/src/hooks/Messages/useMessageProcess.tsx b/client/src/hooks/Messages/useMessageProcess.tsx index 37738b50a9..bb49670a2f 100644 --- a/client/src/hooks/Messages/useMessageProcess.tsx +++ b/client/src/hooks/Messages/useMessageProcess.tsx @@ -1,6 +1,6 @@ import throttle from 'lodash/throttle'; import { Constants } from 'librechat-data-provider'; -import { useEffect, useRef, useCallback, useMemo } from 'react'; +import { useEffect, useRef, useMemo } from 'react'; import type { TMessage } from 'librechat-data-provider'; import { getTextKey, TEXT_KEY_DIVIDER, logger } from '~/utils'; import { useMessagesViewContext } from '~/Providers'; @@ -56,24 +56,25 @@ export default function useMessageProcess({ message }: { message?: TMessage | nu } }, [hasNoChildren, message, setLatestMessage, conversation?.conversationId]); - const handleScroll = useCallback( - (event: unknown | TouchEvent | WheelEvent) => { - throttle(() => { + /** Use ref for isSubmitting to stabilize handleScroll across isSubmitting changes */ + const isSubmittingRef = useRef(isSubmitting); + isSubmittingRef.current = isSubmitting; + + const handleScroll = useMemo( + () => + throttle((event: unknown) => { logger.log( 'message_scrolling', - `useMessageProcess: setting abort scroll to ${isSubmitting}, handleScroll event`, + `useMessageProcess: setting abort scroll to ${isSubmittingRef.current}, handleScroll event`, event, ); - if (isSubmitting) { - setAbortScroll(true); - } else { - setAbortScroll(false); - } - }, 500)(); - }, - [isSubmitting, setAbortScroll], + setAbortScroll(isSubmittingRef.current); + }, 500), + [setAbortScroll], ); + useEffect(() => () => handleScroll.cancel(), [handleScroll]); + return { handleScroll, isSubmitting,