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.