diff --git a/client/src/components/Chat/Messages/Message.tsx b/client/src/components/Chat/Messages/Message.tsx index 53aef812fc..fc2a79fca0 100644 --- a/client/src/components/Chat/Messages/Message.tsx +++ b/client/src/components/Chat/Messages/Message.tsx @@ -47,7 +47,6 @@ export default function Message(props: TMessageProps) { { - logger.log('icon_data', iconData, assistant, agent); + const renderCountRef = useRef(0); + renderCountRef.current += 1; + + useEffect(() => { + logger.log( + 'icon_lifecycle', + 'MOUNT', + iconData?.modelLabel, + `render #${renderCountRef.current}`, + ); + return () => { + logger.log('icon_lifecycle', 'UNMOUNT', iconData?.modelLabel); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + logger.log( + 'icon_data', + `render #${renderCountRef.current}`, + iconData?.isCreatedByUser ? 'user' : iconData?.modelLabel, + iconData, + ); const { data: endpointsConfig } = useGetEndpointsQuery(); const agentName = agent?.name ?? ''; diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index 3d13fa6ae0..2d0e3d512b 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -179,7 +179,6 @@ export default function Message(props: TMessageProps) {
- ); + return ; } else if (message.content) { - return ( - - ); + return ; } - return ( - - ); + return ; } diff --git a/client/src/components/Chat/Messages/__tests__/MessageIcon.render.test.tsx b/client/src/components/Chat/Messages/__tests__/MessageIcon.render.test.tsx new file mode 100644 index 0000000000..1c7b3c1aec --- /dev/null +++ b/client/src/components/Chat/Messages/__tests__/MessageIcon.render.test.tsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { EModelEndpoint } from 'librechat-data-provider'; +import type { Agent } from 'librechat-data-provider'; +import type { TMessageIcon } from '~/common'; + +jest.mock('librechat-data-provider', () => ({ + ...jest.requireActual('librechat-data-provider'), + getEndpointField: jest.fn(() => ''), +})); +jest.mock('~/data-provider', () => ({ + useGetEndpointsQuery: jest.fn(() => ({ data: {} })), +})); + +// logger is a plain object with a real function — not a jest.fn() — +// so restoreMocks/clearMocks won't touch it. We spy on it per-test instead. +const logCalls: unknown[][] = []; +jest.mock('~/utils', () => ({ + getIconEndpoint: jest.fn(() => 'agents'), + logger: { + log: (...args: unknown[]) => { + logCalls.push(args); + }, + }, +})); +jest.mock('~/components/Endpoints/ConvoIconURL', () => { + const ConvoIconURL = (props: Record) => ( +
+ ); + ConvoIconURL.displayName = 'ConvoIconURL'; + return { __esModule: true, default: ConvoIconURL }; +}); +jest.mock('~/components/Endpoints/Icon', () => { + const Icon = (props: Record) => ( +
+ ); + Icon.displayName = 'Icon'; + return { __esModule: true, default: Icon }; +}); + +import MessageIcon from '../MessageIcon'; + +const makeAgent = (overrides?: Partial): Agent => + ({ + id: 'agent_123', + name: 'GitHub Agent', + avatar: { filepath: '/images/agent-avatar.png' }, + ...overrides, + }) as Agent; + +const baseIconData: TMessageIcon = { + endpoint: EModelEndpoint.agents, + model: 'agent_123', + iconURL: undefined, + modelLabel: 'GitHub Agent', + isCreatedByUser: false, +}; + +describe('MessageIcon render cycles', () => { + beforeEach(() => { + logCalls.length = 0; + }); + + it('renders once on initial mount', () => { + render(); + const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data'); + expect(iconDataCalls).toHaveLength(1); + }); + + it('does not re-render when parent re-renders with same field values but new object references', () => { + const agent = makeAgent(); + const { rerender } = render(); + logCalls.length = 0; + + // Simulate parent re-render: new iconData object (same field values), new agent object (same data) + rerender(); + + const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data'); + expect(iconDataCalls).toHaveLength(0); + }); + + it('does not re-render when agent object reference changes but name and avatar are the same', () => { + const agent1 = makeAgent(); + const { rerender } = render(); + logCalls.length = 0; + + // New agent object with different id but same display fields + const agent2 = makeAgent({ id: 'agent_456' }); + rerender(); + + const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data'); + expect(iconDataCalls).toHaveLength(0); + }); + + it('re-renders when agent avatar filepath changes', () => { + const agent1 = makeAgent(); + const { rerender } = render(); + logCalls.length = 0; + + const agent2 = makeAgent({ avatar: { filepath: '/images/new-avatar.png' } }); + rerender(); + + const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data'); + expect(iconDataCalls).toHaveLength(1); + }); + + it('re-renders when agent goes from undefined to defined (name changes from undefined to string)', () => { + const { rerender } = render(); + logCalls.length = 0; + + rerender(); + + const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data'); + expect(iconDataCalls).toHaveLength(1); + }); + + describe('simulates message lifecycle', () => { + it('renders exactly twice during new message + streaming start: initial render + modelLabel update', () => { + // Phase 1: Initial response message created by useChatFunctions + // model is set to agent_id, iconURL is undefined, modelLabel is '' or sender + const initialIconData: TMessageIcon = { + endpoint: EModelEndpoint.agents, + model: 'agent_123', + iconURL: undefined, + modelLabel: '', // Not yet resolved + isCreatedByUser: false, + }; + const agent = makeAgent(); + + const { rerender } = render(); + + // Phase 2: First streaming chunk arrives, messageLabel resolves to agent name + // This is a legitimate re-render — modelLabel changed from '' to 'GitHub Agent' + const streamingIconData: TMessageIcon = { + ...initialIconData, + modelLabel: 'GitHub Agent', + }; + + rerender(); + + const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data'); + // Exactly 2: initial mount + modelLabel change + expect(iconDataCalls).toHaveLength(2); + }); + + it('does NOT re-render on subsequent streaming chunks (content changes, isSubmitting stays true)', () => { + const iconData: TMessageIcon = { + endpoint: EModelEndpoint.agents, + model: 'agent_123', + iconURL: undefined, + modelLabel: 'GitHub Agent', + isCreatedByUser: false, + }; + const agent = makeAgent(); + + const { rerender } = render(); + logCalls.length = 0; + + // Simulate multiple parent re-renders from streaming chunks + // Parent (ContentRender) re-renders because chatContext changed, + // but MessageIcon props are identical field-by-field + for (let i = 0; i < 5; i++) { + rerender(); + } + + const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data'); + expect(iconDataCalls).toHaveLength(0); + }); + + it('does NOT re-render when agentsMap context updates with same agent data', () => { + const iconData: TMessageIcon = { + endpoint: EModelEndpoint.agents, + model: 'agent_123', + iconURL: undefined, + modelLabel: 'GitHub Agent', + isCreatedByUser: false, + }; + + // First render with agent from original agentsMap + const agent1 = makeAgent(); + const { rerender } = render(); + logCalls.length = 0; + + // agentsMap refetched → new agent object, same display data + const agent2 = makeAgent(); + expect(agent1).not.toBe(agent2); // different reference + rerender(); + + const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data'); + expect(iconDataCalls).toHaveLength(0); + }); + }); +}); diff --git a/client/src/components/Messages/MessageContent.tsx b/client/src/components/Messages/MessageContent.tsx index 977e397022..67865ed397 100644 --- a/client/src/components/Messages/MessageContent.tsx +++ b/client/src/components/Messages/MessageContent.tsx @@ -48,7 +48,6 @@ export default function MessageContent(props: TMessageProps) {