fix: react key warnings from duplicate message IDs + establish jest testing framework (#655)

* fix: resolve issue #588 - react key warnings from duplicate message IDs + establish jest testing framework

* Update the makefile and workflow with the js test

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Willem Jiang
2025-10-25 20:46:43 +08:00
committed by GitHub
parent f2be4d6af1
commit 1d71f8910e
15 changed files with 4067 additions and 91 deletions

View File

@@ -43,7 +43,7 @@ import {
useLastFeedbackMessageId,
useLastInterruptMessage,
useMessage,
useMessageIds,
useRenderableMessageIds,
useResearchMessage,
useStore,
} from "~/core/store";
@@ -63,7 +63,8 @@ export function MessageListView({
) => void;
}) {
const scrollContainerRef = useRef<ScrollContainerRef>(null);
const messageIds = useMessageIds();
// Use renderable message IDs to avoid React key warnings from duplicate or non-rendering messages
const messageIds = useRenderableMessageIds();
const interruptMessage = useLastInterruptMessage();
const waitingForFeedbackMessageId = useLastFeedbackMessageId();
const responding = useStore((state) => state.responding);

View File

@@ -46,10 +46,16 @@ export const useStore = create<{
openResearchId: null,
appendMessage(message: Message) {
set((state) => ({
messageIds: [...state.messageIds, message.id],
messages: new Map(state.messages).set(message.id, message),
}));
set((state) => {
// Prevent duplicate message IDs in the array to avoid React key warnings
const newMessageIds = state.messageIds.includes(message.id)
? state.messageIds
: [...state.messageIds, message.id];
return {
messageIds: newMessageIds,
messages: new Map(state.messages).set(message.id, message),
};
});
},
updateMessage(message: Message) {
set((state) => ({
@@ -137,32 +143,48 @@ export async function sendMessage(
try {
for await (const event of stream) {
const { type, data } = event;
messageId = data.id;
let message: Message | undefined;
// Handle tool_call_result specially: use the message that contains the tool call
if (type === "tool_call_result") {
message = findMessageByToolCallId(data.tool_call_id);
} else if (!existsMessage(messageId)) {
message = {
id: messageId,
threadId: data.thread_id,
agent: data.agent,
role: data.role,
content: "",
contentChunks: [],
reasoningContent: "",
reasoningContentChunks: [],
isStreaming: true,
interruptFeedback,
};
appendMessage(message);
if (message) {
// Use the found message's ID, not data.id
messageId = message.id;
} else {
// Shouldn't happen, but handle gracefully
if (process.env.NODE_ENV === "development") {
console.warn(`Tool call result without matching message: ${data.tool_call_id}`);
}
continue; // Skip this event
}
} else {
// For other event types, use data.id
messageId = data.id;
if (!existsMessage(messageId)) {
message = {
id: messageId,
threadId: data.thread_id,
agent: data.agent,
role: data.role,
content: "",
contentChunks: [],
reasoningContent: "",
reasoningContentChunks: [],
isStreaming: true,
interruptFeedback,
};
appendMessage(message);
}
}
message ??= getMessage(messageId);
if (message) {
message = mergeMessage(message, event);
// Collect pending messages for update, instead of updating immediately.
pendingUpdates.set(message.id, message);
scheduleUpdate();
}
}
} catch {
@@ -383,6 +405,29 @@ export function useMessageIds() {
return useStore(useShallow((state) => state.messageIds));
}
export function useRenderableMessageIds() {
return useStore(
useShallow((state) => {
// Filter to only messages that will actually render in MessageListView
// This prevents duplicate keys and React warnings when messages change state
return state.messageIds.filter((messageId) => {
const message = state.messages.get(messageId);
if (!message) return false;
// Only include messages that match MessageListItem rendering conditions
// These are the same conditions checked in MessageListItem component
return (
message.role === "user" ||
message.agent === "coordinator" ||
message.agent === "planner" ||
message.agent === "podcast" ||
state.researchIds.includes(messageId) // startOfResearch condition
);
});
}),
);
}
export function useLastInterruptMessage() {
return useStore(
useShallow((state) => {