🧵 fix: Prevent Unnecessary Re-renders when Loading Chats (#5189)

* chore: typing

* chore: typing

* fix: enhance message scrolling logic to handle empty messages tree and ref checks

* fix: optimize message selection logic with useCallback for better performance

* chore: typing

* refactor: optimize icon rendering

* refactor: further optimize chat props

* fix: remove unnecessary console log in useQueryParams cleanup

* refactor: add queryClient to reset message data on new conversation initiation

* refactor: update data-testid attributes for consistency and improve code readability

* refactor: integrate queryClient to reset message data on new conversation initiation
This commit is contained in:
Danny Avila 2025-01-06 10:32:44 -05:00 committed by GitHub
parent 7987e04a2c
commit b01c744eb8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 184 additions and 88 deletions

View file

@ -1,8 +1,9 @@
import { memo } from 'react';
import { memo, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { useForm } from 'react-hook-form';
import { useParams } from 'react-router-dom';
import { useGetMessagesByConvoId } from 'librechat-data-provider/react-query';
import type { TMessage } from 'librechat-data-provider';
import type { ChatFormValues } from '~/common';
import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } from '~/Providers';
import { useChatHelpers, useAddedResponse, useSSE } from '~/hooks';
@ -24,10 +25,13 @@ function ChatView({ index = 0 }: { index?: number }) {
const fileMap = useFileMapContext();
const { data: messagesTree = null, isLoading } = useGetMessagesByConvoId(conversationId ?? '', {
select: (data) => {
const dataTree = buildTree({ messages: data, fileMap });
return dataTree?.length === 0 ? null : dataTree ?? null;
},
select: useCallback(
(data: TMessage[]) => {
const dataTree = buildTree({ messages: data, fileMap });
return dataTree?.length === 0 ? null : dataTree ?? null;
},
[fileMap],
),
enabled: !!fileMap,
});

View file

@ -1,9 +1,13 @@
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, Constants } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import { useMediaQuery, useLocalize } from '~/hooks';
import { NewChatIcon } from '~/components/svg';
import { useChatContext } from '~/Providers';
import { useMediaQuery, useLocalize } from '~/hooks';
export default function HeaderNewChat() {
const { newConversation } = useChatContext();
const queryClient = useQueryClient();
const { conversation, newConversation } = useChatContext();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const localize = useLocalize();
if (isSmallScreen) {
@ -12,10 +16,16 @@ export default function HeaderNewChat() {
return (
<button
data-testid="wide-header-new-chat-button"
aria-label={localize("com_ui_new_chat")}
aria-label={localize('com_ui_new_chat')}
type="button"
className="btn btn-neutral btn-small border-token-border-medium relative ml-2 flex hidden h-9 w-9 items-center justify-center whitespace-nowrap rounded-lg rounded-lg border focus:border-black-500 dark:focus:border-white-500 md:flex"
onClick={() => newConversation()}
className="btn btn-neutral btn-small border-token-border-medium focus:border-black-500 dark:focus:border-white-500 relative ml-2 flex h-9 w-9 items-center justify-center whitespace-nowrap rounded-lg border md:flex"
onClick={() => {
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
newConversation();
}}
>
<div className="flex w-full items-center justify-center gap-2">
<NewChatIcon />

View file

@ -1,26 +1,23 @@
import React, { useMemo, memo } from 'react';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { TMessage, TPreset, Assistant, Agent } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import type { Assistant, Agent, TMessage } from 'librechat-data-provider';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { getEndpointField, getIconEndpoint } from '~/utils';
import Icon from '~/components/Endpoints/Icon';
const MessageIcon = memo(
(
props: Pick<TMessageProps, 'message' | 'conversation'> & {
assistant?: Assistant;
agent?: Agent;
},
) => {
(props: {
iconData?: TMessage & { modelLabel?: string };
assistant?: Assistant;
agent?: Agent;
}) => {
const { data: endpointsConfig } = useGetEndpointsQuery();
const { message, conversation, assistant, agent } = props;
const { iconData, assistant, agent } = props;
const assistantName = useMemo(() => assistant?.name ?? '', [assistant]);
const assistantAvatar = useMemo(() => assistant?.metadata?.avatar ?? '', [assistant]);
const agentName = useMemo(() => props.agent?.name ?? '', [props.agent]);
const agentAvatar = useMemo(() => props.agent?.avatar?.filepath ?? '', [props.agent]);
const isCreatedByUser = useMemo(() => message?.isCreatedByUser ?? false, [message]);
let avatarURL = '';
@ -30,21 +27,10 @@ const MessageIcon = memo(
avatarURL = agentAvatar;
}
const messageSettings = useMemo(
() => ({
...(conversation ?? {}),
...({
...(message ?? {}),
iconURL: message?.iconURL ?? '',
} as TMessage),
}),
[conversation, message],
);
const iconURL = messageSettings.iconURL;
const iconURL = iconData?.iconURL;
const endpoint = useMemo(
() => getIconEndpoint({ endpointsConfig, iconURL, endpoint: messageSettings.endpoint }),
[endpointsConfig, iconURL, messageSettings.endpoint],
() => getIconEndpoint({ endpointsConfig, iconURL, endpoint: iconData?.endpoint }),
[endpointsConfig, iconURL, iconData?.endpoint],
);
const endpointIconURL = useMemo(
@ -52,10 +38,11 @@ const MessageIcon = memo(
[endpointsConfig, endpoint],
);
if (isCreatedByUser !== true && iconURL != null && iconURL.includes('http')) {
if (iconData?.isCreatedByUser !== true && iconURL != null && iconURL.includes('http')) {
return (
<ConvoIconURL
preset={messageSettings as typeof messageSettings & TPreset}
iconURL={iconURL}
modelLabel={iconData?.modelLabel}
context="message"
assistantAvatar={assistantAvatar}
agentAvatar={agentAvatar}
@ -68,10 +55,10 @@ const MessageIcon = memo(
return (
<Icon
isCreatedByUser={isCreatedByUser}
isCreatedByUser={iconData?.isCreatedByUser ?? false}
endpoint={endpoint}
iconURL={avatarURL || endpointIconURL}
model={message?.model ?? conversation?.model}
model={iconData?.model}
assistantName={assistantName}
agentName={agentName}
size={28.8}

View file

@ -1,7 +1,8 @@
import React, { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import type { TMessageContentParts } from 'librechat-data-provider';
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import Icon from '~/components/Chat/Messages/MessageIcon';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { useMessageHelpers, useLocalize } from '~/hooks';
import ContentParts from './Content/ContentParts';
import SiblingSwitch from './SiblingSwitch';
@ -35,6 +36,26 @@ export default function Message(props: TMessageProps) {
const fontSize = useRecoilValue(store.fontSize);
const { children, messageId = null, isCreatedByUser } = message ?? {};
const iconData = useMemo(
() =>
({
endpoint: conversation?.endpoint,
model: conversation?.model ?? message?.model,
iconURL: conversation?.iconURL ?? message?.iconURL ?? '',
modelLabel: conversation?.chatGptLabel ?? conversation?.modelLabel,
isCreatedByUser: message?.isCreatedByUser,
} as TMessage & { modelLabel?: string }),
[
conversation?.chatGptLabel,
conversation?.modelLabel,
conversation?.endpoint,
conversation?.iconURL,
conversation?.model,
message?.model,
message?.iconURL,
message?.isCreatedByUser,
],
);
if (!message) {
return null;
}
@ -62,12 +83,7 @@ export default function Message(props: TMessageProps) {
<div>
<div className="pt-0.5">
<div className="shadow-stroke flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<Icon
message={message}
conversation={conversation}
assistant={assistant}
agent={agent}
/>
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
</div>
</div>
</div>

View file

@ -56,7 +56,7 @@ export default function MessagesView({
</div>
) : (
<>
{Header && Header}
{Header != null && Header}
<div ref={screenshotTargetRef}>
<MultiMessage
key={conversationId} // avoid internal state mixture

View file

@ -1,5 +1,7 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { useAuthContext, useLocalize } from '~/hooks';
import type { TMessage } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
import Icon from '~/components/Chat/Messages/MessageIcon';
@ -15,6 +17,17 @@ export default function Message({ message }: Pick<TMessageProps, 'message'>) {
const { user } = useAuthContext();
const localize = useLocalize();
const iconData = useMemo(
() =>
({
endpoint: message?.endpoint,
model: message?.model,
iconURL: message?.iconURL ?? '',
isCreatedByUser: message?.isCreatedByUser,
} as TMessage & { modelLabel?: string }),
[message?.model, message?.iconURL, message?.endpoint, message?.isCreatedByUser],
);
if (!message) {
return null;
}
@ -27,7 +40,7 @@ export default function Message({ message }: Pick<TMessageProps, 'message'>) {
? (user?.name ?? '') || (user?.username ?? '')
: localize('com_user_message');
} else {
messageLabel = message.sender || '';
messageLabel = message.sender ?? '';
}
return (
@ -39,7 +52,7 @@ export default function Message({ message }: Pick<TMessageProps, 'message'>) {
<div>
<div className="pt-0.5">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<Icon message={message} />
<Icon iconData={iconData} />
</div>
</div>
</div>

View file

@ -6,7 +6,7 @@ import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import Icon from '~/components/Chat/Messages/MessageIcon';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { Plugin } from '~/components/Messages/Content';
import SubRow from '~/components/Chat/Messages/SubRow';
import { MessageContext } from '~/Providers';
@ -66,6 +66,27 @@ const MessageRender = memo(
[hasNoChildren, msg?.depth, latestMessage?.depth],
);
const iconData = useMemo(
() =>
({
endpoint: conversation?.endpoint,
model: conversation?.model ?? msg?.model,
iconURL: conversation?.iconURL ?? msg?.iconURL ?? '',
modelLabel: conversation?.chatGptLabel ?? conversation?.modelLabel,
isCreatedByUser: msg?.isCreatedByUser,
} as TMessage & { modelLabel?: string }),
[
conversation?.chatGptLabel,
conversation?.modelLabel,
conversation?.endpoint,
conversation?.iconURL,
conversation?.model,
msg?.model,
msg?.iconURL,
msg?.isCreatedByUser,
],
);
if (!msg) {
return null;
}
@ -125,7 +146,7 @@ const MessageRender = memo(
<div>
<div className="pt-0.5">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<Icon message={msg} conversation={conversation} assistant={assistant} />
<MessageIcon iconData={iconData} assistant={assistant} />
</div>
</div>
</div>

View file

@ -52,7 +52,8 @@ export default function ConvoIcon({
<>
{iconURL && iconURL.includes('http') ? (
<ConvoIconURL
preset={conversation}
iconURL={iconURL}
modelLabel={conversation?.chatGptLabel ?? conversation?.modelLabel ?? ''}
endpointIconURL={endpointIconURL}
assistantAvatar={avatar}
assistantName={name}

View file

@ -1,10 +1,10 @@
import React, { memo } from 'react';
import type { TPreset } from 'librechat-data-provider';
import type { IconMapProps } from '~/common';
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
interface ConvoIconURLProps {
preset: TPreset | null;
iconURL?: string;
modelLabel?: string;
endpointIconURL?: string;
assistantName?: string;
agentName?: string;
@ -29,7 +29,8 @@ const styleImageMap = {
};
const ConvoIconURL: React.FC<ConvoIconURLProps> = ({
preset,
iconURL = '',
modelLabel = '',
endpointIconURL,
assistantAvatar,
assistantName,
@ -37,7 +38,6 @@ const ConvoIconURL: React.FC<ConvoIconURLProps> = ({
agentName,
context,
}) => {
const { iconURL = '' } = preset ?? {};
let Icon: (
props: IconMapProps & {
context?: string;
@ -57,7 +57,7 @@ const ConvoIconURL: React.FC<ConvoIconURLProps> = ({
>
<img
src={iconURL}
alt={preset?.chatGptLabel ?? preset?.modelLabel ?? ''}
alt={modelLabel}
style={styleImageMap[context ?? 'default'] ?? styleImageMap.default}
className="object-cover"
/>

View file

@ -42,10 +42,8 @@ export default function EndpointIcon({
if (iconURL && (iconURL.includes('http') || iconURL.startsWith('/images/'))) {
return (
<ConvoIconURL
preset={{
...(conversation as TPreset),
iconURL,
}}
iconURL={iconURL}
modelLabel={conversation?.chatGptLabel ?? conversation?.modelLabel ?? ''}
context={context}
endpointIconURL={endpointIconURL}
assistantAvatar={assistantAvatar}

View file

@ -6,7 +6,7 @@ import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import Icon from '~/components/Chat/Messages/MessageIcon';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import SubRow from '~/components/Chat/Messages/SubRow';
import { useMessageActions } from '~/hooks';
import { cn, logger } from '~/utils';
@ -65,6 +65,27 @@ const ContentRender = memo(
[msg?.children, msg?.depth, latestMessage?.depth],
);
const iconData = useMemo(
() =>
({
endpoint: conversation?.endpoint,
model: conversation?.model ?? msg?.model,
iconURL: conversation?.iconURL ?? msg?.iconURL ?? '',
modelLabel: conversation?.chatGptLabel ?? conversation?.modelLabel,
isCreatedByUser: msg?.isCreatedByUser,
} as TMessage & { modelLabel?: string }),
[
conversation?.chatGptLabel,
conversation?.modelLabel,
conversation?.endpoint,
conversation?.iconURL,
conversation?.model,
msg?.model,
msg?.iconURL,
msg?.isCreatedByUser,
],
);
if (!msg) {
return null;
}
@ -109,12 +130,7 @@ const ContentRender = memo(
<div>
<div className="pt-0.5">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<Icon
message={msg}
conversation={conversation}
assistant={assistant}
agent={agent}
/>
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
</div>
</div>
</div>

View file

@ -1,5 +1,8 @@
import React from 'react';
import { useRecoilValue } from 'recoil';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, Constants } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import type { Dispatch, SetStateAction } from 'react';
import { useLocalize, useNewConvo } from '~/hooks';
import store from '~/store';
@ -10,6 +13,7 @@ export default function MobileNav({
setNavVisible: Dispatch<SetStateAction<boolean>>;
}) {
const localize = useLocalize();
const queryClient = useQueryClient();
const { newConversation } = useNewConvo(0);
const conversation = useRecoilValue(store.conversationByIndex(0));
const { title = 'New Chat' } = conversation || {};
@ -46,13 +50,19 @@ export default function MobileNav({
</svg>
</button>
<h1 className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-center text-sm font-normal">
{title || localize('com_ui_new_chat')}
{title ?? localize('com_ui_new_chat')}
</h1>
<button
type="button"
aria-label={localize('com_ui_new_chat')}
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover"
onClick={() => newConversation()}
onClick={() => {
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
newConversation();
}}
>
<svg
width="24"

View file

@ -1,8 +1,10 @@
import { Search } from 'lucide-react';
import { useRecoilValue } from 'recoil';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, Constants } from 'librechat-data-provider';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { TConversation } from 'librechat-data-provider';
import type { TConversation, TMessage } from 'librechat-data-provider';
import { getEndpointField, getIconEndpoint, getIconKey } from '~/utils';
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
@ -35,7 +37,12 @@ const NewChatButtonIcon = ({ conversation }: { conversation: TConversation | nul
return (
<div className="h-7 w-7 flex-shrink-0">
{iconURL && iconURL.includes('http') ? (
<ConvoIconURL preset={conversation} endpointIconURL={iconURL} context="nav" />
<ConvoIconURL
iconURL={iconURL}
modelLabel={conversation?.chatGptLabel ?? conversation?.modelLabel ?? ''}
endpointIconURL={iconURL}
context="nav"
/>
) : (
<div className="shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black">
{endpoint && Icon != null && (
@ -65,6 +72,7 @@ export default function NewChat({
subHeaders?: React.ReactNode;
isSmallScreen: boolean;
}) {
const queryClient = useQueryClient();
/** Note: this component needs an explicit index passed if using more than one */
const { newConversation: newConvo } = useNewConvo(index);
const navigate = useNavigate();
@ -75,6 +83,10 @@ export default function NewChat({
const clickHandler = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.button === 0 && !(event.ctrlKey || event.metaKey)) {
event.preventDefault();
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
newConvo();
navigate('/c/new');
toggleNav();
@ -87,7 +99,7 @@ export default function NewChat({
<a
href="/"
tabIndex={0}
data-testid="nav-new-chat"
data-testid="nav-new-chat-button"
onClick={clickHandler}
className={cn(
'group flex h-10 items-center gap-2 rounded-lg px-2 font-medium transition-colors duration-200 hover:bg-surface-hover',

View file

@ -42,7 +42,7 @@ export default function Message(props: TMessageProps) {
if (isCreatedByUser) {
messageLabel = 'anonymous';
} else {
messageLabel = message.sender || '';
messageLabel = message.sender ?? '';
}
return (

View file

@ -30,14 +30,15 @@ export default function MessageIcon(
[conversation, message],
);
const iconURL = messageSettings?.iconURL;
let endpoint = messageSettings?.endpoint;
const iconURL = messageSettings.iconURL ?? '';
let endpoint = messageSettings.endpoint;
endpoint = getIconEndpoint({ endpointsConfig: undefined, iconURL, endpoint });
if (!message?.isCreatedByUser && iconURL && iconURL.includes('http')) {
if (message?.isCreatedByUser !== true && iconURL && iconURL.includes('http')) {
return (
<ConvoIconURL
preset={messageSettings as typeof messageSettings & TPreset}
iconURL={iconURL}
modelLabel={messageSettings.chatGptLabel ?? messageSettings.modelLabel ?? ''}
context="message"
assistantAvatar={assistantAvatar}
assistantName={assistantName}
@ -47,7 +48,7 @@ export default function MessageIcon(
);
}
if (message?.isCreatedByUser) {
if (message?.isCreatedByUser === true) {
return (
<div
style={{
@ -67,7 +68,7 @@ export default function MessageIcon(
<MessageEndpointIcon
{...messageSettings}
endpoint={endpoint}
iconURL={!assistant ? undefined : assistantAvatar}
iconURL={assistant == null ? undefined : assistantAvatar}
model={message?.model ?? conversation?.model}
assistantName={assistantName}
agentName={agentName}

View file

@ -116,7 +116,7 @@ export default function useChatHelpers(index = 0, paramId?: string) {
const handleRegenerate = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
const parentMessageId = latestMessage?.parentMessageId;
const parentMessageId = latestMessage?.parentMessageId ?? '';
if (!parentMessageId) {
console.error('Failed to regenerate the message: parentMessageId not found.');
return;

View file

@ -207,7 +207,6 @@ export default function useQueryParams({
return () => {
clearInterval(intervalId);
console.log('Cleanup: `useQueryParams` interval cleared');
};
}, [searchParams, methods, textAreaRef, newQueryConvo, newConversation]);
}

View file

@ -72,22 +72,30 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
});
useEffect(() => {
if (!messagesTree) {
if (!messagesTree || messagesTree.length === 0) {
return;
}
if (isSubmitting && scrollToBottom && !abortScroll) {
if (!messagesEndRef.current || !scrollableRef.current) {
return;
}
if (isSubmitting && scrollToBottom && abortScroll !== true) {
scrollToBottom();
}
return () => {
if (abortScroll) {
if (abortScroll === true) {
scrollToBottom && scrollToBottom.cancel();
}
};
}, [isSubmitting, messagesTree, scrollToBottom, abortScroll]);
useEffect(() => {
if (!messagesEndRef.current || !scrollableRef.current) {
return;
}
if (scrollToBottom && autoScroll && conversationId !== Constants.NEW_CONVO) {
scrollToBottom();
}

View file

@ -141,7 +141,7 @@ const showStopButtonByIndex = atomFamily<boolean, string | number>({
default: false,
});
const abortScrollFamily = atomFamily({
const abortScrollFamily = atomFamily<boolean, string | number>({
key: 'abortScrollByIndex',
default: false,
effects: [

View file

@ -78,7 +78,7 @@ test.describe('Messaging suite', () => {
expect(currentUrl).toBe(initialUrl);
//cleanup the conversation
await page.getByTestId('new-chat-button').click();
await page.getByTestId('nav-new-chat-button').click();
expect(page.url()).toBe(initialUrl);
// Click on the first conversation
@ -158,7 +158,7 @@ test.describe('Messaging suite', () => {
const currentUrl = page.url();
const conversationId = currentUrl.split(basePath).pop() ?? '';
expect(isUUID(conversationId)).toBeTruthy();
await page.getByTestId('new-chat-button').click();
await page.getByTestId('nav-new-chat-button').click();
expect(page.url()).toBe(initialUrl);
});
});