Skip to content

Commit 848205f

Browse files
marthakellyclaude
andcommitted
perf(chat): memoize dedup map, O(1) tool-result comparator, TanStack Virtual, perf page & tests
Addresses review findings from PR CopilotKit#3611: - toolResultMap: pre-compute id→content Map via useMemo so MemoizedAssistantMessage comparator does O(k) lookups instead of O(n) linear scans - resolveSlotComponent<T>() helper eliminates repeated slot-resolution boilerplate - NODE_ENV gate on __perfMsgCount / __perfScrollEl (no production window pollution) - hasScrolledToBottomRef → dep-based [shouldVirtualize, firstMessageId] in useLayoutEffect - Merged duplicate virtual/flat render return paths; removed dead null-guards in comparator - AssistantMessage type import + cast in fingerprint (CopilotChat.tsx) - perf page: private → protected, lastScrollTop=NaN, STABLE_POLLS_REQUIRED constant, agent as any with version-mismatch comment (ag-ui 0.0.46 vs 0.0.48) - snapshot test: act+tick flush instead of sentinel message (Observable subscription ends after RUN_FINISHED so new events are dropped) - .bench.tsx extension comment: not picked up by vitest CI glob Pre-existing test failures in use-human-in-the-loop.e2e.test.tsx (2 tests) and @copilotkit/sqlite-runner + @copilotkit/runtime are on main; unrelated to this PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 453b156 commit 848205f

6 files changed

Lines changed: 140 additions & 136 deletions

File tree

examples/v2/react/demo/src/app/perf/page.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ const TEXTS = [
3131
// with a delay between chunks to simulate live streaming.
3232
// ---------------------------------------------------------------------------
3333
class HistoryAgent extends AbstractAgent {
34-
private messageCount = 0;
35-
private streamDelayMs = 0;
34+
protected messageCount = 0;
35+
protected streamDelayMs = 0;
3636

3737
configure(count: number, streamDelayMs = 0) {
3838
this.messageCount = count;
@@ -259,10 +259,15 @@ export default function PerfPage() {
259259
// reaches `count`) AND for the scroll position to
260260
// stabilise (3 consecutive identical scrollTop readings),
261261
// which means StickToBottom's spring animation has settled.
262+
// How many consecutive 50 ms polls with identical scrollTop = animation settled.
263+
const STABLE_POLLS_REQUIRED = 3;
264+
262265
let phase: "emit" | "render" = "emit";
263266
let emitMs = 0;
264267
let stableCount = 0;
265-
let lastScrollTop = -1;
268+
// NaN ensures we don't start counting stability until we observe at least
269+
// one real scrollTop value (avoids false-positive when scrollEl is null).
270+
let lastScrollTop = NaN;
266271

267272
const poll = setInterval(() => {
268273
if (phase === "emit") {
@@ -289,7 +294,7 @@ export default function PerfPage() {
289294
lastScrollTop = currentScrollTop;
290295
}
291296

292-
if (stableCount >= 3) {
297+
if (stableCount >= STABLE_POLLS_REQUIRED) {
293298
clearInterval(poll);
294299
const start =
295300
(window as any).__perfConnectStart ?? startTimeRef.current;
@@ -391,6 +396,7 @@ export default function PerfPage() {
391396
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
392397
<CopilotKitProvider
393398
key={key}
399+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
394400
agents__unsafe_dev_only={{ default: agent as any }}
395401
>
396402
<RenderCounter

packages/react-core/src/v2/components/chat/CopilotChat.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from "../../providers/CopilotKitProvider";
2222
import { InlineFeatureWarning } from "../../components/license-warning-banner";
2323
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
24+
import { type AssistantMessage } from "@ag-ui/core";
2425
import { renderSlot, SlotValue } from "../../lib/slots";
2526
import {
2627
transcribeAudio,
@@ -368,22 +369,27 @@ export function CopilotChat({
368369
: transcribeMode;
369370

370371
// Memoize messages array - only create new reference when content actually changes
371-
// (agent.messages is mutated in place, so we need a new reference for React to detect changes)
372-
372+
// (agent.messages is mutated in place, so we need a new reference for React to detect changes).
373+
//
374+
// Lightweight fingerprint instead of JSON.stringify(agent.messages):
375+
// - length + last-id catch new messages (O(1))
376+
// - last content covers text streaming (serializes only in-flight message)
377+
// - last tool-call args cover TOOL_CALL_CHUNK streaming
378+
//
379+
// Known limitation: same-length in-place mutations to non-last messages
380+
// (e.g. two tool results arriving in the same tick) are not detected here.
381+
// The MemoizedAssistantMessage comparator handles those cases via toolResultMap.
373382
const messages = useMemo(
374383
() => [...agent.messages],
375-
// Lightweight fingerprint instead of JSON.stringify(agent.messages):
376-
// - length + last-id catch new messages (O(1))
377-
// - last content covers text streaming (serializes only in-flight message)
378-
// - last tool-call args cover TOOL_CALL_CHUNK streaming (arguments change
379-
// on the same message without touching content or length)
380384
// eslint-disable-next-line react-hooks/exhaustive-deps
381385
[
382386
agent.messages.length,
383387
agent.messages.at(-1)?.id ?? "",
384388
JSON.stringify(agent.messages.at(-1)?.content),
385389
JSON.stringify(
386-
agent.messages.at(-1)?.toolCalls?.at(-1)?.function?.arguments,
390+
(agent.messages.at(-1) as AssistantMessage | undefined)?.toolCalls?.at(
391+
-1,
392+
)?.function?.arguments,
387393
),
388394
],
389395
);

0 commit comments

Comments
 (0)