diff --git a/apps/client/src/features/ai-chat/components/message-item-memo.test.tsx b/apps/client/src/features/ai-chat/components/message-item-memo.test.tsx index 06c0c5fb..61894ad7 100644 --- a/apps/client/src/features/ai-chat/components/message-item-memo.test.tsx +++ b/apps/client/src/features/ai-chat/components/message-item-memo.test.tsx @@ -26,16 +26,20 @@ vi.mock("@/features/ai-chat/utils/markdown.ts", async () => { }); import MessageItem from "./message-item"; +import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts"; // matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts. const msg = (parts: UIMessage["parts"]): UIMessage => ({ id: "m1", role: "assistant", parts }) as UIMessage; +// Mirror MessageList: snapshot the signature at (parent) render time and pass it +// as the memo key. The signature must NOT be recomputed inside the memo from the +// live (mutable) message — see message-item.tsx. const renderRow = (message: UIMessage) => render( - + , ); @@ -67,7 +71,7 @@ describe("MessageItem markdown memoization", () => { ]); rerender( - + , ); @@ -78,4 +82,35 @@ describe("MessageItem markdown memoization", () => { expect(callsFor("beta")).toBe(1); expect(callsFor("gamm")).toBe(1); }); + + // REGRESSION (empty-render bug): the AI SDK streams a turn by MUTATING the same + // `parts` IN PLACE and reusing the message object. A row that mounted empty + // (reasoning-first providers render nothing at first) must still stream its text + // in once the parent hands down a fresh signature snapshot. Before the fix the + // memo recomputed the signature from the (mutated) message — identical on both + // sides — and froze the row at its empty render, so the answer never appeared. + it("streams text in after the row mounted empty and parts mutated in place", () => { + renderChatMarkdownSpy.mockClear(); + // Reuse ONE message object across renders (as the SDK does). + const message = msg([{ type: "text", text: "" }]); + const { rerender, queryByText } = render( + + + , + ); + // Empty text part: nothing visible rendered yet. + expect(queryByText("streamed answer")).toBeNull(); + + // SDK delta: mutate the SAME part in place, then re-render with a NEW snapshot. + (message.parts[0] as { text: string }).text = "streamed answer"; + rerender( + + + , + ); + + // The grown text now renders (the memo did NOT freeze the empty mount). + expect(callsFor("streamed answer")).toBe(1); + expect(queryByText("streamed answer")).not.toBeNull(); + }); }); diff --git a/apps/client/src/features/ai-chat/components/message-item.test.ts b/apps/client/src/features/ai-chat/components/message-item.test.ts index dfed46f4..b5b6d96a 100644 --- a/apps/client/src/features/ai-chat/components/message-item.test.ts +++ b/apps/client/src/features/ai-chat/components/message-item.test.ts @@ -10,21 +10,28 @@ vi.mock("react-i18next", () => ({ })); import { arePropsEqual } from "./message-item"; +import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts"; /** * Tests for `arePropsEqual`, the `React.memo` comparator for MessageItem. It must * return false on any visible prop/content change (so the row re-renders) and - * true when nothing visible changed (so a finalized row is skipped). A FIXED - * message id is used so a content-identical clone yields an equal signature. + * true when nothing visible changed (so a finalized row is skipped). The memo key + * is the `signature` PROP — an immutable snapshot the PARENT (MessageList) takes + * per render via `messageSignature(message)`. A FIXED message id is used so a + * content-identical clone yields an equal signature. */ const msg = (parts: UIMessage["parts"]): UIMessage => ({ id: "m1", role: "assistant", parts }) as UIMessage; +// Build the props the parent would pass, INCLUDING the snapshot signature it +// computes during its own render (the load-bearing part — see message-item.tsx: +// the signature must never be recomputed inside arePropsEqual). const props = ( message: UIMessage, over: Record = {}, ) => ({ message, + signature: messageSignature(message), showCitations: true, neutralizeInternalLinks: false, assistantName: "AI", @@ -53,7 +60,7 @@ describe("arePropsEqual", () => { ).toBe(false); }); - it("returns true on the identity fast path (same message object, equal props)", () => { + it("returns true for equal snapshot + equal props (finalized row skipped)", () => { const m = msg([{ type: "text", text: "answer" }]); expect(arePropsEqual(props(m), props(m))).toBe(true); }); @@ -70,4 +77,36 @@ describe("arePropsEqual", () => { const b = msg([{ type: "text", text: "answer grown" }]); expect(arePropsEqual(props(a), props(b))).toBe(false); }); + + // REGRESSION (empty-render bug): the AI SDK streams deltas by mutating the SAME + // `parts` in place and handing back a message wrapper that SHARES them. So the + // PREVIOUS and NEXT props can carry the SAME (mutated) message object, and + // recomputing `messageSignature(message)` inside the comparator would read + // identical (latest) content on BOTH sides → always "equal" → the memo skips + // every streamed update and the assistant row freezes at its initial empty + // render. The comparator MUST instead trust the immutable `signature` SNAPSHOT + // the parent captured at each render. This fails against the old implementation + // (a `prev.message === next.message` fast path + a signature recomputed from the + // live objects). + it("re-renders when parts were mutated in place but the snapshot changed", () => { + const message = msg([{ type: "text", text: "" }]); // empty (renders null) + const prevSig = messageSignature(message); // snapshot BEFORE the delta + // SDK streams a delta by mutating the shared part IN PLACE: + (message.parts[0] as { text: string }).text = "hello world"; + const nextSig = messageSignature(message); // snapshot AFTER the delta + expect(prevSig).not.toBe(nextSig); + // Same object reference on both sides (the SDK reuses it), differing snapshots. + const base = { + message, + showCitations: true, + neutralizeInternalLinks: false, + assistantName: "AI", + }; + expect( + arePropsEqual( + { ...base, signature: prevSig }, + { ...base, signature: nextSig }, + ), + ).toBe(false); + }); }); diff --git a/apps/client/src/features/ai-chat/components/message-item.tsx b/apps/client/src/features/ai-chat/components/message-item.tsx index 6bd4374d..46c25af2 100644 --- a/apps/client/src/features/ai-chat/components/message-item.tsx +++ b/apps/client/src/features/ai-chat/components/message-item.tsx @@ -11,12 +11,30 @@ import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/mess import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts"; import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts"; import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts"; -import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts"; import { describeChatError } from "@/features/ai-chat/utils/error-message.ts"; import classes from "@/features/ai-chat/components/ai-chat.module.css"; interface MessageItemProps { message: UIMessage; + /** + * Immutable content signature for `message`, computed by the PARENT + * (MessageList) during its render via `messageSignature(message)`. This is the + * memo key (see `arePropsEqual`): it MUST be a snapshot captured at render time, + * NOT recomputed from `message` inside `arePropsEqual`. + * + * WHY (load-bearing): the AI SDK streams deltas by mutating the SAME `parts` + * array/objects in place and handing back a message wrapper that SHARES those + * mutated parts. So inside `arePropsEqual`, `prev.message` and `next.message` + * both reflect the CURRENT (latest) parts — `messageSignature(prev.message) === + * messageSignature(next.message)` is therefore ALWAYS true, the memo skips every + * post-mount render, and the assistant row freezes at its initial empty (null) + * render — i.e. the streamed answer + tool cards never appear (reasoning-first + * providers start empty, so NOTHING shows). Snapshotting the signature into this + * immutable string prop in the parent fixes that: `prev.signature` holds the + * value from the previous render (old content) and `next.signature` the new + * content, so they differ as the turn streams in and the row re-renders. + */ + signature: string; /** * Forwarded to ToolCallCard: whether tool cards render page citation links. * Defaults to true (internal chat). The public share passes false. @@ -88,6 +106,8 @@ function MessageItem({ neutralizeInternalLinks = false, assistantName, }: MessageItemProps) { + // `signature` is intentionally not read in the body — it exists solely as the + // memo key (see arePropsEqual). The render reads `message` directly. const { t } = useTranslation(); const isUser = message.role === "user"; @@ -203,24 +223,30 @@ function MessageItem({ } /** Skip re-rendering a message whose visible content is unchanged. The streaming - * TAIL message gets a fresh object whose signature changes each delta, so it - * still re-renders and streams in; every FINALIZED message is skipped, turning a - * per-token whole-transcript re-render into a tail-only one. */ + * TAIL message gets a fresh `signature` snapshot each delta (computed by the + * parent), so it still re-renders and streams in; every FINALIZED message keeps + * the same signature and is skipped, turning a per-token whole-transcript + * re-render into a tail-only one. + * + * CRITICAL: compare the `signature` PROP (an immutable snapshot the parent took + * at its own render), NEVER `messageSignature(prev.message)` vs + * `messageSignature(next.message)`. The AI SDK mutates the shared `parts` in + * place, so both `prev.message` and `next.message` reflect the latest content + * here — recomputing the signature from them yields equal strings every time and + * freezes the row at its initial empty render (the bug this guards against). See + * the `signature` prop doc. Likewise there is NO `prev.message === next.message` + * fast path: same-reference-but-mutated must still re-render when the snapshot + * signature changed. */ export function arePropsEqual( prev: MessageItemProps, next: MessageItemProps, ): boolean { - if ( - prev.showCitations !== next.showCitations || - prev.neutralizeInternalLinks !== next.neutralizeInternalLinks || - prev.assistantName !== next.assistantName - ) { - return false; - } - // Fast path: identical message object (finalized rows keep their identity - // across deltas) — skip without building signatures. - if (prev.message === next.message) return true; - return messageSignature(prev.message) === messageSignature(next.message); + return ( + prev.signature === next.signature && + prev.showCitations === next.showCitations && + prev.neutralizeInternalLinks === next.neutralizeInternalLinks && + prev.assistantName === next.assistantName + ); } export default memo(MessageItem, arePropsEqual); diff --git a/apps/client/src/features/ai-chat/components/message-list.tsx b/apps/client/src/features/ai-chat/components/message-list.tsx index fda2a87f..2cb2183c 100644 --- a/apps/client/src/features/ai-chat/components/message-list.tsx +++ b/apps/client/src/features/ai-chat/components/message-list.tsx @@ -6,6 +6,7 @@ import MessageItem from "@/features/ai-chat/components/message-item.tsx"; import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx"; import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx"; import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts"; +import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts"; import classes from "@/features/ai-chat/components/ai-chat.module.css"; interface MessageListProps { @@ -196,9 +197,16 @@ export default function MessageList({ {messages.map((message) => ( + // `signature` is snapshotted HERE (parent render) into an immutable + // string and handed to MessageItem as its memo key. It must NOT be + // recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the + // shared `parts` in place, so prev/next message objects both read the + // latest content there and the memo would skip every streamed update + // (freezing the row at its empty render). See message-item.tsx.