Resolve the PR #182 code-review (Request changes) on top of the already-merged develop (the merge commit preserves both the markdown useMemo and the collapseBlankLines fix in reasoning-block.tsx). - Extract messageSignature from message-item.tsx into utils/message-signature.ts (matches the feature's "pure UIMessage helper + colocated test" convention) and export arePropsEqual so the memo seam is unit-testable. No logic change. - Add utils/message-signature.test.ts covering every change signal (text grows, part appended, state flip, output appears, errorText appears, usage.reasoningTokens arriving on finish-step, metadata error/finishReason) plus the negative content-identical-clone case. - Add components/message-item.test.ts for arePropsEqual (each prop diff -> false, identity fast-path -> true, same-content-different-object -> true, changed -> false). - Add components/message-item-memo.test.tsx: render-level proof that finalized text parts are not re-parsed when only a tail part grows (MarkdownPart memo). - CHANGELOG: add the user-facing 100% CPU freeze fix under [Unreleased] / Fixed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
82 lines
3.0 KiB
TypeScript
82 lines
3.0 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { render } from "@testing-library/react";
|
|
import { MantineProvider } from "@mantine/core";
|
|
import type { UIMessage } from "@ai-sdk/react";
|
|
|
|
// Stub react-i18next (the component reads `useTranslation`). Mirrors the stub in
|
|
// reasoning-block.test.tsx.
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({ t: (key: string) => key }),
|
|
}));
|
|
|
|
// Spy on `renderChatMarkdown` so we can count parse calls per text. We keep every
|
|
// OTHER named export of markdown.ts intact via `importActual`, and override only
|
|
// `renderChatMarkdown` with a `vi.fn()` that returns simple HTML so the component
|
|
// still renders. This is the seam that proves the MarkdownPart memo works: a
|
|
// finalized text part must NOT be re-parsed on a later streamed delta.
|
|
// `vi.hoisted` so the spy exists when the hoisted `vi.mock` factory runs.
|
|
const { renderChatMarkdownSpy } = vi.hoisted(() => ({
|
|
renderChatMarkdownSpy: vi.fn((text: string) => `<p>${text}</p>`),
|
|
}));
|
|
vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import("@/features/ai-chat/utils/markdown.ts")
|
|
>("@/features/ai-chat/utils/markdown.ts");
|
|
return { ...actual, renderChatMarkdown: renderChatMarkdownSpy };
|
|
});
|
|
|
|
import MessageItem from "./message-item";
|
|
|
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
|
|
|
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
|
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
|
|
|
const renderRow = (message: UIMessage) =>
|
|
render(
|
|
<MantineProvider>
|
|
<MessageItem message={message} />
|
|
</MantineProvider>,
|
|
);
|
|
|
|
/** Count how many spy calls parsed exactly `text` (filtering by the first arg). */
|
|
const callsFor = (text: string) =>
|
|
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === text).length;
|
|
|
|
describe("MessageItem markdown memoization", () => {
|
|
it("does not re-parse finalized text parts when only a tail part grows", () => {
|
|
renderChatMarkdownSpy.mockClear();
|
|
|
|
// Two finalized text parts.
|
|
const first = msg([
|
|
{ type: "text", text: "alpha" },
|
|
{ type: "text", text: "beta" },
|
|
]);
|
|
const { rerender } = renderRow(first);
|
|
|
|
// Both finalized parts parsed exactly once on the initial render.
|
|
expect(callsFor("alpha")).toBe(1);
|
|
expect(callsFor("beta")).toBe(1);
|
|
|
|
// A streamed delta: a NEW message object where only a third tail part grows;
|
|
// the first two parts' text is byte-identical.
|
|
const next = msg([
|
|
{ type: "text", text: "alpha" },
|
|
{ type: "text", text: "beta" },
|
|
{ type: "text", text: "gamm" },
|
|
]);
|
|
rerender(
|
|
<MantineProvider>
|
|
<MessageItem message={next} />
|
|
</MantineProvider>,
|
|
);
|
|
|
|
// The finalized parts hit the MarkdownPart memo: still parsed at most once
|
|
// each across BOTH renders (the resilient invariant). The only new parse is
|
|
// for the changed/added tail part.
|
|
expect(callsFor("alpha")).toBe(1);
|
|
expect(callsFor("beta")).toBe(1);
|
|
expect(callsFor("gamm")).toBe(1);
|
|
});
|
|
});
|