= {};
+
+ 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 () => {
+ 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);
+ });
+
+ 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);
+ });
+});
+
+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/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]
);
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);
}