🪡 fix: Prevent Hover Actions Flash While Streaming (#13285)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions

This commit is contained in:
Danny Avila 2026-05-24 08:39:31 -04:00 committed by GitHub
parent 385c7abcbf
commit 9c4049b1c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 303 additions and 29 deletions

View file

@ -0,0 +1,64 @@
import { renderHook } from '@testing-library/react';
import type { TConversation, TMessage } from 'librechat-data-provider';
import { useChatContext } from '~/Providers';
import useMemoizedChatContext from '../useMemoizedChatContext';
jest.mock('~/Providers', () => ({
useChatContext: jest.fn(),
}));
const mockUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>;
const conversation = {
conversationId: 'convo-id',
endpoint: 'openAI',
model: 'gpt-4',
} as TConversation;
const message = (overrides: Partial<TMessage> = {}) =>
({
messageId: 'assistant-response_',
parentMessageId: 'user-message',
conversationId: 'convo-id',
sender: 'Assistant',
text: '',
isCreatedByUser: false,
children: [],
...overrides,
}) as TMessage;
function mockChatContext(latestMessageId: string | undefined) {
mockUseChatContext.mockReturnValue({
ask: jest.fn(),
index: 0,
regenerate: jest.fn(),
conversation,
latestMessageId,
latestMessageDepth: -1,
handleContinue: jest.fn(),
isSubmitting: true,
} as unknown as ReturnType<typeof useChatContext>);
}
describe('useMemoizedChatContext', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('treats the latest message as submitting while streaming', () => {
mockChatContext('assistant-response_');
const { result } = renderHook(() => useMemoizedChatContext(message(), true));
expect(result.current.effectiveIsSubmitting).toBe(true);
});
it('requires latestMessageId alignment before marking a row as submitting', () => {
mockChatContext(undefined);
const { result } = renderHook(() => useMemoizedChatContext(message(), true));
expect(result.current.effectiveIsSubmitting).toBe(false);
expect(result.current.chatContext.isSubmitting).toBe(true);
});
});

View file

