🎛️ perf: Narrow Composer Subscriptions to Streaming State (#14333)

* 🎛️ perf: Narrow Composer Subscriptions to Streaming State

*  test: Cover Composer Subscription Refactor's Behavioral Changes

Add regression tests for the previously-uncovered changed behavior in the
composer-subscriptions refactor:

- useLatestMessageMeta: exact projected field set, null on empty cache, and
  referential stability + no re-render across token-only cache writes.
- useGetLatestMessage: call-time tail read, stable callback identity with no
  re-render on cache writes, Recoil-snapshot sibling-branch resolution, null
  with no conversation.
- useSubmitMessage: reads the tail at call time and appends it to root when
  missing (and does not when present or absent) — the reconcile branch the
  prior test skipped via an early return.
- useHandleKeyUp: ArrowUp in an empty composer clicks the latest message's edit
  control, with the null / missing-control / non-empty-composer guards.
- useAskAnswerMode (new spec): liveAsk is projected through the
  findLiveAskUserQuestion select, null when empty/disabled.

* 🎨 style: Fix import order in useLatestMessage spec
This commit is contained in:
Danny Avila 2026-07-21 08:28:38 -04:00 committed by GitHub
parent 9e245aced4
commit 71fa24a6ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 490 additions and 34 deletions

View file

@ -26,6 +26,27 @@ function AskUserQuestionPopoverContent({
}: {
conversationId: string;
textAreaRef?: React.RefObject<HTMLTextAreaElement>;
}) {
const ask = useAskAnswerMode(conversationId);
if (!ask.popoverVisible || !ask.liveAsk) {
return null;
}
return <AskUserQuestionPopoverPanel ask={ask} textAreaRef={textAreaRef} />;
}
/**
* Split from the gate above so the per-keystroke `useWatch` subscription only
* exists while the popover is actually visible the invisible popover was
* re-rendering (to null) on every composer keystroke.
*/
function AskUserQuestionPopoverPanel({
ask,
textAreaRef,
}: {
ask: ReturnType<typeof useAskAnswerMode>;
textAreaRef?: React.RefObject<HTMLTextAreaElement>;
}) {
const localize = useLocalize();
const { control } = useChatFormContext();
@ -47,11 +68,10 @@ function AskUserQuestionPopoverContent({
skip,
dismiss,
collapse,
popoverVisible,
handlePopoverKeyDown,
} = useAskAnswerMode(conversationId);
} = ask;
if (!popoverVisible || !liveAsk) {
if (!liveAsk) {
return null;
}

View file

@ -3,7 +3,7 @@ import { QRCodeSVG } from 'qrcode.react';
import { Copy, CopyCheck } from 'lucide-react';
import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query';
import { OGDialogTemplate, Button, Spinner, OGDialog, Checkbox, Label } from '@librechat/client';
import { useLatestMessage } from '~/hooks/Messages/useLatestMessage';
import { useLatestMessageId } from '~/hooks/Messages/useLatestMessage';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import SharedLinkButton from './SharedLinkButton';
@ -38,7 +38,7 @@ export default function ShareButton({
setAnnouncement('');
}, 1000);
};
const latestMessage = useLatestMessage(0);
const latestMessageId = useLatestMessageId(0);
const { data: share, isLoading } = useGetSharedLinkQuery(conversationId);
const shareId = share?.shareId ?? '';
@ -61,7 +61,7 @@ export default function ShareButton({
<SharedLinkButton
share={share}
conversationId={conversationId}
targetMessageId={latestMessage?.messageId ?? undefined}
targetMessageId={latestMessageId ?? undefined}
showQR={showQR}
setShowQR={setShowQR}
setSharedLink={setSharedLink}

View file

@ -15,7 +15,9 @@ let mockMessages: TMessage[] | undefined;
jest.mock('~/data-provider', () => ({
useSteerMessageMutation: () => ({ mutate: mockMutate }),
useGetMessagesByConvoId: () => ({ data: mockMessages }),
useGetMessagesByConvoId: (_id: string, config?: { select?: (messages: unknown) => unknown }) => ({
data: config?.select ? config.select(mockMessages) : mockMessages,
}),
useMarkFilesUsageMutation: () => ({ mutate: mockMarkUsage }),
}));

View file

@ -134,13 +134,16 @@ export default function useSteering({
const duringRunActive = enabled && isSubmitting && !answerModeActive;
const queueKey = hasRealConvoId ? conversationId : Constants.NEW_CONVO;
const { data: messages } = useGetMessagesByConvoId(hasRealConvoId ? conversationId : '', {
enabled: hasRealConvoId,
});
const pausedOnApproval = useMemo(
() => (duringRunActive ? hasLiveToolApproval(messages) : false),
[duringRunActive, messages],
/** Boolean `select` so streaming deltas don't notify this subscription:
* structural sharing only re-renders the composer when the flag flips. */
const { data: liveToolApproval } = useGetMessagesByConvoId<boolean>(
hasRealConvoId ? conversationId : '',
{
enabled: hasRealConvoId,
select: hasLiveToolApproval,
},
);
const pausedOnApproval = duringRunActive ? (liveToolApproval ?? false) : false;
/** Whether a steer can reach the live run right now independent of the
* user's default action, so the per-send menu can always override to

View file

@ -0,0 +1,74 @@
jest.mock('~/data-provider', () => ({ useGetMessagesByConvoId: jest.fn() }));
jest.mock('~/components/Chat/Messages/Content/ApprovalContext', () => ({
useAskSubmitStatus: () => ({ getAskStatus: () => 'idle' }),
useResumeSubmit: () => ({ submitAskAnswer: jest.fn() }),
}));
jest.mock('~/Providers', () => ({ useOptionalChatFormContext: () => undefined }));
jest.mock('~/utils', () => ({ getAskAnswerDraftId: (id: string) => `draft-${id}` }));
jest.mock('recoil', () => ({
atom: (cfg: unknown) => cfg,
useRecoilState: () => [[], jest.fn()],
useRecoilValue: () => false,
}));
jest.mock('~/store', () => ({ __esModule: true, default: { saveDrafts: 'saveDrafts' } }));
import { renderHook } from '@testing-library/react';
import { useGetMessagesByConvoId } from '~/data-provider';
import { findLiveAskUserQuestion } from '~/utils/approval';
import useAskAnswerMode from './useAskAnswerMode';
const mockUseGetMessages = useGetMessagesByConvoId as jest.Mock;
const liveAsk = {
actionId: 'a1',
question: { question: 'Pick one', options: [], multiSelect: false },
} as unknown as ReturnType<typeof findLiveAskUserQuestion>;
describe('useAskAnswerMode', () => {
beforeEach(() => jest.clearAllMocks());
it('projects the live ask via the findLiveAskUserQuestion select over the conversation cache', () => {
mockUseGetMessages.mockReturnValue({ data: liveAsk });
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
expect(mockUseGetMessages).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({ enabled: true, select: findLiveAskUserQuestion }),
);
expect(result.current.liveAsk).toBe(liveAsk);
expect(result.current.active).toBe(true);
expect(result.current.popoverVisible).toBe(true);
});
it('is inactive when the select finds no live ask', () => {
mockUseGetMessages.mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
expect(result.current.liveAsk).toBeNull();
expect(result.current.active).toBe(false);
expect(result.current.popoverVisible).toBe(false);
});
it('disables the query and forces liveAsk null for a new (unsaved) conversation', () => {
mockUseGetMessages.mockReturnValue({ data: liveAsk });
const { result } = renderHook(() => useAskAnswerMode('new'));
expect(mockUseGetMessages).toHaveBeenCalledWith(
'',
expect.objectContaining({ enabled: false }),
);
expect(result.current.liveAsk).toBeNull();
expect(result.current.active).toBe(false);
});
it('forces liveAsk null when there is no conversation id', () => {
mockUseGetMessages.mockReturnValue({ data: liveAsk });
const { result } = renderHook(() => useAskAnswerMode(null));
expect(result.current.liveAsk).toBeNull();
});
});

View file

@ -61,13 +61,15 @@ const askAnswerCheckedAtom = atom<number[]>({
*/
export default function useAskAnswerMode(conversationId?: string | null) {
const enabled = conversationId != null && conversationId !== 'new';
const { data: messages } = useGetMessagesByConvoId(enabled ? conversationId : '', {
/** `select` projects straight to the live pause: streaming deltas leave the
* settled ask part untouched, so structural sharing keeps this null (or the
* same ask object) and the subscription stays quiet until a pause actually
* starts or resolves. */
const { data: liveAskData } = useGetMessagesByConvoId(enabled ? conversationId : '', {
enabled,
select: findLiveAskUserQuestion,
});
const liveAsk = useMemo(
() => (enabled ? findLiveAskUserQuestion(messages) : null),
[enabled, messages],
);
const liveAsk = enabled ? (liveAskData ?? null) : null;
const [dismissedIds, setDismissedIds] = useRecoilState(dismissedAskActionsAtom);
const [collapsedIds, setCollapsedIds] = useRecoilState(collapsedAskActionsAtom);
const [selected, setSelected] = useRecoilState(askAnswerSelectionAtom);

View file

@ -84,13 +84,16 @@ jest.mock('~/hooks/Agents/useAgentCapabilities', () =>
);
jest.mock('~/hooks/Messages/useLatestMessage', () => ({
useLatestMessage: jest.fn(() => null),
useGetLatestMessage: jest.fn(() => () => null),
}));
import React from 'react';
import { renderHook, act } from '@testing-library/react';
import { useGetLatestMessage } from '~/hooks/Messages/useLatestMessage';
import useHandleKeyUp from './useHandleKeyUp';
const mockUseGetLatestMessage = useGetLatestMessage as jest.Mock;
const makeTextAreaRef = (value = '', selectionStart?: number) => {
const ref = {
current: {
@ -517,4 +520,75 @@ describe('useHandleKeyUp', () => {
expect(setShowSkillsPopover).toHaveBeenCalledWith(false);
});
});
describe('ArrowUp edits the latest message (call-time tail read)', () => {
const parentMessageId = 'user-msg-1';
let editButton: HTMLButtonElement | null = null;
const mountEditButton = (id = `edit-${parentMessageId}`) => {
editButton = document.createElement('button');
editButton.id = id;
document.body.appendChild(editButton);
return jest.spyOn(editButton, 'click');
};
afterEach(() => {
editButton?.remove();
editButton = null;
mockUseGetLatestMessage.mockReturnValue(() => null);
});
it('clicks the edit control for the latest message parent on ArrowUp in an empty composer', () => {
mockUseGetLatestMessage.mockReturnValue(() => ({
messageId: 'assistant-1',
parentMessageId,
}));
const click = mountEditButton();
const { handleKeyUp } = renderUseHandleKeyUp(makeTextAreaRef('', 0));
const event = makeKeyEvent('ArrowUp');
act(() => handleKeyUp(event));
expect(event.preventDefault).toHaveBeenCalled();
expect(click).toHaveBeenCalled();
});
it('does nothing when there is no latest message', () => {
mockUseGetLatestMessage.mockReturnValue(() => null);
const click = mountEditButton();
const { handleKeyUp } = renderUseHandleKeyUp(makeTextAreaRef('', 0));
const event = makeKeyEvent('ArrowUp');
act(() => handleKeyUp(event));
expect(event.preventDefault).not.toHaveBeenCalled();
expect(click).not.toHaveBeenCalled();
});
it('does not preventDefault when the edit control is absent', () => {
mockUseGetLatestMessage.mockReturnValue(() => ({
messageId: 'assistant-1',
parentMessageId: 'missing',
}));
const { handleKeyUp } = renderUseHandleKeyUp(makeTextAreaRef('', 0));
const event = makeKeyEvent('ArrowUp');
act(() => handleKeyUp(event));
expect(event.preventDefault).not.toHaveBeenCalled();
});
it('ignores ArrowUp when the composer has text', () => {
const reader = jest.fn(() => ({ messageId: 'assistant-1', parentMessageId }));
mockUseGetLatestMessage.mockReturnValue(reader);
mountEditButton();
const { handleKeyUp } = renderUseHandleKeyUp(makeTextAreaRef('draft', 5));
const event = makeKeyEvent('ArrowUp');
act(() => handleKeyUp(event));
expect(reader).not.toHaveBeenCalled();
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
});

View file

@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions, isAssistantsEndpoint } from 'librechat-data-provider';
import { useGetLatestMessage } from '~/hooks/Messages/useLatestMessage';
import useAgentCapabilities from '~/hooks/Agents/useAgentCapabilities';
import useGetAgentsConfig from '~/hooks/Agents/useGetAgentsConfig';
import useHasAccess from '~/hooks/Roles/useHasAccess';
import { useLatestMessage } from '~/hooks/Messages/useLatestMessage';
import store from '~/store';
/** Event keys that shouldn't trigger a command */
@ -70,7 +70,7 @@ const useHandleKeyUp = ({
});
const { agentsConfig } = useGetAgentsConfig();
const { skillsEnabled } = useAgentCapabilities(agentsConfig?.capabilities);
const latestMessage = useLatestMessage(index);
const getLatestMessage = useGetLatestMessage(index);
const endpoint = useRecoilValue(store.effectiveEndpointByIndex(index));
const setShowMentionPopover = useSetRecoilState(store.showMentionPopoverFamily(index));
const setShowPlusPopover = useSetRecoilState(store.showPlusPopoverFamily(index));
@ -146,6 +146,7 @@ const useHandleKeyUp = ({
const handleUpArrow = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const latestMessage = getLatestMessage();
if (!latestMessage) {
return;
}
@ -157,7 +158,7 @@ const useHandleKeyUp = ({
event.preventDefault();
element.click();
},
[latestMessage],
[getLatestMessage],
);
/**

View file

@ -19,7 +19,7 @@ import {
checkIfScrollable,
} from '~/utils';
import { useAssistantsMapContext } from '~/Providers/AssistantsMapContext';
import { useLatestMessage } from '~/hooks/Messages/useLatestMessage';
import { useLatestMessageMeta } from '~/hooks/Messages/useLatestMessage';
import useFileUploadRouter from '~/hooks/Files/useFileUploadRouter';
import { useAgentsMapContext } from '~/Providers/AgentsMapContext';
import useGetSender from '~/hooks/Conversations/useGetSender';
@ -81,7 +81,7 @@ export default function useTextarea({
}, [customShortcuts]);
const { index, conversation, isSubmitting, filesLoading, setFilesLoading } = useChatContext();
const latestMessage = useLatestMessage(index);
const latestMessage = useLatestMessageMeta(index);
const [activePrompt, setActivePrompt] = useRecoilState(store.activePromptByIndex(index));
const { endpoint = '' } = conversation || {};

View file

@ -4,8 +4,13 @@ import { RecoilRoot, type MutableSnapshot } from 'recoil';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { QueryKeys, type TConversation, type TMessage } from 'librechat-data-provider';
import {
useLatestMessage,
useLatestMessageId,
useLatestMessageMeta,
useGetLatestMessage,
} from '~/hooks/Messages/useLatestMessage';
import { getBranchSiblingIndexesForTarget, getMessageBranchSiblingParentIds } from '~/utils';
import { useLatestMessage, useLatestMessageId } from '~/hooks/Messages/useLatestMessage';
import store from '~/store';
function createQueryClient() {
@ -238,6 +243,166 @@ describe('useLatestMessage', () => {
});
});
describe('useLatestMessageMeta', () => {
it('projects only messageId, error, and isCreatedByUser for the branch tail', () => {
const queryClient = createQueryClient();
const erroredAssistant = { ...assistantMessage, error: true } as TMessage;
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation.conversationId],
[userMessage, erroredAssistant],
);
const { result } = renderHook(() => useLatestMessageMeta(0), {
wrapper: createWrapper(queryClient, ({ set }) => {
set(store.conversationByIndex(0), conversation);
}),
});
expect(result.current).toEqual({
messageId: assistantMessage.messageId,
error: true,
isCreatedByUser: false,
});
});
it('returns null when there is no active conversation', () => {
const { result } = renderHook(() => useLatestMessageMeta(0), {
wrapper: createWrapper(),
});
expect(result.current).toBeNull();
});
it('stays referentially stable and does not re-render across token-only cache writes', () => {
const queryClient = createQueryClient();
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation.conversationId],
[userMessage, assistantMessage],
);
let renderCount = 0;
const { result } = renderHook(
() => {
renderCount += 1;
return useLatestMessageMeta(0);
},
{
wrapper: createWrapper(queryClient, ({ set }) => {
set(store.conversationByIndex(0), conversation);
}),
},
);
const firstMeta = result.current;
expect(firstMeta).toEqual({
messageId: assistantMessage.messageId,
error: undefined,
isCreatedByUser: false,
});
act(() => {
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation.conversationId],
[userMessage, { ...assistantMessage, text: 'Hi there, still streaming' }],
);
});
expect(result.current).toBe(firstMeta);
expect(renderCount).toBe(1);
});
});
describe('useGetLatestMessage', () => {
it('reads the current branch tail at call time', () => {
const queryClient = createQueryClient();
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation.conversationId],
[userMessage, assistantMessage],
);
const { result } = renderHook(() => useGetLatestMessage(0), {
wrapper: createWrapper(queryClient, ({ set }) => {
set(store.conversationByIndex(0), conversation);
}),
});
expect(result.current()).toEqual(
expect.objectContaining({ messageId: assistantMessage.messageId }),
);
});
it('keeps a stable callback and reads fresh data without re-rendering across token writes', () => {
const queryClient = createQueryClient();
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation.conversationId],
[userMessage, assistantMessage],
);
let renderCount = 0;
const { result } = renderHook(
() => {
renderCount += 1;
return useGetLatestMessage(0);
},
{
wrapper: createWrapper(queryClient, ({ set }) => {
set(store.conversationByIndex(0), conversation);
}),
},
);
const reader = result.current;
act(() => {
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation.conversationId],
[userMessage, { ...assistantMessage, text: 'Streamed tail' }],
);
});
/** Call-time reader has no cache subscription: no re-render, stable identity... */
expect(renderCount).toBe(1);
expect(result.current).toBe(reader);
/** ...yet invoking it returns the freshly-written tail. */
expect(result.current()).toEqual(
expect.objectContaining({ messageId: assistantMessage.messageId, text: 'Streamed tail' }),
);
});
it('resolves the active branch tail from the Recoil sibling snapshot', () => {
const queryClient = createQueryClient();
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation.conversationId],
[
userMessage,
olderAssistantMessage,
olderFollowUpUserMessage,
olderFollowUpAssistantMessage,
assistantMessage,
],
);
const { result } = renderHook(() => useGetLatestMessage(0), {
wrapper: createWrapper(queryClient, ({ set }) => {
set(store.conversationByIndex(0), conversation);
set(store.messagesSiblingIdxFamily(userMessage.messageId), 1);
}),
});
expect(result.current()).toEqual(
expect.objectContaining({ messageId: olderFollowUpAssistantMessage.messageId }),
);
});
it('returns null when there is no active conversation', () => {
const { result } = renderHook(() => useGetLatestMessage(0), {
wrapper: createWrapper(),
});
expect(result.current()).toBeNull();
});
});
describe('getMessageBranchSiblingParentIds', () => {
it('returns only parent keys that have branch choices', () => {
expect(

View file

@ -1,7 +1,7 @@
import { act, renderHook } from '@testing-library/react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useChatContext, useChatFormContext, useAddedChatContext } from '~/Providers';
import { useLatestMessage } from '~/hooks/Messages/useLatestMessage';
import { useGetLatestMessage } from '~/hooks/Messages/useLatestMessage';
import { useAuthContext } from '~/hooks/AuthContext';
import useSubmitMessage from '../useSubmitMessage';
@ -27,7 +27,7 @@ jest.mock('~/hooks/AuthContext', () => ({
}));
jest.mock('~/hooks/Messages/useLatestMessage', () => ({
useLatestMessage: jest.fn(),
useGetLatestMessage: jest.fn(),
}));
jest.mock('~/store', () => ({
@ -44,7 +44,7 @@ const mockUseChatContext = useChatContext as jest.Mock;
const mockUseChatFormContext = useChatFormContext as jest.Mock;
const mockUseAddedChatContext = useAddedChatContext as jest.Mock;
const mockUseAuthContext = useAuthContext as jest.Mock;
const mockUseLatestMessage = useLatestMessage as jest.Mock;
const mockUseGetLatestMessage = useGetLatestMessage as jest.Mock;
describe('useSubmitMessage', () => {
const ask = jest.fn();
@ -59,7 +59,7 @@ describe('useSubmitMessage', () => {
mockUseAuthContext.mockReturnValue({ user: { id: 'user-1' } });
mockUseAddedChatContext.mockReturnValue({ conversation: null });
mockUseChatFormContext.mockReturnValue({ reset, getValues: jest.fn(() => '') });
mockUseLatestMessage.mockReturnValue({ messageId: 'assistant-message' });
mockUseGetLatestMessage.mockReturnValue(() => ({ messageId: 'assistant-message' }));
getMessages.mockReturnValue([{ messageId: 'assistant-message' }]);
mockUseChatContext.mockReturnValue({
ask,
@ -82,4 +82,52 @@ describe('useSubmitMessage', () => {
expect(submitted).toBe(false);
expect(reset).not.toHaveBeenCalled();
});
it('reads the tail at call time and appends it to root when missing', () => {
const rootMessages = [{ messageId: 'root-user' }];
const latest = { messageId: 'assistant-tail', text: 'tail' };
const reader = jest.fn(() => latest);
mockUseGetLatestMessage.mockReturnValue(reader);
getMessages.mockReturnValue(rootMessages);
ask.mockReturnValue(true);
const { result } = renderHook(() => useSubmitMessage());
act(() => {
result.current.submitMessage({ text: 'hello' });
});
expect(reader).toHaveBeenCalled();
expect(setMessages).toHaveBeenCalledWith([...rootMessages, latest]);
expect(ask).toHaveBeenCalled();
expect(reset).toHaveBeenCalled();
});
it('does not append when the latest message is already in root', () => {
const latest = { messageId: 'assistant-tail' };
mockUseGetLatestMessage.mockReturnValue(() => latest);
getMessages.mockReturnValue([latest]);
ask.mockReturnValue(true);
const { result } = renderHook(() => useSubmitMessage());
act(() => {
result.current.submitMessage({ text: 'hello' });
});
expect(setMessages).not.toHaveBeenCalled();
expect(ask).toHaveBeenCalled();
});
it('does not append when there is no latest message', () => {
mockUseGetLatestMessage.mockReturnValue(() => null);
getMessages.mockReturnValue([{ messageId: 'root-user' }]);
ask.mockReturnValue(true);
const { result } = renderHook(() => useSubmitMessage());
act(() => {
result.current.submitMessage({ text: 'hello' });
});
expect(setMessages).not.toHaveBeenCalled();
expect(ask).toHaveBeenCalled();
});
});

View file

@ -1,8 +1,8 @@
import { useCallback } from 'react';
import { selectorFamily, useRecoilValue } from 'recoil';
import { useParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Constants, QueryKeys } from 'librechat-data-provider';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { selectorFamily, useRecoilValue, useRecoilCallback } from 'recoil';
import type { TMessage } from 'librechat-data-provider';
import { getMessageBranchSiblingParentIds, selectActiveBranchTail } from '~/utils';
import store from '~/store';
@ -127,3 +127,69 @@ export function useLatestMessageId(
return useMessagesCacheSelect(messagesQueryId, select);
}
export type TLatestMessageMeta = Pick<TMessage, 'messageId' | 'error' | 'isCreatedByUser'>;
/**
* Metadata-only projection of the branch tail. Streaming deltas leave these
* fields untouched, so React Query's structural sharing keeps the selected
* object referentially stable and consumers skip the per-delta re-renders a
* full `useLatestMessage` subscription would cause.
*/
export function useLatestMessageMeta(
index: string | number,
messagesQueryIdOverride?: string | null,
): TLatestMessageMeta | null {
const conversationId = useRecoilValue(store.conversationIdByIndex(index));
const messagesQueryId = useLatestMessagesQueryId(index, conversationId, messagesQueryIdOverride);
const siblingIndexes = useLatestMessageSiblingIndexes(messagesQueryId, conversationId);
const select = useCallback(
(messages: TMessage[]): TLatestMessageMeta | null => {
const tail = selectActiveBranchTail(
messages,
conversationId,
(parentId) => siblingIndexes[getParentLookupKey(parentId)] ?? 0,
);
if (!tail) {
return null;
}
return {
messageId: tail.messageId,
error: tail.error,
isCreatedByUser: tail.isCreatedByUser,
};
},
[conversationId, siblingIndexes],
);
return useMessagesCacheSelect(messagesQueryId, select);
}
/**
* Call-time reader for the branch tail: no cache subscription at all, so
* callbacks that only need the latest message when invoked keep a stable
* identity across streaming deltas.
*/
export function useGetLatestMessage(
index: string | number,
messagesQueryIdOverride?: string | null,
): () => TMessage | null {
const queryClient = useQueryClient();
const conversationId = useRecoilValue(store.conversationIdByIndex(index));
const messagesQueryId = useLatestMessagesQueryId(index, conversationId, messagesQueryIdOverride);
return useRecoilCallback(
({ snapshot }) =>
(): TMessage | null => {
if (!messagesQueryId) {
return null;
}
const messages =
queryClient.getQueryData<TMessage[]>([QueryKeys.messages, messagesQueryId]) ?? [];
return selectActiveBranchTail(messages, conversationId, (parentId) =>
snapshot.getLoadable(store.messagesSiblingIdxFamily(parentId)).getValue(),
);
},
[queryClient, conversationId, messagesQueryId],
);
}

View file

@ -3,7 +3,7 @@ import { useRecoilValue, useSetRecoilState } from 'recoil';
import { replaceSpecialVars } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import { useChatContext, useChatFormContext, useAddedChatContext } from '~/Providers';
import { useLatestMessage } from '~/hooks/Messages/useLatestMessage';
import { useGetLatestMessage } from '~/hooks/Messages/useLatestMessage';
import { useAuthContext } from '~/hooks/AuthContext';
import { mainTextareaId } from '~/common';
import store from '~/store';
@ -13,7 +13,7 @@ export default function useSubmitMessage() {
const methods = useChatFormContext();
const { conversation: addedConvo } = useAddedChatContext();
const { ask, index, getMessages, setMessages } = useChatContext();
const latestMessage = useLatestMessage(index);
const getLatestMessage = useGetLatestMessage(index);
const autoSendPrompts = useRecoilValue(store.autoSendPrompts);
const setActivePrompt = useSetRecoilState(store.activePromptByIndex(index));
@ -28,6 +28,7 @@ export default function useSubmitMessage() {
if (!data) {
return console.warn('No data provided to submitMessage');
}
const latestMessage = getLatestMessage();
const rootMessages = getMessages();
const isLatestInRootMessages = rootMessages?.some(
(message) => message.messageId === latestMessage?.messageId,
@ -54,7 +55,7 @@ export default function useSubmitMessage() {
}
methods.reset();
},
[ask, methods, addedConvo, setMessages, getMessages, latestMessage],
[ask, methods, addedConvo, setMessages, getMessages, getLatestMessage],
);
const submitPrompt = useCallback(