diff --git a/client/src/components/Chat/Messages/Message.tsx b/client/src/components/Chat/Messages/Message.tsx index fc2a79fca0..ad7707310f 100644 --- a/client/src/components/Chat/Messages/Message.tsx +++ b/client/src/components/Chat/Messages/Message.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { useMessageProcess, useMemoizedChatContext } from '~/hooks'; import type { TMessageProps } from '~/common'; +import { useMessageProcess, useMemoizedChatContext } from '~/hooks'; +import { areMessageRowPropsEqual } from '~/utils'; import MessageRender from './ui/MessageRender'; -import MultiMessage from './MultiMessage'; const MessageContainer = React.memo(function MessageContainer({ handleScroll, @@ -22,37 +22,24 @@ const MessageContainer = React.memo(function MessageContainer({ ); }); -export default function Message(props: TMessageProps) { - const { conversation, handleScroll, isSubmitting } = useMessageProcess({ +function Message(props: TMessageProps) { + const { handleScroll, isSubmitting } = useMessageProcess({ message: props.message, }); - const { message, currentEditId, setCurrentEditId } = props; + const { message } = props; const { chatContext, effectiveIsSubmitting } = useMemoizedChatContext(message, isSubmitting); if (!message || typeof message !== 'object') { return null; } - const { children, messageId = null } = message; - return ( - <> - -
- -
-
- - + +
+ +
+
); } + +export default React.memo(Message, areMessageRowPropsEqual); diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index c4b5930214..4798bcd9a0 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -3,22 +3,25 @@ import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import type { TMessageContentParts } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; +import { + cn, + getMessageAriaLabel, + areMessageRowPropsEqual, + getHeaderPrefixForScreenReader, +} from '~/utils'; import { useMessageHelpers, useLocalize, useAttachments, useContentMetadata } from '~/hooks'; -import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils'; import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import ContentParts from './Content/ContentParts'; import { fontSizeAtom } from '~/store/fontSize'; import SiblingSwitch from './SiblingSwitch'; -import MultiMessage from './MultiMessage'; import HoverButtons from './HoverButtons'; import SubRow from './SubRow'; import store from '~/store'; -export default function Message(props: TMessageProps) { +function MessageParts(props: TMessageProps) { const localize = useLocalize(); - const { message, siblingIdx, siblingCount, setSiblingIdx, currentEditId, setCurrentEditId } = - props; + const { message, siblingIdx, siblingCount, setSiblingIdx } = props; const { attachments, searchResults } = useAttachments({ messageId: message?.messageId, attachments: message?.attachments, @@ -41,7 +44,7 @@ export default function Message(props: TMessageProps) { const fontSize = useAtomValue(fontSizeAtom); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); - const { children, messageId = null, isCreatedByUser } = message ?? {}; + const { messageId = null, isCreatedByUser } = message ?? {}; const name = useMemo(() => { let result = ''; @@ -186,13 +189,8 @@ export default function Message(props: TMessageProps) { - ); } + +export default React.memo(MessageParts, areMessageRowPropsEqual); diff --git a/client/src/components/Chat/Messages/MessagesView.tsx b/client/src/components/Chat/Messages/MessagesView.tsx index 0e2166b028..477bd14752 100644 --- a/client/src/components/Chat/Messages/MessagesView.tsx +++ b/client/src/components/Chat/Messages/MessagesView.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { memo, useState, useRef, useEffect } from 'react'; import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import { CSSTransition } from 'react-transition-group'; @@ -12,6 +12,72 @@ import MessageNav from './MessageNav'; import { cn } from '~/utils'; import store from '~/store'; +const intersectionThreshold = 0.85; +const visibilityDebounceRate = 150; + +/** + * Owns the messages-end IntersectionObserver and the button visibility state, + * so scroll-position flips re-render only this component instead of the whole + * message tree host. Intersection is reported up through `onNearBottomChange` + * for the resize-follow logic in `useMessageScrolling`. + */ +const ScrollButton = memo(function ScrollButton({ + scrollableRef, + messagesEndRef, + scrollHandler, + onNearBottomChange, +}: { + scrollableRef: React.RefObject; + messagesEndRef: React.RefObject; + scrollHandler: (event: React.MouseEvent) => void; + onNearBottomChange: (isNearBottom: boolean) => void; +}) { + const scrollButtonPreference = useRecoilValue(store.showScrollButton); + const [showScrollButton, setShowScrollButton] = useState(false); + const scrollToBottomRef = useRef(null); + const timeoutIdRef = useRef(); + + useEffect(() => { + if (!messagesEndRef.current || !scrollableRef.current) { + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + onNearBottomChange(entry.isIntersecting); + clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = setTimeout(() => { + setShowScrollButton(!entry.isIntersecting); + }, visibilityDebounceRate); + }, + { root: scrollableRef.current, threshold: intersectionThreshold }, + ); + + observer.observe(messagesEndRef.current); + + return () => { + observer.disconnect(); + clearTimeout(timeoutIdRef.current); + }; + }, [messagesEndRef, scrollableRef, onNearBottomChange]); + + return ( + + + + ); +}); + function MessagesViewContent({ messagesTree: _messagesTree, }: { @@ -20,18 +86,16 @@ function MessagesViewContent({ const localize = useLocalize(); const fontSize = useAtomValue(fontSizeAtom); const { screenshotTargetRef } = useScreenshot(); - const scrollButtonPreference = useRecoilValue(store.showScrollButton); const [currentEditId, setCurrentEditId] = useState(-1); - const scrollToBottomRef = useRef(null); const { conversation, contentRef, scrollableRef, messagesEndRef, - showScrollButton, handleSmoothToRef, debouncedHandleScroll, + handleNearBottomChange, } = useMessageScrolling(_messagesTree); const { conversationId } = conversation ?? {}; @@ -80,19 +144,12 @@ function MessagesViewContent({ - - - + diff --git a/client/src/components/Chat/Messages/MultiMessage.tsx b/client/src/components/Chat/Messages/MultiMessage.tsx index 4a561045f5..f2737d896b 100644 --- a/client/src/components/Chat/Messages/MultiMessage.tsx +++ b/client/src/components/Chat/Messages/MultiMessage.tsx @@ -1,14 +1,15 @@ +import { memo, useEffect, useCallback } from 'react'; import { useRecoilState } from 'recoil'; -import { useEffect, useCallback } from 'react'; import { isAssistantsEndpoint } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider'; +import type { ReactElement } from 'react'; import type { TMessageProps } from '~/common'; import MessageContent from '~/components/Messages/MessageContent'; import MessageParts from './MessageParts'; import Message from './Message'; import store from '~/store'; -export default function MultiMessage({ +function MultiMessage({ // messageId is used recursively here messageId, messagesTree, @@ -48,7 +49,7 @@ export default function MultiMessage({ /** * No explicit key — React uses positional reconciliation since MultiMessage - * always renders exactly one child at this position. + * always renders exactly one row at this position. * * Both messageId and parentMessageId change during the SSE lifecycle * (client UUID → createdHandler ID → server ID), so neither can serve as a @@ -56,8 +57,9 @@ export default function MultiMessage({ * on each SSE event, destroying memoized state and causing visible flickering. * * Without a key, React reuses the component instance and updates props in place. - * The memo comparators on ContentRender/MessageRender handle field-level diffing, - * and sibling switches work correctly because the message prop changes entirely. + * The row wrappers and MessageRender/ContentRender are memoized with field-level + * comparators, and sibling switches work correctly because the message prop + * changes entirely. */ const sharedProps = { message, @@ -68,11 +70,35 @@ export default function MultiMessage({ setSiblingIdx: setSiblingIdxRev, }; + let row: ReactElement; if (isAssistantsEndpoint(message.endpoint) && message.content) { - return ; + row = ; } else if (message.content) { - return ; + row = ; + } else { + row = ; } - return ; + /** + * The child recursion is a sibling of the row (not rendered inside it), so a + * row that bails via its memo comparator never severs the walk that delivers + * streaming updates to descendants: `buildTree` mints fresh `children` arrays + * on every streaming write, which re-renders exactly this spine while settled + * rows skip their subtrees. + */ + return ( + <> + {row} + + + ); } + +const MemoizedMultiMessage = memo(MultiMessage); + +export default MemoizedMultiMessage; diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx index 7bb08e450b..046806b78c 100644 --- a/client/src/components/Chat/Messages/ui/MessageRender.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx @@ -3,7 +3,12 @@ import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import type { TMessage } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon, TMessageChatContext } from '~/common'; -import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils'; +import { + areMessageFieldsEqual, + cn, + getHeaderPrefixForScreenReader, + getMessageAriaLabel, +} from '~/utils'; import MessageContent from '~/components/Chat/Messages/Content/MessageContent'; import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp'; import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks'; @@ -31,24 +36,6 @@ type MessageRenderProps = { 'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount' >; -export function areMessageFilesEqual( - prevFiles: TMessage['files'], - nextFiles: TMessage['files'], -): boolean { - if (prevFiles === nextFiles) { - return true; - } - const prevLength = prevFiles?.length ?? 0; - const nextLength = nextFiles?.length ?? 0; - if (prevLength !== nextLength) { - return false; - } - if (prevLength === 0) { - return true; - } - return prevFiles?.every((file, index) => file === nextFiles?.[index]) ?? true; -} - /** * 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, @@ -77,32 +64,7 @@ function areMessageRenderPropsEqual(prev: MessageRenderProps, next: MessageRende 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.createdAt === nextMsg.createdAt && - 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 && - areMessageFilesEqual(prevMsg.files, nextMsg.files) && - (prevMsg.quotes?.length ?? 0) === (nextMsg.quotes?.length ?? 0) - ); + return areMessageFieldsEqual(prev.message, next.message); } const MessageRender = memo(function MessageRender({ diff --git a/client/src/components/Chat/Messages/ui/__tests__/MessageRender.test.ts b/client/src/components/Chat/Messages/ui/__tests__/MessageRender.test.ts index e9aa5c944b..50d7ebc9d9 100644 --- a/client/src/components/Chat/Messages/ui/__tests__/MessageRender.test.ts +++ b/client/src/components/Chat/Messages/ui/__tests__/MessageRender.test.ts @@ -1,5 +1,5 @@ import type { TFile } from 'librechat-data-provider'; -import { areMessageFilesEqual } from '../MessageRender'; +import { areMessageFilesEqual } from '~/utils'; const file = (overrides: Partial = {}): TFile => ({ diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index 10800aff0f..f8429113de 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -3,8 +3,13 @@ import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon, TMessageChatContext } from '~/common'; +import { + areMessageFieldsEqual, + cn, + getHeaderPrefixForScreenReader, + getMessageAriaLabel, +} from '~/utils'; import { useAttachments, useLocalize, useMessageActions, useContentMetadata } from '~/hooks'; -import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils'; import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp'; import ContentParts from '~/components/Chat/Messages/Content/ContentParts'; import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow'; @@ -57,34 +62,7 @@ function areContentRenderPropsEqual(prev: ContentRenderProps, next: ContentRende 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.createdAt === nextMsg.createdAt && - 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) && - (prevMsg.manualSkills?.length ?? 0) === (nextMsg.manualSkills?.length ?? 0) && - (prevMsg.alwaysAppliedSkills?.length ?? 0) === (nextMsg.alwaysAppliedSkills?.length ?? 0) && - (prevMsg.quotes?.length ?? 0) === (nextMsg.quotes?.length ?? 0) - ); + return areMessageFieldsEqual(prev.message, next.message); } const ContentRender = memo(function ContentRender({ diff --git a/client/src/components/Messages/MessageContent.tsx b/client/src/components/Messages/MessageContent.tsx index 67865ed397..cbf2b5ef77 100644 --- a/client/src/components/Messages/MessageContent.tsx +++ b/client/src/components/Messages/MessageContent.tsx @@ -1,8 +1,7 @@ import React from 'react'; -import { useMessageProcess, useMemoizedChatContext } from '~/hooks'; import type { TMessageProps } from '~/common'; - -import MultiMessage from '~/components/Chat/Messages/MultiMessage'; +import { useMessageProcess, useMemoizedChatContext } from '~/hooks'; +import { areMessageRowPropsEqual } from '~/utils'; import ContentRender from './ContentRender'; const MessageContainer = React.memo(function MessageContainer({ @@ -23,37 +22,24 @@ const MessageContainer = React.memo(function MessageContainer({ ); }); -export default function MessageContent(props: TMessageProps) { - const { conversation, handleScroll, isSubmitting } = useMessageProcess({ +function MessageContent(props: TMessageProps) { + const { handleScroll, isSubmitting } = useMessageProcess({ message: props.message, }); - const { message, currentEditId, setCurrentEditId } = props; + const { message } = props; const { chatContext, effectiveIsSubmitting } = useMemoizedChatContext(message, isSubmitting); if (!message || typeof message !== 'object') { return null; } - const { children, messageId = null } = message; - return ( - <> - -
- -
-
- - + +
+ +
+
); } + +export default React.memo(MessageContent, areMessageRowPropsEqual); diff --git a/client/src/hooks/Messages/useMessageScrolling.ts b/client/src/hooks/Messages/useMessageScrolling.ts index 84e420f848..b15b4aff51 100644 --- a/client/src/hooks/Messages/useMessageScrolling.ts +++ b/client/src/hooks/Messages/useMessageScrolling.ts @@ -1,14 +1,12 @@ +import { useRef, useCallback, useEffect } from 'react'; import { useRecoilValue } from 'recoil'; import { Constants } from 'librechat-data-provider'; -import { useState, useRef, useCallback, useEffect } from 'react'; import type { TMessage } from 'librechat-data-provider'; import { useMessagesConversation, useMessagesSubmission } from '~/Providers'; -import useScrollToRef from '~/hooks/useScrollToRef'; import { reconcileMessageContentLayout } from './messageLayout'; +import useScrollToRef from '~/hooks/useScrollToRef'; import store from '~/store'; -const threshold = 0.85; -const debounceRate = 150; const resizeFollowThreshold = 120; export default function useMessageScrolling(messagesTree?: TMessage[] | null) { @@ -19,12 +17,9 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) { const messagesEndRef = useRef(null); const isNearBottomRef = useRef(true); const suppressNextResizeFollowRef = useRef(false); - const [showScrollButton, setShowScrollButton] = useState(false); const { conversation, conversationId } = useMessagesConversation(); const { setAbortScroll, isSubmitting, abortScroll } = useMessagesSubmission(); - const timeoutIdRef = useRef(); - const getIsNearBottom = useCallback(() => { const scrollEl = scrollableRef.current; if (!scrollEl) { @@ -34,53 +29,20 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) { return distance <= resizeFollowThreshold; }, []); - const debouncedSetShowScrollButton = useCallback((value: boolean) => { - clearTimeout(timeoutIdRef.current); - timeoutIdRef.current = setTimeout(() => { - setShowScrollButton(value); - }, debounceRate); + /** The scroll-to-bottom button owns the IntersectionObserver (so its + * visibility state never re-renders the message tree host) and reports + * intersection back through this callback. */ + const handleNearBottomChange = useCallback((isNearBottom: boolean) => { + isNearBottomRef.current = isNearBottom; }, []); - useEffect(() => { - if (!messagesEndRef.current || !scrollableRef.current) { - return; - } - - const observer = new IntersectionObserver( - ([entry]) => { - isNearBottomRef.current = entry.isIntersecting; - debouncedSetShowScrollButton(!entry.isIntersecting); - }, - { root: scrollableRef.current, threshold }, - ); - - observer.observe(messagesEndRef.current); - - return () => { - observer.disconnect(); - clearTimeout(timeoutIdRef.current); - }; - }, [messagesEndRef, scrollableRef, debouncedSetShowScrollButton]); - const debouncedHandleScroll = useCallback(() => { isNearBottomRef.current = getIsNearBottom(); - if (messagesEndRef.current && scrollableRef.current) { - const observer = new IntersectionObserver( - ([entry]) => { - isNearBottomRef.current = entry.isIntersecting; - debouncedSetShowScrollButton(!entry.isIntersecting); - }, - { root: scrollableRef.current, threshold }, - ); - observer.observe(messagesEndRef.current); - return () => observer.disconnect(); - } - }, [debouncedSetShowScrollButton, getIsNearBottom]); + }, [getIsNearBottom]); const scrollCallback = () => { reconcileMessageContentLayout(scrollableRef.current); isNearBottomRef.current = true; - debouncedSetShowScrollButton(false); }; const { scrollToRef: scrollToBottom, handleSmoothToRef } = useScrollToRef({ @@ -192,8 +154,8 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) { scrollableRef, messagesEndRef, scrollToBottom, - showScrollButton, handleSmoothToRef, debouncedHandleScroll, + handleNearBottomChange, }; } diff --git a/client/src/utils/__tests__/messages.test.ts b/client/src/utils/__tests__/messages.test.ts index 2a628353e1..51710d2723 100644 --- a/client/src/utils/__tests__/messages.test.ts +++ b/client/src/utils/__tests__/messages.test.ts @@ -1,7 +1,7 @@ import { QueryClient } from '@tanstack/react-query'; import { Constants, QueryKeys } from 'librechat-data-provider'; -import type { TMessage } from 'librechat-data-provider'; -import type { LocalizeFunction } from '~/common'; +import type { TMessage, TConversation } from 'librechat-data-provider'; +import type { LocalizeFunction, TMessageProps } from '~/common'; import { clearMessagesCache, clearDeletedConversationMessagesCache, @@ -9,6 +9,8 @@ import { getMessageAriaLabel, getMessageTimestamp, getHeaderPrefixForScreenReader, + areMessageFieldsEqual, + areMessageRowPropsEqual, } from '../messages'; const translations: Record = { @@ -222,3 +224,123 @@ describe('getMessageTimestamp', () => { expect(getMessageTimestamp(iso, 'not a locale!!')).not.toBeNull(); }); }); + +const noop = () => {}; +/** Shared content reference so the baseline compares equal on `content` (which + * the comparator diffs BY REFERENCE); the mutation below hands a fresh array. */ +const SHARED_CONTENT = [] as TMessage['content']; + +const makeFieldsMsg = (over: Partial = {}): TMessage => + ({ + messageId: 'm1', + text: 'hello', + error: false, + unfinished: false, + createdAt: '2026-07-01T00:00:00.000Z', + depth: 0, + isCreatedByUser: false, + content: SHARED_CONTENT, + model: 'gpt-4', + endpoint: 'openAI', + iconURL: '', + ...over, + }) as TMessage; + +/** + * One entry per field `areMessageFieldsEqual` compares, each differing from the + * `makeFieldsMsg` baseline. This list is the guard: dropping a field from the + * comparator makes its case here fail (a bailed row would show stale content), + * and adding a rendered field should mean adding it in both places. + */ +const FIELD_MUTATIONS: Array<[string, Partial]> = [ + ['messageId', { messageId: 'm2' }], + ['text', { text: 'changed' }], + ['error', { error: true }], + ['unfinished', { unfinished: true }], + ['createdAt', { createdAt: '2026-07-02T00:00:00.000Z' }], + ['depth', { depth: 3 }], + ['isCreatedByUser', { isCreatedByUser: true }], + ['children length', { children: [makeFieldsMsg(), makeFieldsMsg()] }], + ['content reference', { content: [] as TMessage['content'] }], + ['model', { model: 'gpt-5' }], + ['endpoint', { endpoint: 'anthropic' }], + ['iconURL', { iconURL: 'https://example.com/icon.png' }], + ['feedback rating', { feedback: { rating: 'thumbsDown' } as unknown as TMessage['feedback'] }], + ['files', { files: [{ file_id: 'f1' }] as TMessage['files'] }], + [ + 'attachments length', + { attachments: [{ file_id: 'a1' }] as unknown as TMessage['attachments'] }, + ], + ['manualSkills length', { manualSkills: ['skill'] as unknown as TMessage['manualSkills'] }], + [ + 'alwaysAppliedSkills length', + { alwaysAppliedSkills: ['skill'] as unknown as TMessage['alwaysAppliedSkills'] }, + ], + ['quotes length', { quotes: [{ text: 'q' }] as unknown as TMessage['quotes'] }], +]; + +describe('areMessageFieldsEqual', () => { + it('is true for the same reference', () => { + const message = makeFieldsMsg(); + expect(areMessageFieldsEqual(message, message)).toBe(true); + }); + + it('is true for distinct objects with identical compared fields', () => { + expect(areMessageFieldsEqual(makeFieldsMsg(), makeFieldsMsg())).toBe(true); + }); + + it('handles nullish operands', () => { + expect(areMessageFieldsEqual(makeFieldsMsg(), null)).toBe(false); + expect(areMessageFieldsEqual(null, makeFieldsMsg())).toBe(false); + expect(areMessageFieldsEqual(null, null)).toBe(true); + expect(areMessageFieldsEqual(undefined, undefined)).toBe(true); + }); + + it.each(FIELD_MUTATIONS)('re-renders when %s changes', (_label, mutation) => { + expect(areMessageFieldsEqual(makeFieldsMsg(), makeFieldsMsg(mutation))).toBe(false); + }); +}); + +const baseMessage = makeFieldsMsg(); + +const makeProps = (over: Partial = {}): TMessageProps => + ({ + currentEditId: null, + setCurrentEditId: noop, + siblingIdx: 0, + siblingCount: 1, + setSiblingIdx: noop, + isSearchView: false, + conversation: null, + message: baseMessage, + ...over, + }) as TMessageProps; + +const PROP_MUTATIONS: Array<[string, Partial]> = [ + ['currentEditId', { currentEditId: 'edit-1' }], + ['setCurrentEditId', { setCurrentEditId: () => {} }], + ['siblingIdx', { siblingIdx: 1 }], + ['siblingCount', { siblingCount: 2 }], + ['setSiblingIdx', { setSiblingIdx: () => {} }], + ['isSearchView', { isSearchView: true }], + ['conversation', { conversation: { conversationId: 'c1' } as unknown as TConversation }], +]; + +describe('areMessageRowPropsEqual', () => { + it('is true for distinct prop objects with identical values', () => { + expect(areMessageRowPropsEqual(makeProps(), makeProps())).toBe(true); + }); + + it.each(PROP_MUTATIONS)('re-renders when %s changes', (_label, mutation) => { + expect(areMessageRowPropsEqual(makeProps(), makeProps(mutation))).toBe(false); + }); + + it('re-renders when only a message field changes (delegates to areMessageFieldsEqual)', () => { + expect( + areMessageRowPropsEqual( + makeProps(), + makeProps({ message: makeFieldsMsg({ text: 'edited' }) }), + ), + ).toBe(false); + }); +}); diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts index d88edecf69..31d5621ebc 100644 --- a/client/src/utils/messages.ts +++ b/client/src/utils/messages.ts @@ -14,7 +14,7 @@ import type { TMessageContentParts, } from 'librechat-data-provider'; import type { QueryClient } from '@tanstack/react-query'; -import type { LocalizeFunction } from '~/common'; +import type { LocalizeFunction, TMessageProps } from '~/common'; export const TEXT_KEY_DIVIDER = '|||'; export const STREAM_START_FAILED_METADATA_KEY = 'streamStartFailed'; @@ -552,3 +552,75 @@ export const createDualMessageContent = ( // that will be replaced by real content with proper types from the server return [primaryContent, addedContent] as unknown as TMessageContentParts[]; }; + +export function areMessageFilesEqual(prevFiles?: TMessage['files'], nextFiles?: TMessage['files']) { + if (prevFiles === nextFiles) { + return true; + } + const prevLength = prevFiles?.length ?? 0; + const nextLength = nextFiles?.length ?? 0; + if (prevLength !== nextLength) { + return false; + } + if (prevLength === 0) { + return true; + } + return prevFiles?.every((file, index) => file === nextFiles?.[index]) ?? true; +} + +/** + * Field-level equality for `message` props: `buildTree` mints a new node object + * for EVERY message on each streaming update, so memo comparators must diff the + * fields that drive rendering instead of the object reference. + */ +export function areMessageFieldsEqual( + prevMsg?: TMessage | null, + nextMsg?: TMessage | null, +): boolean { + if (prevMsg === nextMsg) { + return true; + } + if (!prevMsg || !nextMsg) { + return false; + } + + return ( + prevMsg.messageId === nextMsg.messageId && + prevMsg.text === nextMsg.text && + prevMsg.error === nextMsg.error && + prevMsg.unfinished === nextMsg.unfinished && + prevMsg.createdAt === nextMsg.createdAt && + 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 && + areMessageFilesEqual(prevMsg.files, nextMsg.files) && + (prevMsg.attachments?.length ?? 0) === (nextMsg.attachments?.length ?? 0) && + (prevMsg.manualSkills?.length ?? 0) === (nextMsg.manualSkills?.length ?? 0) && + (prevMsg.alwaysAppliedSkills?.length ?? 0) === (nextMsg.alwaysAppliedSkills?.length ?? 0) && + (prevMsg.quotes?.length ?? 0) === (nextMsg.quotes?.length ?? 0) + ); +} + +/** + * Comparator for the memoized message-row wrappers (Message / MessageContent / + * MessageParts): identity-compare the scalar props, field-compare the message. + * The child recursion lives in MultiMessage, so a bailed row never severs the + * spine walk that delivers streaming updates to descendants. + */ +export function areMessageRowPropsEqual(prev: TMessageProps, next: TMessageProps): boolean { + return ( + prev.currentEditId === next.currentEditId && + prev.setCurrentEditId === next.setCurrentEditId && + prev.siblingIdx === next.siblingIdx && + prev.siblingCount === next.siblingCount && + prev.setSiblingIdx === next.setSiblingIdx && + prev.isSearchView === next.isSearchView && + prev.conversation === next.conversation && + areMessageFieldsEqual(prev.message, next.message) + ); +}