@ -0,0 +1,215 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Constants } from 'librechat-data-provider';
import type { TConversation, TMessage, EventSubmission } from 'librechat-data-provider';
import useEventHandlers, { type EventHandlerParams } from '../useEventHandlers';
import { renderHook, act } from '@testing-library/react';
const mockAnnouncePolite = jest.fn();
const mockApplyAgentTemplate = jest.fn();
const mockContentHandler = jest.fn();
const mockResetContentHandler = jest.fn();
const mockStepHandler = jest.fn();
const mockClearStepMaps = jest.fn();
const mockResetSubagentAtoms = jest.fn();
const mockSyncStepMessage = jest.fn();
const mockAttachmentHandler = jest.fn();
jest.mock('~/Providers', () => ({
useLiveAnnouncer: () => ({ announcePolite: mockAnnouncePolite }),
}));
jest.mock('~/hooks/AuthContext', () => ({
useAuthContext: () => ({ token: 'token' }),
}));
jest.mock('~/hooks/Agents', () => ({
useApplyAgentTemplate: () => mockApplyAgentTemplate,
}));
jest.mock('~/hooks/SSE/useContentHandler', () => () => ({
contentHandler: mockContentHandler,
resetContentHandler: mockResetContentHandler,
}));
jest.mock('~/hooks/SSE/useStepHandler', () => () => ({
stepHandler: mockStepHandler,
clearStepMaps: mockClearStepMaps,
resetSubagentAtoms: mockResetSubagentAtoms,
syncStepMessage: mockSyncStepMessage,
}));
jest.mock('~/hooks/SSE/useAttachmentHandler', () => () => mockAttachmentHandler);
const conversation = {
conversationId: 'convo-id',
endpoint: 'openAI',
model: 'gpt-4',
title: 'New Chat',
} as TConversation;
const userMessage = {
messageId: 'user-message',
parentMessageId: Constants.NO_PARENT,
conversationId: 'convo-id',
sender: 'User',
text: 'hello',
isCreatedByUser: true,
} as TMessage;
const initialResponse = {
messageId: 'user-message_',
parentMessageId: 'user-message',
conversationId: 'convo-id',
sender: 'Assistant',
text: '',
isCreatedByUser: false,
} as TMessage;
const submission = {
conversation,
messages: [] as TMessage[],
userMessage,
initialResponse,
} as unknown as EventSubmission;
function wrapper({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<MemoryRouter initialEntries={['/c/convo-id']}>{children}</MemoryRouter>
</RecoilRoot>
</QueryClientProvider>
);
}
const expectedCreatedAssistantResponse = () =>
expect.objectContaining({
messageId: 'user-message_',
parentMessageId: 'user-message',
isCreatedByUser: false,
});
type EventHandlerSpies = Omit<
EventHandlerParams,
| 'setMessages'
| 'setLatestMessage'
| 'getMessages'
| 'setCompleted'
| 'setIsSubmitting'
| 'setShowStopButton'
> & {
setMessages: jest.Mock;
setLatestMessage: jest.Mock;
getMessages: jest.Mock;
setCompleted: jest.Mock;
setIsSubmitting: jest.Mock;
setShowStopButton: jest.Mock;
};
function renderEventHandlersWithSpies(overrides: Partial<EventHandlerSpies> = {}) {
const defaultSpies: EventHandlerSpies = {
setMessages: jest.fn(),
setLatestMessage: jest.fn(),
getMessages: jest.fn(() => []),
setCompleted: jest.fn(),
setIsSubmitting: jest.fn(),
setShowStopButton: jest.fn(),
};
const spies: EventHandlerSpies = { ...defaultSpies, ...overrides };
const { result } = renderHook(() => useEventHandlers(spies), { wrapper });
return { result, ...spies };
}
describe('useEventHandlers latest-message transitions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('keeps latestMessage aligned after replacing optimistic messages in createdHandler', () => {
const { result, setMessages, setLatestMessage } = renderEventHandlersWithSpies();
act(() => {
result.current.createdHandler(
{ created: true } as unknown as Parameters<typeof result.current.createdHandler>[0],
submission,
);
});
expect(setMessages).toHaveBeenCalledWith([userMessage, expectedCreatedAssistantResponse()]);
expect(setLatestMessage).toHaveBeenCalledWith(expectedCreatedAssistantResponse());
expect(setLatestMessage.mock.invocationCallOrder[0]).toBeLessThan(
setMessages.mock.invocationCallOrder[0],
);
});
it('keeps latestMessage aligned when createdHandler starts a regeneration', () => {
const previousMessage = {
...userMessage,
messageId: 'previous-message',
} as TMessage;
const regenerateSubmission = {
...submission,
messages: [previousMessage],
isRegenerate: true,
} as unknown as EventSubmission;
const { result, setMessages, setLatestMessage } = renderEventHandlersWithSpies({
getMessages: jest.fn(() => [previousMessage]),
});
act(() => {
result.current.createdHandler(
{ created: true } as unknown as Parameters<typeof result.current.createdHandler>[0],
regenerateSubmission,
);
});
expect(setMessages).toHaveBeenCalledWith([previousMessage, expectedCreatedAssistantResponse()]);
expect(setLatestMessage).toHaveBeenCalledWith(expectedCreatedAssistantResponse());
expect(setLatestMessage.mock.invocationCallOrder[0]).toBeLessThan(
setMessages.mock.invocationCallOrder[0],
);
});
it('keeps latestMessage aligned after replacing optimistic messages in syncHandler', () => {
const requestMessage = { ...userMessage, messageId: 'server-user-message' } as TMessage;
const responseMessage = {
...initialResponse,
messageId: 'server-assistant-response',
parentMessageId: 'server-user-message',
} as TMessage;
const setShowStopButton = jest.fn();
const { result, setMessages, setLatestMessage } = renderEventHandlersWithSpies({
getMessages: jest.fn(() => [userMessage, initialResponse]),
setShowStopButton,
});
act(() => {
result.current.syncHandler(
{
sync: true,
conversationId: 'convo-id',
thread_id: 'thread-id',
requestMessage,
responseMessage,
},
submission,
);
});
expect(setMessages).toHaveBeenCalledWith([requestMessage, responseMessage]);
expect(setShowStopButton).toHaveBeenCalledWith(true);
expect(setLatestMessage).toHaveBeenCalledWith(responseMessage);
expect(setLatestMessage.mock.invocationCallOrder[0]).toBeLessThan(
setMessages.mock.invocationCallOrder[0],
);
});
});

View file

