From 7e2b51697edf9a5961db94b6abf48ac8449dfb9b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 29 Mar 2026 17:05:12 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=A2=20refactor:=20Eliminate=20Unnecess?= =?UTF-8?q?ary=20Re-renders=20During=20Message=20Streaming=20(#12454)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: add TMessageChatContext type for stable context passing Defines a type for a stable context object that wrapper components pass to memo'd message components, avoiding direct ChatContext subscriptions that bypass React.memo during streaming. * perf: remove ChatContext subscription from useMessageActions useMessageActions previously called useChatContext() inside memo'd components (MessageRender, ContentRender), bypassing React.memo when isSubmitting changed during streaming. Now accepts a stable chatContext param instead, using a ref for the isSubmitting guard in regenerateMessage. Also stabilizes handleScroll in useMessageProcess by using a ref for isSubmitting instead of including it in useCallback deps. * perf: pass stable chatContext to memo'd message components Wrapper components (Message, MessageContent) now create a stable chatContext object via useMemo with a getter-backed isSubmitting, and compute effectiveIsSubmitting (false for non-latest messages). This ensures MessageRender and ContentRender (both React.memo'd) only re-render for the latest message during streaming, preventing unnecessary re-renders of all prior messages and their SubRow, HoverButtons, and SiblingSwitch children. * perf: add custom memo comparators to prevent message reference re-renders buildTree creates new message objects on every streaming update for ALL messages, not just the changed one. This defeats React.memo's default shallow comparison since the message prop has a new reference even when the content hasn't changed. Custom areEqual comparators now compare message by key fields (messageId, text, error, depth, children length, etc.) instead of reference equality, preventing unnecessary re-renders of SubRow, Files, HoverButtons and other children for non-latest messages. * perf: memoize ChatForm children to prevent streaming re-renders - Wrap StopButton in React.memo - Wrap AudioRecorder in React.memo, use ref for isSubmitting in onTranscriptionComplete callback to stabilize it - Remove useChatContext() from FileFormChat (bypassed its memo during streaming), accept files/setFiles/setFilesLoading as props from ChatForm instead * perf: stabilize ChatForm child props to prevent cascading re-renders ChatForm re-renders frequently during streaming (ChatContext changes). This caused StopButton and AttachFileChat/AttachFileMenu to re-render despite being memo'd, because their props were new references each time. - Wrap handleStopGenerating in a ref-based stable callback so StopButton always receives the same function reference - Create stableConversation via useMemo keyed on rendering-relevant fields only (conversationId, endpoint, agent_id, etc.), so AttachFileChat and FileFormChat don't re-render from unrelated conversation metadata updates (e.g., title generation) * perf: remove ChatContext subscription from AttachFileMenu and FileFormChat Both components used useFileHandling() which internally calls useChatContext(), bypassing their React.memo wrappers and causing re-renders on every streaming chunk. Switch to useFileHandlingNoChatContext() which accepts file state as parameters. The state (files, setFiles, setFilesLoading, conversation) is passed down from ChatForm → AttachFileChat → AttachFileMenu as props, keeping the memo chain intact. * fix: update imports and test mocks for useFileHandlingNoChatContext - Re-export useFileHandlingNoChatContext from hooks barrel - Import from ~/hooks instead of direct path for test compatibility - Add useToastContext mock to @librechat/client in AttachFileMenu tests since useFileHandlingNoChatContext runs the core hook which needs it - Add useFileHandlingNoChatContext to ~/hooks test mock * perf: fix remaining ChatForm streaming re-renders - Switch AttachFileMenu from useSharePointFileHandling (subscribes to ChatContext) to useSharePointFileHandlingNoChatContext with explicit file state props - Memoize ChatForm textarea onFocus/onBlur handlers with useCallback to prevent TextareaAutosize re-renders (inline arrow functions and .bind() created new references on every ChatForm render) - Update AttachFileMenu test mocks for new hook variants * refactor: add displayName to ChatForm for React DevTools * perf: prevent ChatForm re-renders during streaming via wrapper pattern ChatForm was re-rendering on every streaming chunk because it subscribed to useChatContext() internally, and the ChatContext value changed frequently during streaming. Extract context subscription into a ChatFormWrapper that: - Subscribes to useChatContext() (re-renders on every chunk, cheap) - Stabilizes conversation via selective useMemo - Stabilizes handleStopGenerating via ref-based callback - Passes individual stable values as props to ChatForm ChatForm (memo'd) now receives context values as props instead of subscribing directly. Since individual values (files, setFiles, isSubmitting, etc.) are stable references during streaming, ChatForm's memo prevents re-renders entirely — it only re-renders when isSubmitting actually toggles (2x per stream: start/end). * perf: stabilize newConversation prop and memoize CollapseChat - Wrap newConversation in ref-based stable callback in ChatFormWrapper (was the remaining unstable prop causing ChatForm to re-render) - Wrap CollapseChat in React.memo to prevent re-renders from parent * perf: memoize useAddedResponse return value useAddedResponse returned a new object literal on every render, causing AddedChatContext.Provider to trigger re-renders of all consumers (including ChatForm) on every streaming chunk. Wrap in useMemo so the context value stays referentially stable. * perf: memoize TextareaHeader to prevent re-renders from ChatForm * perf: address review findings for streaming render optimization Finding 1: Switch AttachFile.tsx from useFileHandling to useFileHandlingNoChatContext, closing the optimization hole for standard (non-agent) chat endpoints. Finding 2: Replace content reference equality with length comparison in both memo comparators — safer against buildTree array reconstruction. Finding 3: Add conversation?.model to stableConversation deps in ChatFormWrapper so file uploads use the correct model after switches. Finding 4/14: Fix stableNewConversation to explicitly return the underlying call's result instead of discarding it via `as` cast. Finding 5/6: Extract useMemoizedChatContext hook shared by Message.tsx and MessageContent.tsx — eliminates ~70 lines of duplication and stabilizes chatContext.conversation via selective useMemo to prevent post-stream metadata updates from re-rendering all messages. Finding 8: Use TMessage type for regenerate param instead of Record. Finding 9: Use FileSetter alias in FileFormChat instead of inline type. Finding 11: Fix pre-existing broken throttle in useMessageProcess — was creating a new throttle instance per call, providing zero deduplication. Now retains the instance via useMemo. Finding 12: Initialize isSubmittingRef with chatContext.isSubmitting instead of false for consistency. Finding 13: Add ChatFormWrapper displayName. * fix: revert content comparison to reference equality in memo comparators The length-based comparison (content?.length) missed updates within existing content parts during streaming — text chunks update a part's content without changing the array length, so the comparator returned true and skipped re-renders for the latest message. Reference equality (===) is correct here: buildTree preserves content array references for unchanged messages via shallow spread, while React Query gives the latest message a new reference when its content updates during streaming. * fix: cancel throttled handleScroll on unmount and remove unused import * fix: use chatContext getter directly in regenerateMessage callback The local isSubmittingRef was stale for non-latest messages (which don't re-render during streaming by design). chatContext.isSubmitting is a getter backed by the wrapper's ref, so reading it at call-time always returns the current value regardless of whether the component has re-rendered. * fix: remove unused useCallback import from useMemoizedChatContext * fix: pass global isSubmitting to HoverButtons for action gating HoverButtons uses isSubmitting via useGenerationsByLatest to disable regenerate and hide edit buttons during streaming. Passing the effective value (false for non-latest messages) re-enabled those actions mid-stream, risking overlapping edits/regenerations. Use chatContext.isSubmitting (getter, always returns current value) for HoverButtons while keeping the effective value for rendering-only UI (cursor, placeholder, streaming indicator). * fix: address second review — stale HoverButtons, messages dep, cleanup - Add isSubmitting to chatContext useMemo deps in useMemoizedChatContext so HoverButtons correctly updates when streaming starts/ends (2 extra re-renders per session, belt-and-suspenders for post-stream state) - Change conversation?.messages?.length dep to boolean in ChatFormWrapper stableConversation — only need 0↔1+ transition for landing page check, not exact count on every message addition - Add defensive comment at chatContext destructuring point in useMessageActions explaining why isSubmitting must not be destructured - Remove dead mockUseFileHandling.mockReturnValue from AttachFileMenu tests * chore: remove dead useFileHandling mock artifacts from AttachFileMenu tests * fix: resolve eslint warnings for useMemo dependencies - Extract complex expression (conversation?.messages?.length ?? 0) > 0 to hasMessages variable for static analysis in ChatFormWrapper - Add eslint-disable for intentional isSubmitting dep in useMemoizedChatContext (forces new chatContext reference on streaming start/end so HoverButtons re-renders) --- client/src/common/types.ts | 22 +++ .../components/Chat/Input/AudioRecorder.tsx | 12 +- client/src/components/Chat/Input/ChatForm.tsx | 141 +++++++++++++++--- .../components/Chat/Input/CollapseChat.tsx | 2 +- .../Chat/Input/Files/AttachFile.tsx | 25 +++- .../Chat/Input/Files/AttachFileChat.tsx | 21 ++- .../Chat/Input/Files/AttachFileMenu.tsx | 28 +++- .../Chat/Input/Files/FileFormChat.tsx | 24 ++- .../Files/__tests__/AttachFileChat.spec.tsx | 8 +- .../Files/__tests__/AttachFileMenu.spec.tsx | 27 +++- .../src/components/Chat/Input/StopButton.tsx | 11 +- .../components/Chat/Input/TextareaHeader.tsx | 5 +- .../src/components/Chat/Messages/Message.tsx | 11 +- .../Chat/Messages/ui/MessageRender.tsx | 79 ++++++++-- .../src/components/Messages/ContentRender.tsx | 74 ++++++++- .../components/Messages/MessageContent.tsx | 9 +- client/src/hooks/Chat/useAddedResponse.ts | 15 +- client/src/hooks/Files/index.ts | 2 +- client/src/hooks/Messages/index.ts | 1 + .../hooks/Messages/useMemoizedChatContext.ts | 80 ++++++++++ .../src/hooks/Messages/useMessageActions.tsx | 27 +++- .../src/hooks/Messages/useMessageProcess.tsx | 27 ++-- 22 files changed, 554 insertions(+), 97 deletions(-) create mode 100644 client/src/hooks/Messages/useMemoizedChatContext.ts 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,