Files
Jyotisha/frontend/tests/home-streaming-render-split.test.ts
T
Jesse_Chen 6eadb62eb7 perf(chat): coalesce stream events per frame and pace text release
Every NDJSON event used to commit its own React update and re-parse the
whole partial answer through react-markdown, so long replies grew
quadratically slower. Stream events now land in a frame buffer that
flushes at most once per animation frame, releases answer and thinking
text at a steady pace with a twelve-frame catch-up, and settles
synchronously on completion, failure and abort. Streaming markdown is
split at the last completed block so only the tail is re-parsed each
frame. Applied to both the consultation hook and the rectification chat.

BUG-473

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:02:15 +00:00

123 lines
4.7 KiB
TypeScript

import assert from "node:assert/strict";
import { createElement, createRef } from "react";
import { renderToString } from "react-dom/server";
import test from "node:test";
import {
SettledMessageList,
StreamingMessageEntry,
UnsplitChatTranscript,
type ChatTranscriptActions,
type ChatTranscriptProps,
} from "../src/components/chat-transcript.tsx";
import type { ChatMessage } from "../src/lib/chat-message-view.ts";
import { createStreamFrameBuffer, type StreamFrameScheduler } from "../src/lib/stream-frame-buffer.ts";
import { streamingChatMessageView } from "../src/lib/chat-message-view.ts";
import {
disableHomeStreamingRenderProbe,
enableHomeStreamingRenderProbe,
homeStreamingRenderProbeSnapshot,
resetHomeStreamingRenderProbe,
} from "../src/lib/home-streaming-render-probe.ts";
function propsFor(messages: readonly ChatMessage[]): Omit<ChatTranscriptProps, "loading" | "streamingText"> {
const actionsRef = createRef<ChatTranscriptActions>() as ChatTranscriptProps["actionsRef"];
actionsRef.current = {
onFeedback() {},
onCopy() {},
onRegenerate() {},
onFollowUp() {},
};
return {
messages,
sessionId: "session-test",
sessionType: "consultation",
theme: "career",
messageFeedback: {},
copiedMessageKey: null,
cancellationPending: false,
productEntrypointsDisabled: true,
actionsRef,
};
}
test("the split architecture renders settled history once while streaming tokens", () => {
const messages: ChatMessage[] = [
{ role: "user", text: "问题一" },
{ role: "assistant", text: "回答一,需要足够长才能生成追问卡片。" },
{ role: "user", text: "问题二" },
{ role: "assistant", text: "回答二,需要足够长才能生成追问卡片。" },
{ role: "user", text: "请继续说明这个月的安排。" },
];
const tokens = ["甲", "甲乙", "甲乙丙", "甲乙丙丁", "甲乙丙丁戊"];
const base = propsFor(messages);
resetHomeStreamingRenderProbe();
enableHomeStreamingRenderProbe();
renderToString(createElement(SettledMessageList, { ...base, loading: true }));
for (const streamingText of tokens) {
const streamingMessage = streamingChatMessageView(messages, true, streamingText);
assert.ok(streamingMessage);
renderToString(createElement(StreamingMessageEntry, { message: streamingMessage }));
}
const split = homeStreamingRenderProbeSnapshot();
disableHomeStreamingRenderProbe();
resetHomeStreamingRenderProbe();
enableHomeStreamingRenderProbe();
for (const streamingText of tokens) {
renderToString(createElement(UnsplitChatTranscript, { ...base, loading: true, streamingText }));
}
const unsplit = homeStreamingRenderProbeSnapshot();
disableHomeStreamingRenderProbe();
assert.equal(split.settledListRenders, 1);
// Former assertion: `streamingRowRenders === tokens.length`. That was a snapshot of the
// status quo (one commit per network token), not the goal; this test drives renders by
// hand, so the count equals the number of hand-driven renders and must never exceed it.
assert.ok(split.streamingRowRenders <= tokens.length);
assert.ok(split.streamingRowRenders >= 1);
assert.equal(unsplit.unsplitListRenders, tokens.length);
assert.ok(unsplit.settledRowRenders > split.settledRowRenders);
});
test("frame coalescing renders the streaming row once per frame, not once per token", () => {
const messages: ChatMessage[] = [{ role: "user", text: "请继续说明这个月的安排。" }];
const frames: Array<() => void> = [];
const scheduler: StreamFrameScheduler = {
requestFrame(callback) {
frames.push(callback);
return frames.length;
},
cancelFrame() { frames.length = 0; },
requestTimeout() { return 0; },
cancelTimeout() {},
hidden: () => false,
};
resetHomeStreamingRenderProbe();
enableHomeStreamingRenderProbe();
const buffer = createStreamFrameBuffer<null>({
initialMeta: null,
scheduler,
flush: (frame) => {
const streamingMessage = streamingChatMessageView(messages, true, frame.answer);
assert.ok(streamingMessage);
renderToString(createElement(StreamingMessageEntry, { message: streamingMessage }));
},
});
// 200 one-character tokens arrive four per frame across fifty frames.
let answer = "";
for (let index = 0; index < 200; index += 1) {
answer += "字";
buffer.setAnswer(answer);
if (index % 4 === 3) for (const callback of frames.splice(0)) callback();
}
buffer.settle();
const probe = homeStreamingRenderProbeSnapshot();
disableHomeStreamingRenderProbe();
assert.ok(probe.streamingRowRenders <= 51, `rendered ${probe.streamingRowRenders} times for 200 tokens`);
assert.ok(probe.streamingRowRenders * 3 <= 200);
});