@ -156,7 +156,7 @@ const buildChatHelpers = () => ({
setConversation: jest.fn(),
setIsSubmitting: mockSetIsSubmitting,
newConversation: jest.fn(),
resetLatestMessage: jest.fn(),
setLatestMessage: jest.fn(),
});
const getLastSSE = (): MockSSEInstance => {

View file

@ -11,7 +11,7 @@ type ChatHelpers = Pick<
| 'setConversation'
| 'setIsSubmitting'
| 'newConversation'
| 'resetLatestMessage'
| 'setLatestMessage'
>;
/**

View file

@ -21,7 +21,7 @@ import type {
} from 'librechat-data-provider';
import type { TResData, TFinalResData, ConvoGenerator } from '~/common';
import type { InfiniteData } from '@tanstack/react-query';
import type { SetterOrUpdater, Resetter } from 'recoil';
import type { SetterOrUpdater } from 'recoil';
import type { ConversationCursorData } from '~/utils';
import {
logger,
@ -62,7 +62,7 @@ export type EventHandlerParams = {
setConversation?: SetterOrUpdater<TConversation | null>;
newConversation?: ConvoGenerator;
setShowStopButton: SetterOrUpdater<boolean>;
resetLatestMessage?: Resetter;
setLatestMessage: SetterOrUpdater<TMessage | null>;
};
const createErrorMessage = ({
@ -175,7 +175,7 @@ export default function useEventHandlers({
setIsSubmitting,
newConversation,
setShowStopButton,
resetLatestMessage,
setLatestMessage,
}: EventHandlerParams) {
const queryClient = useQueryClient();
const { announcePolite } = useLiveAnnouncer();
@ -323,14 +323,14 @@ export default function useEventHandlers({
const { initialResponse, messages: _messages, userMessage } = submission;
const messages = _messages.filter((msg) => msg.messageId !== userMessage.messageId);
setMessages([
...messages,
requestMessage,
{
...initialResponse,
...responseMessage,
},
]);
const nextResponseMessage = {
...initialResponse,
...responseMessage,
};
logger.log('latest_message', 'syncHandler: setting latest message');
setLatestMessage(nextResponseMessage);
setMessages([...messages, requestMessage, nextResponseMessage]);
announcePolite({
message: 'start',
@ -375,10 +375,6 @@ export default function useEventHandlers({
}
setShowStopButton(true);
if (resetLatestMessage) {
logger.log('latest_message', 'syncHandler: resetting latest message');
resetLatestMessage();
}
},
[
queryClient,
@ -387,7 +383,7 @@ export default function useEventHandlers({
announcePolite,
setConversation,
setShowStopButton,
resetLatestMessage,
setLatestMessage,
],
);
@ -410,7 +406,10 @@ export default function useEventHandlers({
...submission.initialResponse,
parentMessageId: userMessage.messageId,
messageId: userMessage.messageId + '_',
conversationId: userMessage.conversationId ?? submission.initialResponse.conversationId,
};
logger.log('latest_message', 'createdHandler: setting latest message');
setLatestMessage(initialResponse);
if (isRegenerate) {
setMessages([...messages, initialResponse]);
} else {
@ -469,10 +468,6 @@ export default function useEventHandlers({
});
}
if (resetLatestMessage) {
logger.log('latest_message', 'createdHandler: resetting latest message');
resetLatestMessage();
}
scrollToEnd(() => setAbortScroll(false));
},
[
@ -482,7 +477,7 @@ export default function useEventHandlers({
isAddedRequest,
announcePolite,
setConversation,
resetLatestMessage,
setLatestMessage,
applyAgentTemplate,
],
);

View file

@ -35,7 +35,7 @@ type ChatHelpers = Pick<
| 'setConversation'
| 'setIsSubmitting'
| 'newConversation'
| 'resetLatestMessage'
| 'setLatestMessage'
>;
const MAX_RETRIES = 5;
@ -102,7 +102,7 @@ export default function useResumableSSE(
setConversation,
setIsSubmitting,
newConversation,
resetLatestMessage,
setLatestMessage,
} = chatHelpers;
const {
@ -125,7 +125,7 @@ export default function useResumableSSE(
setIsSubmitting,
newConversation,
setShowStopButton,
resetLatestMessage,
setLatestMessage,
});
const { data: startupConfig } = useGetStartupConfig();

View file

@ -19,7 +19,7 @@ type ChatHelpers = Pick<
| 'setConversation'
| 'setIsSubmitting'
| 'newConversation'
| 'resetLatestMessage'
| 'setLatestMessage'
>;
export default function useSSE(
@ -41,7 +41,7 @@ export default function useSSE(
setConversation,
setIsSubmitting,
newConversation,
resetLatestMessage,
setLatestMessage,
} = chatHelpers;
const {
@ -64,7 +64,7 @@ export default function useSSE(
setIsSubmitting,
newConversation,
setShowStopButton,
resetLatestMessage,
setLatestMessage,
});
const { data: startupConfig } = useGetStartupConfig();