perf(frontend): cache settled copy-data derivation across streaming chunks (#5095)

* perf(frontend): cache settled copy-data derivation across streaming chunks

Every SSE values chunk re-renders MessageList, and the re-render re-derived
copy/toolbar text for every settled row: getAssistantTurnCopyData re-ran the
O(turn bytes) content extraction per settled group, and MessageListItem's
toolbar recomputed getMessageCopyData per message. Settled group arrays keep
their identity across chunks (deriveStableMessageGroups), so both derivations
now cache on that stable reference: a WeakMap keyed on the messages array for
turn copy data, and a useMemo on message identity for the toolbar copy text.

Fixes #5094

* fix(frontend): gate row copy-data memo and correct cache win claim

Address review: derive one memoized copy value only when
isHuman || (!isLoading && showCopyButton) and reuse it for both editing
and the toolbar, so settled assistant rows (whose toolbar never renders)
skip the derivation and human rows derive once, not twice; correct the
assistantTurnCopyDataCache comment — the regex/trim split is already
cached per message, the cache's win is the traversal/allocations for
string turns and the uncached O(bytes) map/join/trim for array-content
turns (benchmarked: 5.1x / 17.2x per settled history sweep).

* style(frontend): expand single-line messages array for Prettier
This commit is contained in:
hataa 2026-08-30 14:28:43 +08:00 committed by GitHub
parent 22b0456e45
commit 56454af931
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 205 additions and 7 deletions

View File

@ -165,10 +165,19 @@ export function MessageListItem({
}) {
const { t } = useI18n();
const isHuman = message.type === "human";
const editableText = useMemo(
() => (isHuman ? (getMessageCopyData(message) ?? "") : ""),
[isHuman, message],
// One derivation serves both editing and the toolbar, and only runs when
// either consumer can use it: assistant rows never render this toolbar
// (the sole call site passes showCopyButton only for non-assistant rows)
// and the toolbar stays unrendered while loading — matching the guard the
// pre-memo call sat behind instead of deriving for every settled row.
const copyData = useMemo(
() =>
isHuman || (!isLoading && showCopyButton)
? (getMessageCopyData(message) ?? "")
: "",
[isHuman, isLoading, showCopyButton, message],
);
const editableText = isHuman ? copyData : "";
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState("");
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
@ -239,7 +248,7 @@ export function MessageListItem({
)}
>
<div className="pointer-events-auto flex gap-1">
<CopyButton clipboardData={getMessageCopyData(message)} />
<CopyButton clipboardData={copyData} />
{canEdit && isHuman && onEditAndRegenerate && !isEditing && (
<Tooltip content={t.common.editAndRerun}>
<Button

View File

@ -490,6 +490,18 @@ export function isAssistantMessageGroupStreaming(
});
}
// `deriveStableMessageGroups` preserves the identity of a settled group's
// `messages` array across streaming chunks, so caching on that array lets the
// message list re-render per chunk without re-running the derivation for
// every settled turn (#5094). For string-content turns the saved work is the
// reverse/filter/map traversal and its allocations — the regex/trim split
// itself is already cached per message by `inlineReasoningCache`; for
// array-content turns `extractContentFromMessage` has no lower-level cache,
// so this also skips its O(bytes) map/join/trim re-run. Settled group arrays
// are treated as immutable everywhere else, so the same reference always
// yields the same result.
const assistantTurnCopyDataCache = new WeakMap<Message[], string>();
export function getAssistantTurnCopyData(
messages: Message[],
{ isStreaming = false }: { isStreaming?: boolean } = {},
@ -498,7 +510,12 @@ export function getAssistantTurnCopyData(
return null;
}
return (
const cached = assistantTurnCopyDataCache.get(messages);
if (cached !== undefined) {
return cached;
}
const copyData =
[...messages]
.reverse()
.filter((message) => message.type === "ai")
@ -511,8 +528,11 @@ export function getAssistantTurnCopyData(
? content
: (extractReasoningContentFromMessage(message) ?? "");
})
.find((content) => content.length > 0) ?? null
);
.find((content) => content.length > 0) ?? null;
if (copyData !== null) {
assistantTurnCopyDataCache.set(messages, copyData);
}
return copyData;
}
export function getMessageCopyData(message: Message) {

View File

@ -0,0 +1,118 @@
import type { Message } from "@langchain/langgraph-sdk";
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { cleanup, render } from "@testing-library/react";
import { MessageListItem } from "@/components/workspace/messages/message-list-item";
import { I18nContext } from "@/core/i18n/context";
import { enUS } from "@/core/i18n/locales/en-US";
import * as messageUtils from "@/core/messages/utils";
// The unit under test is the row's copy-data memo, not message rendering.
// Stubbing the markdown pipeline also removes its first-render suspension:
// when a settled body suspends, React restarts the render attempt and
// legitimately re-runs useMemo factories, which would make call counts
// depend on microtask timing instead of the memo's own deps.
rs.mock("@/components/workspace/messages/markdown-content", () => ({
MarkdownContent: () => null,
}));
// Count calls to the copy-data derivation itself. The render path reads the
// message content for the body (markdown, reasoning, tasks), so content
// getters cannot distinguish rendering reads from copy derivation — the spy
// on the derived entry point can.
const copyDataCalls = rs.spyOn(messageUtils, "getMessageCopyData");
function makeMessage(type: "human" | "ai"): Message {
return {
id: `${type}-1`,
type,
content: "copy me",
} as unknown as Message;
}
function withI18n(ui: React.ReactElement) {
return (
<I18nContext.Provider
value={{ locale: "en-US", setLocale: () => undefined, t: enUS }}
>
{ui}
</I18nContext.Provider>
);
}
function renderRow(
message: Message,
{
showCopyButton,
isLoading = false,
}: { showCopyButton: boolean; isLoading?: boolean },
) {
return render(
withI18n(
<MessageListItem
message={message}
threadId="thread-1"
showCopyButton={showCopyButton}
isLoading={isLoading}
/>,
),
);
}
afterEach(cleanup);
afterEach(() => {
copyDataCalls.mockClear();
});
describe("MessageListItem copy-data derivation guard", () => {
it("derives no copy data for an assistant row that never renders the toolbar", () => {
const message = makeMessage("ai");
const view = renderRow(message, { showCopyButton: false });
expect(copyDataCalls).toHaveBeenCalledTimes(0);
// The sole call site passes showCopyButton only for non-assistant rows;
// the memo must stay gated so settled assistant rows skip the derivation
// entirely (the pre-memo call sat behind the same toolbar guard).
view.rerender(
withI18n(
<MessageListItem
message={message}
threadId="thread-1"
showCopyButton={false}
isLoading={false}
/>,
),
);
expect(copyDataCalls).toHaveBeenCalledTimes(0);
});
it("derives a human row's copy data once and reuses it across re-renders", () => {
const message = makeMessage("human");
const view = renderRow(message, { showCopyButton: true });
// One memo serves both editing and the toolbar — not one derivation per
// consumer.
expect(copyDataCalls).toHaveBeenCalledTimes(1);
view.rerender(
withI18n(
<MessageListItem
message={message}
threadId="thread-1"
showCopyButton={true}
isLoading={false}
/>,
),
);
expect(copyDataCalls).toHaveBeenCalledTimes(1);
});
it("still derives for a human row while loading (editing needs it)", () => {
const message = makeMessage("human");
renderRow(message, { showCopyButton: true, isLoading: true });
expect(copyDataCalls.mock.calls.length).toBeGreaterThan(0);
});
});

View File

@ -755,6 +755,57 @@ test("falls back to reasoning for a reasoning-only assistant turn's copy data",
expect(getAssistantTurnCopyData(messages)).toBe("the actual reasoning");
});
test("settled copy data is derived once per messages array reference (#5094)", () => {
// Settled group arrays keep their identity across streaming chunks, and the
// copy button re-renders per chunk. Reading `content` through a getter
// proves the second settled call is served from the array-reference cache
// instead of re-running the O(turn bytes) extraction.
let contentReads = 0;
const message = {
id: "ai-1",
type: "ai",
get content() {
contentReads += 1;
return "Final answer";
},
} as unknown as Message;
const messages = [message];
expect(getAssistantTurnCopyData(messages)).toBe("Final answer");
const readsAfterFirstCall = contentReads;
expect(readsAfterFirstCall).toBeGreaterThan(0);
expect(getAssistantTurnCopyData(messages)).toBe("Final answer");
expect(contentReads).toBe(readsAfterFirstCall);
});
test("copy-data cache does not leak across array references", () => {
const first = [
{ id: "ai-1", type: "ai", content: "first answer" },
] as Message[];
const second = [
{ id: "ai-2", type: "ai", content: "second answer" },
] as Message[];
expect(getAssistantTurnCopyData(first)).toBe("first answer");
expect(getAssistantTurnCopyData(second)).toBe("second answer");
// The streaming short-circuit stays ahead of the cache.
expect(getAssistantTurnCopyData(second, { isStreaming: true })).toBeNull();
expect(getAssistantTurnCopyData(second)).toBe("second answer");
});
test("null copy data is not cached for a reference", () => {
// A turn with no copyable AI text must keep recomputing (and stay null)
// rather than a cached null hiding a later value — the same array can be
// re-used once messages are appended to a rebuilt group.
const messages = [
{ id: "human-1", type: "human", content: "hi" },
] as Message[];
expect(getAssistantTurnCopyData(messages)).toBeNull();
expect(getAssistantTurnCopyData(messages)).toBeNull();
});
test("marks the latest assistant message as streaming", () => {
const messages = [
{