🧬 perf: Memoize Message Spine and Isolate Scroll-Button State (#14330)

* 🧬 perf: Memoize Message Spine and Isolate Scroll-Button State

*  test: Pin the message-row memo comparators against field drift

areMessageFieldsEqual and areMessageRowPropsEqual gate every message row's
re-render but had no direct tests. Add a completeness suite: a field-mutation
table asserts each compared field flips the comparator to false (a dropped
field fails its case), plus same-ref / equal-distinct-objects / nullish cases,
and the same shape for the row-props comparator including its delegation into
areMessageFieldsEqual.
This commit is contained in:
Danny Avila 2026-07-21 08:35:33 -04:00 committed by GitHub
parent 71fa24a6ea
commit ad46f66dc4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 364 additions and 214 deletions

View file

@ -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 (
<>
<MessageContainer handleScroll={handleScroll}>
<div className="m-auto justify-center p-4 py-2 md:gap-6">
<MessageRender
{...props}
isSubmitting={effectiveIsSubmitting}
chatContext={chatContext}
/>
</div>
</MessageContainer>
<MultiMessage
messageId={messageId}
conversation={conversation}
messagesTree={children ?? []}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
/>
</>
<MessageContainer handleScroll={handleScroll}>
<div className="m-auto justify-center p-4 py-2 md:gap-6">
<MessageRender {...props} isSubmitting={effectiveIsSubmitting} chatContext={chatContext} />
</div>
</MessageContainer>
);
}
export default React.memo(Message, areMessageRowPropsEqual);

View file

@ -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) {
</div>
</div>
</div>
<MultiMessage
messageId={messageId}
conversation={conversation}
messagesTree={children ?? []}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
/>
</>
);
}
export default React.memo(MessageParts, areMessageRowPropsEqual);

View file

@ -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<HTMLDivElement | null>;
messagesEndRef: React.RefObject<HTMLDivElement | null>;
scrollHandler: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onNearBottomChange: (isNearBottom: boolean) => void;
}) {
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
const [showScrollButton, setShowScrollButton] = useState(false);
const scrollToBottomRef = useRef<HTMLDivElement>(null);
const timeoutIdRef = useRef<NodeJS.Timeout>();
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 (
<CSSTransition
in={showScrollButton && scrollButtonPreference}
timeout={{
enter: 300,
exit: 250,
}}
classNames="scroll-animation"
unmountOnExit={true}
appear={true}
nodeRef={scrollToBottomRef}
>
<ScrollToBottom ref={scrollToBottomRef} scrollHandler={scrollHandler} />
</CSSTransition>
);
});
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<number | string | null>(-1);
const scrollToBottomRef = useRef<HTMLDivElement>(null);
const {
conversation,
contentRef,
scrollableRef,
messagesEndRef,
showScrollButton,
handleSmoothToRef,
debouncedHandleScroll,
handleNearBottomChange,
} = useMessageScrolling(_messagesTree);
const { conversationId } = conversation ?? {};
@ -80,19 +144,12 @@ function MessagesViewContent({
</div>
</div>
<CSSTransition
in={showScrollButton && scrollButtonPreference}
timeout={{
enter: 300,
exit: 250,
}}
classNames="scroll-animation"
unmountOnExit={true}
appear={true}
nodeRef={scrollToBottomRef}
>
<ScrollToBottom ref={scrollToBottomRef} scrollHandler={handleSmoothToRef} />
</CSSTransition>
<ScrollButton
scrollableRef={scrollableRef}
messagesEndRef={messagesEndRef}
scrollHandler={handleSmoothToRef}
onNearBottomChange={handleNearBottomChange}
/>
<MessageNav scrollableRef={scrollableRef} />
</div>

View file

@ -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 <MessageParts {...sharedProps} />;
row = <MessageParts {...sharedProps} />;
} else if (message.content) {
return <MessageContent {...sharedProps} />;
row = <MessageContent {...sharedProps} />;
} else {
row = <Message {...sharedProps} />;
}
return <Message {...sharedProps} />;
/**
* 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}
<MemoizedMultiMessage
messageId={message.messageId}
messagesTree={message.children ?? []}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
/>
</>
);
}
const MemoizedMultiMessage = memo(MultiMessage);
export default MemoizedMultiMessage;

View file

@ -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({

View file

@ -1,5 +1,5 @@
import type { TFile } from 'librechat-data-provider';
import { areMessageFilesEqual } from '../MessageRender';
import { areMessageFilesEqual } from '~/utils';
const file = (overrides: Partial<TFile> = {}): TFile =>
({

View file

@ -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({

View file

@ -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 (
<>
<MessageContainer handleScroll={handleScroll}>
<div className="m-auto justify-center p-4 py-2 md:gap-6">
<ContentRender
{...props}
isSubmitting={effectiveIsSubmitting}
chatContext={chatContext}
/>
</div>
</MessageContainer>
<MultiMessage
messageId={messageId}
conversation={conversation}
messagesTree={children ?? []}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
/>
</>
<MessageContainer handleScroll={handleScroll}>
<div className="m-auto justify-center p-4 py-2 md:gap-6">
<ContentRender {...props} isSubmitting={effectiveIsSubmitting} chatContext={chatContext} />
</div>
</MessageContainer>
);
}
export default React.memo(MessageContent, areMessageRowPropsEqual);

View file

@ -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<HTMLDivElement | null>(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<NodeJS.Timeout>();
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,
};
}

View file

@ -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<string, string> = {
@ -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> = {}): 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<TMessage>]> = [
['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> = {}): 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<TMessageProps>]> = [
['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);
});
});

View file

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