mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🧬 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:
parent
71fa24a6ea
commit
ad46f66dc4
11 changed files with 364 additions and 214 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue