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)
+ );
+}