From aa0f7944de3beeb24c6187b24a9788ed4fa9156f Mon Sep 17 00:00:00 2001 From: Markus Ecker Date: Thu, 27 Nov 2025 12:41:36 +0100 Subject: [PATCH 1/3] Fix and test re-rendering issues --- .../chat/CopilotChatMessageView.tsx | 229 +++- .../CopilotChatToolRerenders.e2e.test.tsx | 1011 +++++++++++++++++ .../react/src/hooks/use-render-tool-call.tsx | 138 ++- 3 files changed, 1308 insertions(+), 70 deletions(-) create mode 100644 packages/react/src/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx diff --git a/packages/react/src/components/chat/CopilotChatMessageView.tsx b/packages/react/src/components/chat/CopilotChatMessageView.tsx index 1ea9d9f..51b9d6b 100644 --- a/packages/react/src/components/chat/CopilotChatMessageView.tsx +++ b/packages/react/src/components/chat/CopilotChatMessageView.tsx @@ -1,10 +1,162 @@ +import React from "react"; import { WithSlots, renderSlot } from "@/lib/slots"; import CopilotChatAssistantMessage from "./CopilotChatAssistantMessage"; import CopilotChatUserMessage from "./CopilotChatUserMessage"; -import { Message } from "@ag-ui/core"; +import { ActivityMessage, AssistantMessage, Message, UserMessage } from "@ag-ui/core"; import { twMerge } from "tailwind-merge"; import { useRenderActivityMessage, useRenderCustomMessages } from "@/hooks"; +/** + * Memoized wrapper for assistant messages to prevent re-renders when other messages change. + */ +const MemoizedAssistantMessage = React.memo( + function MemoizedAssistantMessage({ + message, + messages, + isRunning, + AssistantMessageComponent, + }: { + message: AssistantMessage; + messages: Message[]; + isRunning: boolean; + AssistantMessageComponent: typeof CopilotChatAssistantMessage; + }) { + return ( + + ); + }, + (prevProps, nextProps) => { + // Only re-render if this specific message changed + if (prevProps.message.id !== nextProps.message.id) return false; + if (prevProps.message.content !== nextProps.message.content) return false; + + // Compare tool calls if present + const prevToolCalls = prevProps.message.toolCalls; + const nextToolCalls = nextProps.message.toolCalls; + if (prevToolCalls?.length !== nextToolCalls?.length) return false; + if (prevToolCalls && nextToolCalls) { + for (let i = 0; i < prevToolCalls.length; i++) { + const prevTc = prevToolCalls[i]; + const nextTc = nextToolCalls[i]; + if (!prevTc || !nextTc) return false; + if (prevTc.id !== nextTc.id) return false; + if (prevTc.function.arguments !== nextTc.function.arguments) return false; + } + } + + // Check if tool results changed for this message's tool calls + // Tool results are separate messages with role="tool" that reference tool call IDs + if (prevToolCalls && prevToolCalls.length > 0) { + const toolCallIds = new Set(prevToolCalls.map(tc => tc.id)); + + const prevToolResults = prevProps.messages.filter( + m => m.role === "tool" && toolCallIds.has((m as any).toolCallId) + ); + const nextToolResults = nextProps.messages.filter( + m => m.role === "tool" && toolCallIds.has((m as any).toolCallId) + ); + + // If number of tool results changed, re-render + if (prevToolResults.length !== nextToolResults.length) return false; + + // If any tool result content changed, re-render + for (let i = 0; i < prevToolResults.length; i++) { + if ((prevToolResults[i] as any).content !== (nextToolResults[i] as any).content) return false; + } + } + + // Only care about isRunning if this message is CURRENTLY the latest + // (we don't need to re-render just because a message stopped being the latest) + const nextIsLatest = nextProps.messages[nextProps.messages.length - 1]?.id === nextProps.message.id; + if (nextIsLatest && prevProps.isRunning !== nextProps.isRunning) return false; + + // Check if component reference changed + if (prevProps.AssistantMessageComponent !== nextProps.AssistantMessageComponent) return false; + + return true; + } +); + +/** + * Memoized wrapper for user messages to prevent re-renders when other messages change. + */ +const MemoizedUserMessage = React.memo( + function MemoizedUserMessage({ + message, + UserMessageComponent, + }: { + message: UserMessage; + UserMessageComponent: typeof CopilotChatUserMessage; + }) { + return ; + }, + (prevProps, nextProps) => { + // Only re-render if this specific message changed + if (prevProps.message.id !== nextProps.message.id) return false; + if (prevProps.message.content !== nextProps.message.content) return false; + if (prevProps.UserMessageComponent !== nextProps.UserMessageComponent) return false; + return true; + } +); + +/** + * Memoized wrapper for activity messages to prevent re-renders when other messages change. + */ +const MemoizedActivityMessage = React.memo( + function MemoizedActivityMessage({ + message, + renderActivityMessage, + }: { + message: ActivityMessage; + renderActivityMessage: (message: ActivityMessage) => React.ReactElement | null; + }) { + return renderActivityMessage(message); + }, + (prevProps, nextProps) => { + // Only re-render if this specific activity message changed + if (prevProps.message.id !== nextProps.message.id) return false; + if (prevProps.message.activityType !== nextProps.message.activityType) return false; + // Compare content - need to stringify since it's an object + if (JSON.stringify(prevProps.message.content) !== JSON.stringify(nextProps.message.content)) return false; + // Note: We don't compare renderActivityMessage function reference because it changes + // frequently due to useCallback dependencies in useRenderActivityMessage. + // The message content comparison is sufficient to determine if a re-render is needed. + return true; + } +); + +/** + * Memoized wrapper for custom messages to prevent re-renders when other messages change. + */ +const MemoizedCustomMessage = React.memo( + function MemoizedCustomMessage({ + message, + position, + renderCustomMessage, + }: { + message: Message; + position: "before" | "after"; + renderCustomMessage: (params: { message: Message; position: "before" | "after" }) => React.ReactElement | null; + }) { + return renderCustomMessage({ message, position }); + }, + (prevProps, nextProps) => { + // Only re-render if the message or position changed + if (prevProps.message.id !== nextProps.message.id) return false; + if (prevProps.position !== nextProps.position) return false; + // Compare message content - for assistant messages this is a string, for others may differ + if (prevProps.message.content !== nextProps.message.content) return false; + if (prevProps.message.role !== nextProps.message.role) return false; + // Note: We don't compare renderCustomMessage function reference because it changes + // frequently. The message content comparison is sufficient to determine if a re-render is needed. + return true; + } +); + export type CopilotChatMessageViewProps = Omit< WithSlots< { @@ -43,48 +195,71 @@ export function CopilotChatMessageView({ .flatMap((message) => { const elements: (React.ReactElement | null | undefined)[] = []; - // Render custom message before + // Render custom message before (using memoized wrapper) if (renderCustomMessage) { elements.push( - renderCustomMessage({ - message, - position: "before", - }), + ); } - // Render the main message + // Render the main message using memoized wrappers to prevent unnecessary re-renders if (message.role === "assistant") { + // Determine the component to use (custom slot or default) + const AssistantComponent = ( + typeof assistantMessage === "function" + ? assistantMessage + : CopilotChatAssistantMessage + ) as typeof CopilotChatAssistantMessage; + elements.push( - renderSlot(assistantMessage, CopilotChatAssistantMessage, { - key: message.id, - message, - messages, - isRunning, - }), + ); } else if (message.role === "user") { + // Determine the component to use (custom slot or default) + const UserComponent = ( + typeof userMessage === "function" + ? userMessage + : CopilotChatUserMessage + ) as typeof CopilotChatUserMessage; + elements.push( - renderSlot(userMessage, CopilotChatUserMessage, { - key: message.id, - message, - }), + ); } else if (message.role === "activity") { - const renderedActivity = renderActivityMessage(message); - - if (renderedActivity) { - elements.push(renderedActivity); - } + // Use memoized wrapper to prevent re-renders when other messages change + elements.push( + + ); } - // Render custom message after + // Render custom message after (using memoized wrapper) if (renderCustomMessage) { elements.push( - renderCustomMessage({ - message, - position: "after", - }), + ); } diff --git a/packages/react/src/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx b/packages/react/src/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx new file mode 100644 index 0000000..112f1c6 --- /dev/null +++ b/packages/react/src/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx @@ -0,0 +1,1011 @@ +import React, { useRef } from "react"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { z } from "zod"; +import { CopilotKitProvider } from "@/providers/CopilotKitProvider"; +import { CopilotChat } from "../CopilotChat"; +import { + AbstractAgent, + EventType, + type BaseEvent, + type RunAgentInput, +} from "@ag-ui/client"; +import { Observable, Subject } from "rxjs"; +import { + defineToolCallRenderer, + ReactToolCallRenderer, +} from "@/types"; +import { ToolCallStatus } from "@copilotkitnext/core"; +import { CopilotChatMessageView } from "../CopilotChatMessageView"; +import { CopilotChatConfigurationProvider } from "@/providers/CopilotChatConfigurationProvider"; +import { ActivityMessage, AssistantMessage, Message } from "@ag-ui/core"; +import { ReactActivityMessageRenderer, ReactCustomMessageRenderer } from "@/types"; + +// A controllable streaming agent to step through events deterministically +class MockStepwiseAgent extends AbstractAgent { + private subject = new Subject(); + + emit(event: BaseEvent) { + if (event.type === EventType.RUN_STARTED) { + this.isRunning = true; + } else if ( + event.type === EventType.RUN_FINISHED || + event.type === EventType.RUN_ERROR + ) { + this.isRunning = false; + } + this.subject.next(event); + } + + complete() { + this.isRunning = false; + this.subject.complete(); + } + + clone(): MockStepwiseAgent { + // For tests, return same instance so we can keep controlling it. + return this; + } + + run(_input: RunAgentInput): Observable { + return this.subject.asObservable(); + } +} + +describe("Tool Call Re-render Prevention", () => { + it("should not re-render a completed tool call when subsequent text is streamed", async () => { + const agent = new MockStepwiseAgent(); + + // Track render counts for the tool renderer + let toolRenderCount = 0; + let lastRenderStatus: string | null = null; + let lastRenderArgs: Record | null = null; + + const renderToolCalls = [ + defineToolCallRenderer({ + name: "getWeather", + args: z.object({ + location: z.string(), + }), + render: ({ status, args, result }) => { + toolRenderCount++; + lastRenderStatus = status; + lastRenderArgs = args as Record; + + return ( +
+ {toolRenderCount} + {status} + {args.location} + {result ? String(result) : "pending"} +
+ ); + }, + }), + ] as unknown as ReactToolCallRenderer[]; + + render( + +
+ +
+
+ ); + + // Submit a user message to trigger runAgent + const input = await screen.findByRole("textbox"); + fireEvent.change(input, { target: { value: "What's the weather?" } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("What's the weather?")).toBeDefined(); + }); + + const messageId = "m_rerender_test"; + const toolCallId = "tc_rerender_test"; + + // Start the run + agent.emit({ type: EventType.RUN_STARTED } as BaseEvent); + + // Stream the tool call with complete args + agent.emit({ + type: EventType.TOOL_CALL_CHUNK, + toolCallId, + toolCallName: "getWeather", + parentMessageId: messageId, + delta: '{"location":"Paris"}', + } as BaseEvent); + + // Wait for tool to render with InProgress status + await waitFor(() => { + const statusEl = screen.getByTestId("status"); + expect(statusEl.textContent).toBe("inProgress"); + expect(screen.getByTestId("location").textContent).toBe("Paris"); + }); + + const renderCountAfterToolCall = toolRenderCount; + + // Send the tool result to complete the tool call + agent.emit({ + type: EventType.TOOL_CALL_RESULT, + toolCallId, + messageId: `${messageId}_result`, + content: JSON.stringify({ temperature: 22, condition: "sunny" }), + } as BaseEvent); + + // Wait for tool to show Complete status + await waitFor(() => { + const statusEl = screen.getByTestId("status"); + expect(statusEl.textContent).toBe("complete"); + }); + + const renderCountAfterComplete = toolRenderCount; + + // Sanity check: it should have re-rendered at least once to show complete status + expect(renderCountAfterComplete).toBeGreaterThan(renderCountAfterToolCall); + + // Now stream additional text AFTER the tool call is complete + // This should NOT cause the tool call renderer to re-render + agent.emit({ + type: EventType.TEXT_MESSAGE_CHUNK, + messageId: "m_followup", + delta: "The weather in Paris is ", + } as BaseEvent); + + // Wait a moment for React to process + await waitFor(() => { + expect(screen.getByText(/The weather in Paris is/)).toBeDefined(); + }); + + const renderCountAfterFirstTextChunk = toolRenderCount; + + // Stream more text chunks + agent.emit({ + type: EventType.TEXT_MESSAGE_CHUNK, + messageId: "m_followup", + delta: "currently sunny ", + } as BaseEvent); + + await waitFor(() => { + expect(screen.getByText(/currently sunny/)).toBeDefined(); + }); + + agent.emit({ + type: EventType.TEXT_MESSAGE_CHUNK, + messageId: "m_followup", + delta: "with a temperature of 22°C.", + } as BaseEvent); + + await waitFor(() => { + expect(screen.getByText(/22°C/)).toBeDefined(); + }); + + const renderCountAfterAllText = toolRenderCount; + + // THE KEY ASSERTION: The tool should NOT have re-rendered after it was complete + // and we started streaming text + expect(renderCountAfterAllText).toBe(renderCountAfterComplete); + + // Verify the tool still shows the correct completed state + expect(screen.getByTestId("status").textContent).toBe("complete"); + expect(screen.getByTestId("location").textContent).toBe("Paris"); + expect(screen.getByTestId("result").textContent).toContain("temperature"); + + agent.emit({ type: EventType.RUN_FINISHED } as BaseEvent); + agent.complete(); + }); + + it("should not re-render a tool call when its arguments have not changed during streaming", async () => { + const agent = new MockStepwiseAgent(); + + // Track render counts + let toolRenderCount = 0; + + const renderToolCalls = [ + defineToolCallRenderer({ + name: "search", + args: z.object({ + query: z.string(), + }), + render: ({ status, args }) => { + toolRenderCount++; + + return ( +
+ {toolRenderCount} + {status} + {args.query} +
+ ); + }, + }), + ] as unknown as ReactToolCallRenderer[]; + + render( + +
+ +
+
+ ); + + const input = await screen.findByRole("textbox"); + fireEvent.change(input, { target: { value: "Search for something" } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("Search for something")).toBeDefined(); + }); + + const messageId = "m_search"; + const toolCallId = "tc_search"; + + agent.emit({ type: EventType.RUN_STARTED } as BaseEvent); + + // Stream complete tool call args + agent.emit({ + type: EventType.TOOL_CALL_CHUNK, + toolCallId, + toolCallName: "search", + parentMessageId: messageId, + delta: '{"query":"React hooks"}', + } as BaseEvent); + + await waitFor(() => { + expect(screen.getByTestId("search-query").textContent).toBe("React hooks"); + }); + + const renderCountAfterToolCall = toolRenderCount; + + // Stream text in the same message (before tool result) + // This simulates the agent adding explanation text while tool is in progress + agent.emit({ + type: EventType.TEXT_MESSAGE_CHUNK, + messageId, + delta: "Let me search for that...", + } as BaseEvent); + + await waitFor(() => { + expect(screen.getByText(/Let me search for that/)).toBeDefined(); + }); + + const renderCountAfterText = toolRenderCount; + + // The tool call should NOT re-render just because text was added to the message + // since its arguments haven't changed + expect(renderCountAfterText).toBe(renderCountAfterToolCall); + + agent.emit({ type: EventType.RUN_FINISHED } as BaseEvent); + agent.complete(); + }); +}); + +describe("Text Message Re-render Prevention", () => { + it("should not re-render a previous assistant message when a new message streams in", async () => { + // Track render counts per message ID + const renderCounts: Record = {}; + + // Custom assistant message component that tracks renders + const TrackedAssistantMessage: React.FC<{ + message: AssistantMessage; + messages?: Message[]; + isRunning?: boolean; + }> = ({ message }) => { + // Increment render count for this message + renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; + + return ( +
+ {message.content} + + {renderCounts[message.id]} + +
+ ); + }; + + // Initial messages - one complete assistant message + const initialMessages: Message[] = [ + { + id: "msg-1", + role: "assistant", + content: "Hello! How can I help you today?", + } as AssistantMessage, + ]; + + const { rerender } = render( + + + + + + ); + + // Verify first message rendered + await waitFor(() => { + expect(screen.getByTestId("assistant-message-msg-1")).toBeDefined(); + }); + + const firstMessageRenderCountAfterInitial = renderCounts["msg-1"]; + expect(firstMessageRenderCountAfterInitial).toBe(1); + + // Simulate streaming a second message - first chunk + const messagesWithSecondPartial: Message[] = [ + ...initialMessages, + { + id: "msg-2", + role: "assistant", + content: "Let me help", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("assistant-message-msg-2")).toBeDefined(); + }); + + const firstMessageRenderCountAfterSecondMessage = renderCounts["msg-1"]; + + // Continue streaming the second message + const messagesWithMoreContent: Message[] = [ + ...initialMessages, + { + id: "msg-2", + role: "assistant", + content: "Let me help you with that task.", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("content-msg-2").textContent).toBe( + "Let me help you with that task." + ); + }); + + // Stream even more content + const messagesWithEvenMoreContent: Message[] = [ + ...initialMessages, + { + id: "msg-2", + role: "assistant", + content: "Let me help you with that task. Here's what I found:", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("content-msg-2").textContent).toContain( + "Here's what I found" + ); + }); + + const firstMessageRenderCountAfterAllStreaming = renderCounts["msg-1"]; + + // THE KEY ASSERTION: The first message should NOT have re-rendered + // when the second message was streaming + expect(firstMessageRenderCountAfterAllStreaming).toBe( + firstMessageRenderCountAfterInitial + ); + + // Verify the second message did update (it should have rendered multiple times) + expect(renderCounts["msg-2"]).toBeGreaterThan(1); + }); + + it("should not re-render a user message when assistant message streams", async () => { + const renderCounts: Record = {}; + + const TrackedAssistantMessage: React.FC<{ + message: AssistantMessage; + messages?: Message[]; + isRunning?: boolean; + }> = ({ message }) => { + renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; + return ( +
+ {message.content} +
+ ); + }; + + const TrackedUserMessage: React.FC<{ + message: Message; + }> = ({ message }) => { + renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; + return ( +
+ + {typeof message.content === "string" ? message.content : ""} + + + {renderCounts[message.id]} + +
+ ); + }; + + const initialMessages: Message[] = [ + { + id: "user-1", + role: "user", + content: "Hello!", + }, + ]; + + const { rerender } = render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("user-message-user-1")).toBeDefined(); + }); + + const userMessageRenderCountInitial = renderCounts["user-1"]; + expect(userMessageRenderCountInitial).toBe(1); + + // Add assistant response and stream it + const messagesWithAssistant: Message[] = [ + ...initialMessages, + { + id: "assistant-1", + role: "assistant", + content: "Hi there!", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + // Stream more content + const messagesWithMoreAssistant: Message[] = [ + ...initialMessages, + { + id: "assistant-1", + role: "assistant", + content: "Hi there! How can I assist you today?", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("content-assistant-1").textContent).toContain( + "How can I assist" + ); + }); + + const userMessageRenderCountAfterStreaming = renderCounts["user-1"]; + + // THE KEY ASSERTION: User message should not re-render when assistant streams + expect(userMessageRenderCountAfterStreaming).toBe( + userMessageRenderCountInitial + ); + }); +}); + +describe("Activity Message Re-render Prevention", () => { + it("should not re-render a previous activity message when a new message streams in", async () => { + // Track render counts per message ID + const renderCounts: Record = {}; + + // Custom activity renderer that tracks renders + const activityRenderer: ReactActivityMessageRenderer<{ status: string; percent: number }> = { + activityType: "search-progress", + content: z.object({ status: z.string(), percent: z.number() }), + render: ({ content, message }) => { + renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; + return ( +
+ + {content.status} - {content.percent}% + + + {renderCounts[message.id]} + +
+ ); + }, + }; + + // Initial messages - one activity message + const initialMessages: Message[] = [ + { + id: "activity-1", + role: "activity", + activityType: "search-progress", + content: { status: "Searching", percent: 50 }, + } as ActivityMessage, + ]; + + const { rerender } = render( + + + + + + ); + + // Verify first activity rendered + await waitFor(() => { + expect(screen.getByTestId("activity-activity-1")).toBeDefined(); + }); + + const firstActivityRenderCountAfterInitial = renderCounts["activity-1"]; + expect(firstActivityRenderCountAfterInitial).toBe(1); + + // Add a second activity message + const messagesWithSecondActivity: Message[] = [ + ...initialMessages, + { + id: "activity-2", + role: "activity", + activityType: "search-progress", + content: { status: "Processing", percent: 75 }, + } as ActivityMessage, + ]; + + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("activity-activity-2")).toBeDefined(); + }); + + // Update the second activity message + const messagesWithUpdatedSecondActivity: Message[] = [ + initialMessages[0], + { + id: "activity-2", + role: "activity", + activityType: "search-progress", + content: { status: "Almost done", percent: 90 }, + } as ActivityMessage, + ]; + + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("activity-content-activity-2").textContent).toContain( + "Almost done" + ); + }); + + const firstActivityRenderCountAfterAllUpdates = renderCounts["activity-1"]; + + // THE KEY ASSERTION: The first activity should NOT have re-rendered + // when the second activity was added or updated + expect(firstActivityRenderCountAfterAllUpdates).toBe( + firstActivityRenderCountAfterInitial + ); + + // Verify the second activity did update (it should have rendered multiple times) + expect(renderCounts["activity-2"]).toBeGreaterThan(1); + }); + + it("should not re-render an activity message when an assistant message streams", async () => { + const renderCounts: Record = {}; + + const activityRenderer: ReactActivityMessageRenderer<{ status: string }> = { + activityType: "progress", + content: z.object({ status: z.string() }), + render: ({ content, message }) => { + renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; + return ( +
+ {content.status} +
+ ); + }, + }; + + const TrackedAssistantMessage: React.FC<{ + message: AssistantMessage; + messages?: Message[]; + isRunning?: boolean; + }> = ({ message }) => { + renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; + return ( +
+ {message.content} +
+ ); + }; + + const initialMessages: Message[] = [ + { + id: "activity-1", + role: "activity", + activityType: "progress", + content: { status: "Loading..." }, + } as ActivityMessage, + ]; + + const { rerender } = render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("activity-activity-1")).toBeDefined(); + }); + + const activityRenderCountInitial = renderCounts["activity-1"]; + expect(activityRenderCountInitial).toBe(1); + + // Add an assistant message and stream it + const messagesWithAssistant: Message[] = [ + ...initialMessages, + { + id: "assistant-1", + role: "assistant", + content: "Here's what I found...", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + // Stream more content + const messagesWithMoreAssistant: Message[] = [ + initialMessages[0], + { + id: "assistant-1", + role: "assistant", + content: "Here's what I found... The results show that...", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("assistant-assistant-1").textContent).toContain( + "The results show" + ); + }); + + const activityRenderCountAfterStreaming = renderCounts["activity-1"]; + + // THE KEY ASSERTION: Activity message should not re-render when assistant streams + expect(activityRenderCountAfterStreaming).toBe(activityRenderCountInitial); + }); +}); + +describe("Custom Message Re-render Prevention", () => { + it("should not re-render a custom message for a previous message when a new message streams in", async () => { + const agent = new MockStepwiseAgent(); + + // Track render counts by message ID and position + const renderCounts: Record = {}; + + // Custom message renderer that tracks renders + const customRenderer: ReactCustomMessageRenderer = { + render: ({ message, position }) => { + // Only render for assistant messages in "after" position + if (message.role !== "assistant" || position !== "after") { + return null; + } + + const key = `${message.id}-${position}`; + renderCounts[key] = (renderCounts[key] || 0) + 1; + + return ( +
+ + Custom content for {message.id} + + + {renderCounts[key]} + +
+ ); + }, + }; + + // Initial messages - one assistant message + const initialMessages: Message[] = [ + { + id: "assistant-1", + role: "assistant", + content: "Hello! How can I help you?", + } as AssistantMessage, + ]; + + const { rerender } = render( + + + + + + ); + + // Verify first custom message rendered + await waitFor(() => { + expect(screen.getByTestId("custom-assistant-1")).toBeDefined(); + }); + + const firstCustomRenderCountAfterInitial = renderCounts["assistant-1-after"]; + expect(firstCustomRenderCountAfterInitial).toBe(1); + + // Add a second assistant message + const messagesWithSecond: Message[] = [ + ...initialMessages, + { + id: "assistant-2", + role: "assistant", + content: "Here's some more info...", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("custom-assistant-2")).toBeDefined(); + }); + + // Update the second message (streaming more content) + const messagesWithUpdatedSecond: Message[] = [ + initialMessages[0], + { + id: "assistant-2", + role: "assistant", + content: "Here's some more info... Let me explain in detail.", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + // Stream even more content + const messagesWithMoreContent: Message[] = [ + initialMessages[0], + { + id: "assistant-2", + role: "assistant", + content: "Here's some more info... Let me explain in detail. This is comprehensive.", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + const firstCustomRenderCountAfterAllUpdates = renderCounts["assistant-1-after"]; + + // THE KEY ASSERTION: The first custom message should NOT have re-rendered + // when the second message was streaming + expect(firstCustomRenderCountAfterAllUpdates).toBe( + firstCustomRenderCountAfterInitial + ); + + // Verify the second custom message did update + expect(renderCounts["assistant-2-after"]).toBeGreaterThan(1); + }); + + it("should not re-render custom messages when isRunning changes but message content is the same", async () => { + const agent = new MockStepwiseAgent(); + const renderCounts: Record = {}; + + const customRenderer: ReactCustomMessageRenderer = { + render: ({ message, position }) => { + if (message.role !== "assistant" || position !== "after") { + return null; + } + + const key = `${message.id}-${position}`; + renderCounts[key] = (renderCounts[key] || 0) + 1; + + return ( +
+ Render count: {renderCounts[key]} +
+ ); + }, + }; + + const messages: Message[] = [ + { + id: "assistant-1", + role: "assistant", + content: "Complete message", + } as AssistantMessage, + ]; + + const { rerender } = render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("custom-assistant-1")).toBeDefined(); + }); + + const renderCountWhileRunning = renderCounts["assistant-1-after"]; + expect(renderCountWhileRunning).toBe(1); + + // Change isRunning to false (but same messages) + rerender( + + + + + + ); + + const renderCountAfterRunningChanged = renderCounts["assistant-1-after"]; + + // THE KEY ASSERTION: Custom message should not re-render just because isRunning changed + expect(renderCountAfterRunningChanged).toBe(renderCountWhileRunning); + }); +}); diff --git a/packages/react/src/hooks/use-render-tool-call.tsx b/packages/react/src/hooks/use-render-tool-call.tsx index f18417b..60a059b 100644 --- a/packages/react/src/hooks/use-render-tool-call.tsx +++ b/packages/react/src/hooks/use-render-tool-call.tsx @@ -1,16 +1,99 @@ -import React, { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { ToolCall, ToolMessage } from "@ag-ui/core"; import { ToolCallStatus } from "@copilotkitnext/core"; import { useCopilotKit } from "@/providers/CopilotKitProvider"; import { useCopilotChatConfiguration } from "@/providers/CopilotChatConfigurationProvider"; import { DEFAULT_AGENT_ID } from "@copilotkitnext/shared"; import { partialJSONParse } from "@copilotkitnext/shared"; +import { ReactToolCallRenderer } from "@/types/react-tool-call-renderer"; export interface UseRenderToolCallProps { toolCall: ToolCall; toolMessage?: ToolMessage; } +/** + * Props for the memoized ToolCallRenderer component + */ +interface ToolCallRendererProps { + toolCall: ToolCall; + toolMessage?: ToolMessage; + RenderComponent: ReactToolCallRenderer["render"]; + isExecuting: boolean; +} + +/** + * Memoized component that renders a single tool call. + * This prevents unnecessary re-renders when parent components update + * but the tool call data hasn't changed. + */ +const ToolCallRenderer = React.memo( + function ToolCallRenderer({ + toolCall, + toolMessage, + RenderComponent, + isExecuting, + }: ToolCallRendererProps) { + // Memoize args based on the arguments string to maintain stable reference + const args = useMemo( + () => partialJSONParse(toolCall.function.arguments), + [toolCall.function.arguments] + ); + + const toolName = toolCall.function.name; + + // Render based on status to preserve discriminated union type inference + if (toolMessage) { + return ( + + ); + } else if (isExecuting) { + return ( + + ); + } else { + return ( + + ); + } + }, + // Custom comparison function to prevent re-renders when tool call data hasn't changed + (prevProps, nextProps) => { + // Compare tool call identity and content + if (prevProps.toolCall.id !== nextProps.toolCall.id) return false; + if (prevProps.toolCall.function.name !== nextProps.toolCall.function.name) return false; + if (prevProps.toolCall.function.arguments !== nextProps.toolCall.function.arguments) return false; + + // Compare tool message (result) + const prevResult = prevProps.toolMessage?.content; + const nextResult = nextProps.toolMessage?.content; + if (prevResult !== nextResult) return false; + + // Compare executing state + if (prevProps.isExecuting !== nextProps.isExecuting) return false; + + // Compare render component reference + if (prevProps.RenderComponent !== nextProps.RenderComponent) return false; + + return true; + } +); + /** * Hook that returns a function to render tool calls based on the render functions * defined in CopilotKitProvider. @@ -86,49 +169,18 @@ export function useRenderToolCall() { } const RenderComponent = renderConfig.render; + const isExecuting = executingToolCallIds.has(toolCall.id); - // Parse the arguments if they're a string - const args = partialJSONParse(toolCall.function.arguments); - - // Create props based on status with proper typing - const toolName = toolCall.function.name; - - if (toolMessage) { - // Complete status with result - return ( - - ); - } else if (executingToolCallIds.has(toolCall.id)) { - // Tool is currently executing - return ( - - ); - } else { - // In progress status - tool call exists but hasn't completed yet - // This remains true even after agent stops running, until we get a result - return ( - - ); - } + // Use the memoized ToolCallRenderer component to prevent unnecessary re-renders + return ( + + ); }, [renderToolCalls, executingToolCallIds, agentId] ); From 59e39f0a56c2f2546c316f744b4e57cdb6c4135c Mon Sep 17 00:00:00 2001 From: Markus Ecker Date: Thu, 27 Nov 2025 12:46:10 +0100 Subject: [PATCH 2/3] Update CopilotChatToolRerenders.e2e.test.tsx --- .../CopilotChatToolRerenders.e2e.test.tsx | 685 +++++++++++++++++- 1 file changed, 684 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx b/packages/react/src/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx index 112f1c6..c1d9381 100644 --- a/packages/react/src/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx +++ b/packages/react/src/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx @@ -283,6 +283,182 @@ describe("Tool Call Re-render Prevention", () => { agent.emit({ type: EventType.RUN_FINISHED } as BaseEvent); agent.complete(); }); + + it("should re-render a tool call when its arguments change during streaming", async () => { + const agent = new MockStepwiseAgent(); + + // Track render counts and captured args + let toolRenderCount = 0; + const capturedArgs: string[] = []; + + const renderToolCalls = [ + defineToolCallRenderer({ + name: "search", + args: z.object({ + query: z.string(), + }), + render: ({ args }) => { + toolRenderCount++; + capturedArgs.push(args.query); + + return ( +
+ {toolRenderCount} + {args.query} +
+ ); + }, + }), + ] as unknown as ReactToolCallRenderer[]; + + render( + +
+ +
+
+ ); + + const input = await screen.findByRole("textbox"); + fireEvent.change(input, { target: { value: "Search for something" } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("Search for something")).toBeDefined(); + }); + + const messageId = "m_search_update"; + const toolCallId = "tc_search_update"; + + agent.emit({ type: EventType.RUN_STARTED } as BaseEvent); + + // Stream partial args first + agent.emit({ + type: EventType.TOOL_CALL_CHUNK, + toolCallId, + toolCallName: "search", + parentMessageId: messageId, + delta: '{"query":"Rea', + } as BaseEvent); + + await waitFor(() => { + expect(screen.getByTestId("search-query").textContent).toBe("Rea"); + }); + + const renderCountAfterFirstChunk = toolRenderCount; + + // Stream more args + agent.emit({ + type: EventType.TOOL_CALL_CHUNK, + toolCallId, + toolCallName: "search", + parentMessageId: messageId, + delta: 'ct hooks"}', + } as BaseEvent); + + await waitFor(() => { + expect(screen.getByTestId("search-query").textContent).toBe("React hooks"); + }); + + const renderCountAfterSecondChunk = toolRenderCount; + + // THE KEY ASSERTION: Tool should re-render when arguments change + expect(renderCountAfterSecondChunk).toBeGreaterThan(renderCountAfterFirstChunk); + expect(capturedArgs).toContain("Rea"); + expect(capturedArgs).toContain("React hooks"); + + agent.emit({ type: EventType.RUN_FINISHED } as BaseEvent); + agent.complete(); + }); + + it("should re-render a tool call when status changes to complete", async () => { + const agent = new MockStepwiseAgent(); + + let toolRenderCount = 0; + const capturedStatuses: string[] = []; + + const renderToolCalls = [ + defineToolCallRenderer({ + name: "getData", + args: z.object({ id: z.string() }), + render: ({ status, result }) => { + toolRenderCount++; + capturedStatuses.push(status); + + return ( +
+ {status} + {result ? String(result) : "none"} +
+ ); + }, + }), + ] as unknown as ReactToolCallRenderer[]; + + render( + +
+ +
+
+ ); + + const input = await screen.findByRole("textbox"); + fireEvent.change(input, { target: { value: "Get data" } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("Get data")).toBeDefined(); + }); + + const messageId = "m_data"; + const toolCallId = "tc_data"; + + agent.emit({ type: EventType.RUN_STARTED } as BaseEvent); + + // Send tool call + agent.emit({ + type: EventType.TOOL_CALL_CHUNK, + toolCallId, + toolCallName: "getData", + parentMessageId: messageId, + delta: '{"id":"123"}', + } as BaseEvent); + + await waitFor(() => { + expect(screen.getByTestId("data-status").textContent).toBe("inProgress"); + }); + + const renderCountBeforeResult = toolRenderCount; + + // Send tool result + agent.emit({ + type: EventType.TOOL_CALL_RESULT, + toolCallId, + messageId: `${messageId}_result`, + content: JSON.stringify({ data: "found" }), + } as BaseEvent); + + await waitFor(() => { + expect(screen.getByTestId("data-status").textContent).toBe("complete"); + }); + + const renderCountAfterResult = toolRenderCount; + + // THE KEY ASSERTION: Tool should re-render when status changes + expect(renderCountAfterResult).toBeGreaterThan(renderCountBeforeResult); + expect(capturedStatuses).toContain("inProgress"); + expect(capturedStatuses).toContain("complete"); + + agent.emit({ type: EventType.RUN_FINISHED } as BaseEvent); + agent.complete(); + }); }); describe("Text Message Re-render Prevention", () => { @@ -553,6 +729,165 @@ describe("Text Message Re-render Prevention", () => { userMessageRenderCountInitial ); }); + + it("should re-render an assistant message when its content changes", async () => { + const renderCounts: Record = {}; + const capturedContent: string[] = []; + + const TrackedAssistantMessage: React.FC<{ + message: AssistantMessage; + messages?: Message[]; + isRunning?: boolean; + }> = ({ message }) => { + renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; + capturedContent.push(message.content); + return ( +
+ {message.content} +
+ ); + }; + + const initialMessages: Message[] = [ + { + id: "msg-1", + role: "assistant", + content: "Hello", + } as AssistantMessage, + ]; + + const { rerender } = render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("assistant-message-msg-1")).toBeDefined(); + }); + + const renderCountAfterInitial = renderCounts["msg-1"]; + expect(renderCountAfterInitial).toBe(1); + + // Update message content (streaming) + const updatedMessages: Message[] = [ + { + id: "msg-1", + role: "assistant", + content: "Hello! How can I help", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("content-msg-1").textContent).toBe( + "Hello! How can I help" + ); + }); + + const renderCountAfterUpdate = renderCounts["msg-1"]; + + // THE KEY ASSERTION: Message should re-render when content changes + expect(renderCountAfterUpdate).toBeGreaterThan(renderCountAfterInitial); + expect(capturedContent).toContain("Hello"); + expect(capturedContent).toContain("Hello! How can I help"); + }); + + it("should re-render a user message when its content changes", async () => { + const renderCounts: Record = {}; + const capturedContent: string[] = []; + + const TrackedUserMessage: React.FC<{ + message: Message; + }> = ({ message }) => { + renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; + const content = typeof message.content === "string" ? message.content : ""; + capturedContent.push(content); + return ( +
+ {content} +
+ ); + }; + + const initialMessages: Message[] = [ + { + id: "user-1", + role: "user", + content: "Initial message", + }, + ]; + + const { rerender } = render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("user-message-user-1")).toBeDefined(); + }); + + const renderCountAfterInitial = renderCounts["user-1"]; + expect(renderCountAfterInitial).toBe(1); + + // Update user message content (e.g., editing) + const updatedMessages: Message[] = [ + { + id: "user-1", + role: "user", + content: "Updated message", + }, + ]; + + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("user-content-user-1").textContent).toBe( + "Updated message" + ); + }); + + const renderCountAfterUpdate = renderCounts["user-1"]; + + // THE KEY ASSERTION: User message should re-render when content changes + expect(renderCountAfterUpdate).toBeGreaterThan(renderCountAfterInitial); + expect(capturedContent).toContain("Initial message"); + expect(capturedContent).toContain("Updated message"); + }); }); describe("Activity Message Re-render Prevention", () => { @@ -786,7 +1121,176 @@ describe("Activity Message Re-render Prevention", () => { // THE KEY ASSERTION: Activity message should not re-render when assistant streams expect(activityRenderCountAfterStreaming).toBe(activityRenderCountInitial); }); -}); + + it("should re-render an activity message when its content changes", async () => { + const renderCounts: Record = {}; + const capturedContent: { status: string; percent: number }[] = []; + + const activityRenderer: ReactActivityMessageRenderer<{ status: string; percent: number }> = { + activityType: "progress", + content: z.object({ status: z.string(), percent: z.number() }), + render: ({ content, message }) => { + renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; + capturedContent.push({ ...content }); + return ( +
+ {content.status} + {content.percent} +
+ ); + }, + }; + + const initialMessages: Message[] = [ + { + id: "activity-1", + role: "activity", + activityType: "progress", + content: { status: "Starting", percent: 0 }, + } as ActivityMessage, + ]; + + const { rerender } = render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("activity-activity-1")).toBeDefined(); + }); + + const renderCountAfterInitial = renderCounts["activity-1"]; + expect(renderCountAfterInitial).toBe(1); + + // Update activity content (progress update) + const updatedMessages: Message[] = [ + { + id: "activity-1", + role: "activity", + activityType: "progress", + content: { status: "Processing", percent: 50 }, + } as ActivityMessage, + ]; + + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("activity-status-activity-1").textContent).toBe("Processing"); + expect(screen.getByTestId("activity-percent-activity-1").textContent).toBe("50"); + }); + + const renderCountAfterUpdate = renderCounts["activity-1"]; + + // THE KEY ASSERTION: Activity should re-render when content changes + expect(renderCountAfterUpdate).toBeGreaterThan(renderCountAfterInitial); + expect(capturedContent).toContainEqual({ status: "Starting", percent: 0 }); + expect(capturedContent).toContainEqual({ status: "Processing", percent: 50 }); + }); + + it("should re-render an activity message when its activityType changes", async () => { + const renderCounts: Record = {}; + + const progressRenderer: ReactActivityMessageRenderer<{ status: string }> = { + activityType: "progress", + content: z.object({ status: z.string() }), + render: ({ content, message }) => { + renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; + return ( +
+ progress + {content.status} +
+ ); + }, + }; + + const completedRenderer: ReactActivityMessageRenderer<{ result: string }> = { + activityType: "completed", + content: z.object({ result: z.string() }), + render: ({ content, message }) => { + renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; + return ( +
+ completed + {content.result} +
+ ); + }, + }; + + const initialMessages: Message[] = [ + { + id: "activity-1", + role: "activity", + activityType: "progress", + content: { status: "Loading..." }, + } as ActivityMessage, + ]; + + const { rerender } = render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("activity-type-activity-1").textContent).toBe("progress"); + }); + + const renderCountAfterInitial = renderCounts["activity-1"]; + expect(renderCountAfterInitial).toBe(1); + + // Change activity type + const updatedMessages: Message[] = [ + { + id: "activity-1", + role: "activity", + activityType: "completed", + content: { result: "Done!" }, + } as ActivityMessage, + ]; + + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("activity-type-activity-1").textContent).toBe("completed"); + }); + + const renderCountAfterTypeChange = renderCounts["activity-1"]; + + // THE KEY ASSERTION: Activity should re-render when activityType changes + expect(renderCountAfterTypeChange).toBeGreaterThan(renderCountAfterInitial); + }); +}); describe("Custom Message Re-render Prevention", () => { it("should not re-render a custom message for a previous message when a new message streams in", async () => { @@ -1008,4 +1512,183 @@ describe("Custom Message Re-render Prevention", () => { // THE KEY ASSERTION: Custom message should not re-render just because isRunning changed expect(renderCountAfterRunningChanged).toBe(renderCountWhileRunning); }); + + it("should re-render a custom message when its message content changes", async () => { + const agent = new MockStepwiseAgent(); + const renderCounts: Record = {}; + const capturedContent: string[] = []; + + const customRenderer: ReactCustomMessageRenderer = { + render: ({ message, position }) => { + if (message.role !== "assistant" || position !== "after") { + return null; + } + + const key = `${message.id}-${position}`; + renderCounts[key] = (renderCounts[key] || 0) + 1; + const content = typeof message.content === "string" ? message.content : ""; + capturedContent.push(content); + + return ( +
+ {content} + + {renderCounts[key]} + +
+ ); + }, + }; + + const initialMessages: Message[] = [ + { + id: "assistant-1", + role: "assistant", + content: "Hello", + } as AssistantMessage, + ]; + + const { rerender } = render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("custom-assistant-1")).toBeDefined(); + }); + + const renderCountAfterInitial = renderCounts["assistant-1-after"]; + expect(renderCountAfterInitial).toBe(1); + + // Update message content (streaming) + const updatedMessages: Message[] = [ + { + id: "assistant-1", + role: "assistant", + content: "Hello! How can I help you today?", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("custom-content-assistant-1").textContent).toBe( + "Hello! How can I help you today?" + ); + }); + + const renderCountAfterUpdate = renderCounts["assistant-1-after"]; + + // THE KEY ASSERTION: Custom message should re-render when content changes + expect(renderCountAfterUpdate).toBeGreaterThan(renderCountAfterInitial); + expect(capturedContent).toContain("Hello"); + expect(capturedContent).toContain("Hello! How can I help you today?"); + }); + + it("should re-render a custom message when its message role changes", async () => { + const agent = new MockStepwiseAgent(); + const renderCounts: Record = {}; + + const customRenderer: ReactCustomMessageRenderer = { + render: ({ message, position }) => { + if (position !== "after") { + return null; + } + + const key = `${message.id}-${position}`; + renderCounts[key] = (renderCounts[key] || 0) + 1; + + return ( +
+ {message.role} + + {renderCounts[key]} + +
+ ); + }, + }; + + const initialMessages: Message[] = [ + { + id: "msg-1", + role: "user", + content: "Hello", + }, + ]; + + const { rerender } = render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("custom-role-msg-1").textContent).toBe("user"); + }); + + const renderCountAfterInitial = renderCounts["msg-1-after"]; + expect(renderCountAfterInitial).toBe(1); + + // Change message role (unusual but possible) + const updatedMessages: Message[] = [ + { + id: "msg-1", + role: "assistant", + content: "Hello", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("custom-role-msg-1").textContent).toBe("assistant"); + }); + + const renderCountAfterRoleChange = renderCounts["msg-1-after"]; + + // THE KEY ASSERTION: Custom message should re-render when role changes + expect(renderCountAfterRoleChange).toBeGreaterThan(renderCountAfterInitial); + }); }); From 2397c56a3b423b8187c9cea6e0d3d0e20440ecad Mon Sep 17 00:00:00 2001 From: Markus Ecker Date: Thu, 27 Nov 2025 16:30:23 +0100 Subject: [PATCH 3/3] globally memoize slots --- .../src/components/chat/CopilotChatView.tsx | 25 +- .../CopilotChatToolRerenders.e2e.test.tsx | 748 +++++++++++++++++- packages/react/src/lib/slots.tsx | 90 ++- 3 files changed, 794 insertions(+), 69 deletions(-) diff --git a/packages/react/src/components/chat/CopilotChatView.tsx b/packages/react/src/components/chat/CopilotChatView.tsx index fc9c76b..d4a1fee 100644 --- a/packages/react/src/components/chat/CopilotChatView.tsx +++ b/packages/react/src/components/chat/CopilotChatView.tsx @@ -1,5 +1,5 @@ import React, { useRef, useState, useEffect } from "react"; -import { WithSlots, renderSlot } from "@/lib/slots"; +import { WithSlots, SlotValue, renderSlot } from "@/lib/slots"; import CopilotChatMessageView from "./CopilotChatMessageView"; import CopilotChatInput, { CopilotChatInputProps } from "./CopilotChatInput"; import CopilotChatSuggestionView, { CopilotChatSuggestionViewProps } from "./CopilotChatSuggestionView"; @@ -113,20 +113,19 @@ export function CopilotChatView({ }); const BoundInput = renderSlot(input, CopilotChatInput, (inputProps ?? {}) as CopilotChatInputProps); + const hasSuggestions = Array.isArray(suggestions) && suggestions.length > 0; const BoundSuggestionView = hasSuggestions - ? renderSlot( - suggestionView, - CopilotChatSuggestionView, - { - suggestions, - loadingIndexes: suggestionLoadingIndexes, - onSelectSuggestion, - className: "mb-3 lg:ml-4 lg:mr-4 ml-0 mr-0", - }, - ) + ? renderSlot(suggestionView, CopilotChatSuggestionView, { + suggestions, + loadingIndexes: suggestionLoadingIndexes, + onSelectSuggestion, + className: "mb-3 lg:ml-4 lg:mr-4 ml-0 mr-0", + }) : null; + const BoundFeather = renderSlot(feather, CopilotChatView.Feather, {}); + const BoundScrollView = renderSlot(scrollView, CopilotChatView.ScrollView, { autoScroll, scrollToBottomButton, @@ -187,7 +186,7 @@ export namespace CopilotChatView { // Inner component that has access to StickToBottom context const ScrollContent: React.FC<{ children: React.ReactNode; - scrollToBottomButton?: React.FC>; + scrollToBottomButton?: SlotValue>>; inputContainerHeight: number; isResizing: boolean; }> = ({ children, scrollToBottomButton, inputContainerHeight, isResizing }) => { @@ -219,7 +218,7 @@ export namespace CopilotChatView { export const ScrollView: React.FC< React.HTMLAttributes & { autoScroll?: boolean; - scrollToBottomButton?: React.FC>; + scrollToBottomButton?: SlotValue>>; inputContainerHeight?: number; isResizing?: boolean; } diff --git a/packages/react/src/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx b/packages/react/src/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx index c1d9381..ba2de03 100644 --- a/packages/react/src/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx +++ b/packages/react/src/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from "react"; +import React, { useRef, useState } from "react"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { z } from "zod"; import { CopilotKitProvider } from "@/providers/CopilotKitProvider"; @@ -16,9 +16,13 @@ import { } from "@/types"; import { ToolCallStatus } from "@copilotkitnext/core"; import { CopilotChatMessageView } from "../CopilotChatMessageView"; +import { CopilotChatView, CopilotChatViewProps } from "../CopilotChatView"; import { CopilotChatConfigurationProvider } from "@/providers/CopilotChatConfigurationProvider"; import { ActivityMessage, AssistantMessage, Message } from "@ag-ui/core"; import { ReactActivityMessageRenderer, ReactCustomMessageRenderer } from "@/types"; +import CopilotChatInput, { CopilotChatInputProps } from "../CopilotChatInput"; +import { CopilotChatSuggestionView } from "../CopilotChatSuggestionView"; +import { CopilotChatAssistantMessage } from "../CopilotChatAssistantMessage"; // A controllable streaming agent to step through events deterministically class MockStepwiseAgent extends AbstractAgent { @@ -299,7 +303,7 @@ describe("Tool Call Re-render Prevention", () => { }), render: ({ args }) => { toolRenderCount++; - capturedArgs.push(args.query); + capturedArgs.push(args.query!); return (
@@ -500,7 +504,7 @@ describe("Text Message Re-render Prevention", () => { @@ -530,7 +534,7 @@ describe("Text Message Re-render Prevention", () => { @@ -558,7 +562,7 @@ describe("Text Message Re-render Prevention", () => { @@ -586,7 +590,7 @@ describe("Text Message Re-render Prevention", () => { @@ -656,8 +660,8 @@ describe("Text Message Re-render Prevention", () => { @@ -686,8 +690,8 @@ describe("Text Message Re-render Prevention", () => { @@ -709,8 +713,8 @@ describe("Text Message Re-render Prevention", () => { @@ -740,7 +744,7 @@ describe("Text Message Re-render Prevention", () => { isRunning?: boolean; }> = ({ message }) => { renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; - capturedContent.push(message.content); + capturedContent.push(message.content ?? ""); return (
{message.content} @@ -762,7 +766,7 @@ describe("Text Message Re-render Prevention", () => { @@ -772,7 +776,7 @@ describe("Text Message Re-render Prevention", () => { expect(screen.getByTestId("assistant-message-msg-1")).toBeDefined(); }); - const renderCountAfterInitial = renderCounts["msg-1"]; + const renderCountAfterInitial = renderCounts["msg-1"]!; expect(renderCountAfterInitial).toBe(1); // Update message content (streaming) @@ -790,7 +794,7 @@ describe("Text Message Re-render Prevention", () => { @@ -802,7 +806,7 @@ describe("Text Message Re-render Prevention", () => { ); }); - const renderCountAfterUpdate = renderCounts["msg-1"]; + const renderCountAfterUpdate = renderCounts["msg-1"]!; // THE KEY ASSERTION: Message should re-render when content changes expect(renderCountAfterUpdate).toBeGreaterThan(renderCountAfterInitial); @@ -841,7 +845,7 @@ describe("Text Message Re-render Prevention", () => { @@ -851,7 +855,7 @@ describe("Text Message Re-render Prevention", () => { expect(screen.getByTestId("user-message-user-1")).toBeDefined(); }); - const renderCountAfterInitial = renderCounts["user-1"]; + const renderCountAfterInitial = renderCounts["user-1"]!; expect(renderCountAfterInitial).toBe(1); // Update user message content (e.g., editing) @@ -869,7 +873,7 @@ describe("Text Message Re-render Prevention", () => { @@ -881,7 +885,7 @@ describe("Text Message Re-render Prevention", () => { ); }); - const renderCountAfterUpdate = renderCounts["user-1"]; + const renderCountAfterUpdate = renderCounts["user-1"]!; // THE KEY ASSERTION: User message should re-render when content changes expect(renderCountAfterUpdate).toBeGreaterThan(renderCountAfterInitial); @@ -971,7 +975,7 @@ describe("Activity Message Re-render Prevention", () => { // Update the second activity message const messagesWithUpdatedSecondActivity: Message[] = [ - initialMessages[0], + initialMessages[0]!, { id: "activity-2", role: "activity", @@ -1053,7 +1057,7 @@ describe("Activity Message Re-render Prevention", () => { @@ -1082,7 +1086,7 @@ describe("Activity Message Re-render Prevention", () => { @@ -1090,7 +1094,7 @@ describe("Activity Message Re-render Prevention", () => { // Stream more content const messagesWithMoreAssistant: Message[] = [ - initialMessages[0], + initialMessages[0]!, { id: "assistant-1", role: "assistant", @@ -1104,7 +1108,7 @@ describe("Activity Message Re-render Prevention", () => { @@ -1165,7 +1169,7 @@ describe("Activity Message Re-render Prevention", () => { expect(screen.getByTestId("activity-activity-1")).toBeDefined(); }); - const renderCountAfterInitial = renderCounts["activity-1"]; + const renderCountAfterInitial = renderCounts["activity-1"]!; expect(renderCountAfterInitial).toBe(1); // Update activity content (progress update) @@ -1194,7 +1198,7 @@ describe("Activity Message Re-render Prevention", () => { expect(screen.getByTestId("activity-percent-activity-1").textContent).toBe("50"); }); - const renderCountAfterUpdate = renderCounts["activity-1"]; + const renderCountAfterUpdate = renderCounts["activity-1"]!; // THE KEY ASSERTION: Activity should re-render when content changes expect(renderCountAfterUpdate).toBeGreaterThan(renderCountAfterInitial); @@ -1257,7 +1261,7 @@ describe("Activity Message Re-render Prevention", () => { expect(screen.getByTestId("activity-type-activity-1").textContent).toBe("progress"); }); - const renderCountAfterInitial = renderCounts["activity-1"]; + const renderCountAfterInitial = renderCounts["activity-1"]!; expect(renderCountAfterInitial).toBe(1); // Change activity type @@ -1285,7 +1289,7 @@ describe("Activity Message Re-render Prevention", () => { expect(screen.getByTestId("activity-type-activity-1").textContent).toBe("completed"); }); - const renderCountAfterTypeChange = renderCounts["activity-1"]; + const renderCountAfterTypeChange = renderCounts["activity-1"]!; // THE KEY ASSERTION: Activity should re-render when activityType changes expect(renderCountAfterTypeChange).toBeGreaterThan(renderCountAfterInitial); @@ -1384,7 +1388,7 @@ describe("Custom Message Re-render Prevention", () => { // Update the second message (streaming more content) const messagesWithUpdatedSecond: Message[] = [ - initialMessages[0], + initialMessages[0]!, { id: "assistant-2", role: "assistant", @@ -1408,7 +1412,7 @@ describe("Custom Message Re-render Prevention", () => { // Stream even more content const messagesWithMoreContent: Message[] = [ - initialMessages[0], + initialMessages[0]!, { id: "assistant-2", role: "assistant", @@ -1489,7 +1493,7 @@ describe("Custom Message Re-render Prevention", () => { expect(screen.getByTestId("custom-assistant-1")).toBeDefined(); }); - const renderCountWhileRunning = renderCounts["assistant-1-after"]; + const renderCountWhileRunning = renderCounts["assistant-1-after"]!; expect(renderCountWhileRunning).toBe(1); // Change isRunning to false (but same messages) @@ -1507,7 +1511,7 @@ describe("Custom Message Re-render Prevention", () => { ); - const renderCountAfterRunningChanged = renderCounts["assistant-1-after"]; + const renderCountAfterRunningChanged = renderCounts["assistant-1-after"]!; // THE KEY ASSERTION: Custom message should not re-render just because isRunning changed expect(renderCountAfterRunningChanged).toBe(renderCountWhileRunning); @@ -1566,7 +1570,7 @@ describe("Custom Message Re-render Prevention", () => { expect(screen.getByTestId("custom-assistant-1")).toBeDefined(); }); - const renderCountAfterInitial = renderCounts["assistant-1-after"]; + const renderCountAfterInitial = renderCounts["assistant-1-after"]!; expect(renderCountAfterInitial).toBe(1); // Update message content (streaming) @@ -1598,7 +1602,7 @@ describe("Custom Message Re-render Prevention", () => { ); }); - const renderCountAfterUpdate = renderCounts["assistant-1-after"]; + const renderCountAfterUpdate = renderCounts["assistant-1-after"]!; // THE KEY ASSERTION: Custom message should re-render when content changes expect(renderCountAfterUpdate).toBeGreaterThan(renderCountAfterInitial); @@ -1656,7 +1660,7 @@ describe("Custom Message Re-render Prevention", () => { expect(screen.getByTestId("custom-role-msg-1").textContent).toBe("user"); }); - const renderCountAfterInitial = renderCounts["msg-1-after"]; + const renderCountAfterInitial = renderCounts["msg-1-after"]!; expect(renderCountAfterInitial).toBe(1); // Change message role (unusual but possible) @@ -1686,9 +1690,677 @@ describe("Custom Message Re-render Prevention", () => { expect(screen.getByTestId("custom-role-msg-1").textContent).toBe("assistant"); }); - const renderCountAfterRoleChange = renderCounts["msg-1-after"]; + const renderCountAfterRoleChange = renderCounts["msg-1-after"]!; // THE KEY ASSERTION: Custom message should re-render when role changes expect(renderCountAfterRoleChange).toBeGreaterThan(renderCountAfterInitial); }); }); + +describe("Input Component Re-render Prevention", () => { + it("should not re-render the input component when messages stream in", async () => { + let inputRenderCount = 0; + + // Custom input component that tracks renders + const TrackedInput: React.FC = (props) => { + inputRenderCount++; + return ( +
+ {inputRenderCount} + +
+ ); + }; + + // Use a stable callback reference to properly test memoization + const stableOnSubmit = () => {}; + + const initialMessages: Message[] = [ + { + id: "msg-1", + role: "assistant", + content: "Hello!", + } as AssistantMessage, + ]; + + const { rerender } = render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("tracked-input")).toBeDefined(); + }); + + const renderCountAfterInitial = inputRenderCount; + expect(renderCountAfterInitial).toBe(1); + + // Stream a new message (add more content) + const updatedMessages: Message[] = [ + ...initialMessages, + { + id: "msg-2", + role: "assistant", + content: "How can I help?", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + // Continue streaming + const moreMessages: Message[] = [ + ...initialMessages, + { + id: "msg-2", + role: "assistant", + content: "How can I help you today?", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + // Even more streaming + const evenMoreMessages: Message[] = [ + ...initialMessages, + { + id: "msg-2", + role: "assistant", + content: "How can I help you today? I'm here to assist.", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + const renderCountAfterStreaming = inputRenderCount; + + // THE KEY ASSERTION: Input should NOT re-render when messages change + // (since inputProps haven't changed) + expect(renderCountAfterStreaming).toBe(renderCountAfterInitial); + }); + + it("should re-render a replaced input component when its internal state changes", async () => { + let externalRenderCount = 0; + + // Custom input with internal state - uses useState to track clicks + const InputWithInternalState: React.FC = (props) => { + const [clickCount, setClickCount] = useState(0); + externalRenderCount++; + + return ( +
+ {externalRenderCount} + {clickCount} + + +
+ ); + }; + + const messages: Message[] = [ + { + id: "msg-1", + role: "assistant", + content: "Hello!", + } as AssistantMessage, + ]; + + render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("stateful-input")).toBeDefined(); + }); + + // Initial state + expect(screen.getByTestId("click-count").textContent).toBe("0"); + const initialExternalRenderCount = externalRenderCount; + expect(initialExternalRenderCount).toBe(1); + + // Click the button to trigger internal state change + const incrementButton = screen.getByTestId("increment-button"); + fireEvent.click(incrementButton); + + // THE KEY ASSERTION: Internal state changes SHOULD cause re-render + await waitFor(() => { + expect(screen.getByTestId("click-count").textContent).toBe("1"); + }); + + // Verify the component actually re-rendered (not just DOM updated) + expect(externalRenderCount).toBe(2); + + // Click again to confirm consistent behavior + fireEvent.click(incrementButton); + + await waitFor(() => { + expect(screen.getByTestId("click-count").textContent).toBe("2"); + }); + + expect(externalRenderCount).toBe(3); + }); + + it("should re-render the input component when its props change", async () => { + let inputRenderCount = 0; + const capturedModes: string[] = []; + + const TrackedInput: React.FC = (props) => { + inputRenderCount++; + capturedModes.push(props.mode || "default"); + return ( +
+ {props.mode} + +
+ ); + }; + + const messages: Message[] = [ + { + id: "msg-1", + role: "assistant", + content: "Hello!", + } as AssistantMessage, + ]; + + const { rerender } = render( + + + {}, mode: "input" }} + /> + + + ); + + await waitFor(() => { + expect(screen.getByTestId("tracked-input")).toBeDefined(); + }); + + const renderCountAfterInitial = inputRenderCount; + expect(renderCountAfterInitial).toBe(1); + + // Change the mode prop + rerender( + + + {}, mode: "processing" }} + /> + + + ); + + await waitFor(() => { + expect(screen.getByTestId("input-mode").textContent).toBe("processing"); + }); + + const renderCountAfterModeChange = inputRenderCount; + + // THE KEY ASSERTION: Input SHOULD re-render when its props change + expect(renderCountAfterModeChange).toBeGreaterThan(renderCountAfterInitial); + expect(capturedModes).toContain("input"); + expect(capturedModes).toContain("processing"); + }); +}); + +describe("Suggestion View Re-render Prevention", () => { + it("should re-render a suggestion when its loading state changes", async () => { + const suggestionRenderCounts: Record = {}; + + const TrackedSuggestionPill: React.FC<{ + children: React.ReactNode; + isLoading?: boolean; + onClick?: () => void; + }> = ({ children, isLoading, onClick }) => { + const title = String(children); + suggestionRenderCounts[title] = (suggestionRenderCounts[title] || 0) + 1; + return ( + + ); + }; + + const suggestions = [ + { title: "Tell me a joke", message: "Tell me a joke", isLoading: false }, + { title: "What's the weather?", message: "What's the weather?", isLoading: false }, + ]; + + const { rerender } = render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("suggestion-loading-Tell me a joke").textContent).toBe("ready"); + }); + + const initialRenderCount = suggestionRenderCounts["Tell me a joke"]!; + + // Set first suggestion to loading + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("suggestion-loading-Tell me a joke").textContent).toBe("loading"); + }); + + // THE KEY ASSERTION: Suggestion SHOULD re-render when loading state changes + expect(suggestionRenderCounts["Tell me a joke"]).toBeGreaterThan(initialRenderCount); + }); +}); + +describe("Markdown Renderer Re-render Prevention", () => { + it("should not re-render markdown when other messages change", async () => { + const markdownRenderCounts: Record = {}; + + const TrackedMarkdownRenderer: React.FC<{ + content: string; + }> = ({ content }) => { + markdownRenderCounts[content] = (markdownRenderCounts[content] || 0) + 1; + return ( +
+ {content} + + {markdownRenderCounts[content]} + +
+ ); + }; + + const TrackedAssistantMessage: React.FC<{ + message: AssistantMessage; + messages?: Message[]; + isRunning?: boolean; + }> = ({ message, messages, isRunning }) => { + return ( + + ); + }; + + const initialMessages: Message[] = [ + { + id: "msg-1", + role: "assistant", + content: "Hello! How can I help?", + } as AssistantMessage, + ]; + + const { rerender } = render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("markdown-Hello! How can I hel")).toBeDefined(); + }); + + const initialRenderCount = markdownRenderCounts["Hello! How can I help?"]!; + + // Add a new message (simulating streaming) + const messagesWithSecond: Message[] = [ + ...initialMessages, + { + id: "msg-2", + role: "assistant", + content: "Let me help you with", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("markdown-Let me help you with")).toBeDefined(); + }); + + // THE KEY ASSERTION: First message's markdown should NOT re-render + expect(markdownRenderCounts["Hello! How can I help?"]).toBe(initialRenderCount); + }); + + it("should re-render markdown when its content changes", async () => { + const markdownRenderCounts: Record = {}; + const capturedContent: string[] = []; + + const TrackedMarkdownRenderer: React.FC<{ + content: string; + }> = ({ content }) => { + markdownRenderCounts[content] = (markdownRenderCounts[content] || 0) + 1; + capturedContent.push(content); + return ( +
+ {content} +
+ ); + }; + + const TrackedAssistantMessage: React.FC<{ + message: AssistantMessage; + messages?: Message[]; + isRunning?: boolean; + }> = ({ message, messages, isRunning }) => { + return ( + + ); + }; + + const initialMessages: Message[] = [ + { + id: "msg-1", + role: "assistant", + content: "Hello", + } as AssistantMessage, + ]; + + const { rerender } = render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("markdown-content").textContent).toBe("Hello"); + }); + + // Stream more content + const messagesWithMoreContent: Message[] = [ + { + id: "msg-1", + role: "assistant", + content: "Hello! How are you today?", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("markdown-content").textContent).toBe("Hello! How are you today?"); + }); + + // THE KEY ASSERTION: Markdown SHOULD re-render when content changes + expect(capturedContent).toContain("Hello"); + expect(capturedContent).toContain("Hello! How are you today?"); + }); +}); + +describe("Copy Button Re-render Prevention", () => { + it("should not re-render copy button when a new message is added", async () => { + let copyButtonRenderCount = 0; + + const TrackedCopyButton: React.FC<{ + onClick?: () => void; + }> = ({ onClick }) => { + copyButtonRenderCount++; + return ( + + ); + }; + + const TrackedAssistantMessage: React.FC<{ + message: AssistantMessage; + messages?: Message[]; + isRunning?: boolean; + }> = ({ message, messages, isRunning }) => { + return ( + + ); + }; + + // Start with a completed message (isRunning=false so toolbar shows) + const initialMessages: Message[] = [ + { + id: "msg-1", + role: "assistant", + content: "Hello! First message here.", + } as AssistantMessage, + ]; + + const { rerender } = render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("copy-button")).toBeDefined(); + }); + + const initialRenderCount = copyButtonRenderCount; + + // Add a second message - the first message's copy button should NOT re-render + const messagesWithSecond: Message[] = [ + ...initialMessages, + { + id: "msg-2", + role: "assistant", + content: "Second message here.", + } as AssistantMessage, + ]; + + rerender( + + + + + + ); + + // Wait for second message to render + await waitFor(() => { + expect(screen.getAllByTestId("copy-button").length).toBe(2); + }); + + // THE KEY ASSERTION: First message's copy button should NOT re-render when second message is added + // We check that the total render count is 2 (one for each message), not 3 (which would mean first re-rendered) + expect(copyButtonRenderCount).toBe(2); + }); + + it("should re-render copy button when its onClick handler changes", async () => { + let copyButtonRenderCount = 0; + + const TrackedCopyButton: React.FC<{ + onClick?: () => void; + }> = ({ onClick }) => { + copyButtonRenderCount++; + return ( + + ); + }; + + // First render with one message (isRunning=false so toolbar shows) + const message1: AssistantMessage = { + id: "msg-1", + role: "assistant", + content: "First message", + }; + + const { rerender } = render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("copy-button")).toBeDefined(); + }); + + const initialRenderCount = copyButtonRenderCount; + + // Re-render with a completely different message (different ID = different onClick) + const message2: AssistantMessage = { + id: "msg-2", + role: "assistant", + content: "Second message", + }; + + rerender( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("copy-button")).toBeDefined(); + }); + + // THE KEY ASSERTION: Copy button SHOULD re-render when the message changes + // because the onClick handler needs to reference the new message content + expect(copyButtonRenderCount).toBeGreaterThan(initialRenderCount); + }); +}); diff --git a/packages/react/src/lib/slots.tsx b/packages/react/src/lib/slots.tsx index 7556af4..5534d9a 100644 --- a/packages/react/src/lib/slots.tsx +++ b/packages/react/src/lib/slots.tsx @@ -1,17 +1,27 @@ import React from "react"; -// /** Utility: Create a component type with specific props omitted */ -// export type OmitSlotProps< -// C extends React.ComponentType, -// K extends keyof React.ComponentProps, -// > = React.ComponentType, K>>; - /** Existing union (unchanged) */ export type SlotValue> = | C | string | Partial>; +/** + * Shallow equality comparison for objects. + */ +export function shallowEqual>(obj1: T, obj2: T): boolean { + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + + if (keys1.length !== keys2.length) return false; + + for (const key of keys1) { + if (obj1[key] !== obj2[key]) return false; + } + + return true; +} + /** Utility: concrete React elements for every slot */ type SlotElements = { [K in keyof S]: React.ReactElement }; @@ -25,31 +35,75 @@ export type WithSlots< children?: (props: SlotElements & Rest) => React.ReactNode; } & Omit; -export function renderSlot< - C extends React.ComponentType, - P = React.ComponentProps, ->( - slot: SlotValue | undefined, - DefaultComponent: C, - props: P +/** + * Internal function to render a slot value as a React element (non-memoized). + */ +function renderSlotElement( + slot: SlotValue> | undefined, + DefaultComponent: React.ComponentType, + props: Record ): React.ReactElement { if (typeof slot === "string") { return React.createElement(DefaultComponent, { - ...(props as P), + ...props, className: slot, }); } if (typeof slot === "function") { - const Comp = slot as C; - return React.createElement(Comp, props as P); + return React.createElement(slot as React.ComponentType, props); } if (slot && typeof slot === "object" && !React.isValidElement(slot)) { return React.createElement(DefaultComponent, { - ...(props as P), + ...props, ...slot, }); } - return React.createElement(DefaultComponent, props as P); + return React.createElement(DefaultComponent, props); +} + +/** + * Internal memoized wrapper component for renderSlot. + * Uses forwardRef to support ref forwarding. + */ +const MemoizedSlotWrapper = React.memo( + React.forwardRef(function MemoizedSlotWrapper(props, ref) { + const { $slot, $component, ...rest } = props; + const propsWithRef: Record = ref !== null ? { ...rest, ref } : rest; + return renderSlotElement($slot, $component, propsWithRef); + }), + (prev: any, next: any) => { + // Compare slot and component references + if (prev.$slot !== next.$slot) return false; + if (prev.$component !== next.$component) return false; + + // Shallow compare remaining props (ref is handled separately by React) + const { $slot: _ps, $component: _pc, ...prevRest } = prev; + const { $slot: _ns, $component: _nc, ...nextRest } = next; + return shallowEqual( + prevRest as Record, + nextRest as Record + ); + } +); + +/** + * Renders a slot value as a memoized React element. + * Automatically prevents unnecessary re-renders using shallow prop comparison. + * Supports ref forwarding. + * + * @example + * renderSlot(customInput, CopilotChatInput, { onSubmit: handleSubmit }) + */ +export function renderSlot, P = React.ComponentProps>( + slot: SlotValue | undefined, + DefaultComponent: C, + props: P +): React.ReactElement { + return React.createElement(MemoizedSlotWrapper, { + ...props, + $slot: slot, + $component: DefaultComponent, + } as any); }