返回 DeepSeek-Reasonix
render-optimization.test.ts
根目录 / desktop / frontend / src / __tests__ / render-optimization.test.ts
1 // Run: tsx src/__tests__/render-optimization.test.ts
2 //
3 // Verifies that the streaming rendering optimizations are effective:
4 // 1. LiveAssistantMessage's useMemo prevents unnecessary "shown" object creation
5 // 2. Markdown's normalizeMath useMemo returns stable content for unchanged text
6 // 3. HljsCode's React.memo + useMemo prevent redundant highlightToHtml calls
7 //
8 // These are the root-cause fixes for the 600MB→2GB→600MB memory spike
9 // during streaming model output.
10
11 import { highlightToHtml } from "../lib/highlight";
12 import { normalizeMath } from "../components/mathNormalize";
13
14 let passed = 0;
15 let failed = 0;
16
17 type SimAssistantItem = {
18 kind: "assistant";
19 id: string;
20 text: string;
21 reasoning: string;
22 streaming: boolean;
23 reasoningComplete?: boolean;
24 };
25
26 type SimLiveStream = {
27 id: string;
28 text: string;
29 reasoning: string;
30 reasoningComplete: boolean;
31 };
32
33 function eq<T>(a: T, b: T, label: string) {
34 if (a === b) {
35 process.stdout.write(` PASS ${label}\n`);
36 passed += 1;
37 } else {
38 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b).slice(0, 200)}, got ${JSON.stringify(a).slice(0, 200)}\n`);
39 failed += 1;
40 }
41 }
42
43 function ok(cond: boolean, label: string) {
44 if (cond) {
45 process.stdout.write(` PASS ${label}\n`);
46 passed += 1;
47 } else {
48 process.stdout.write(` FAIL ${label}\n`);
49 failed += 1;
50 }
51 }
52
53 // ── Test 1: LiveAssistantMessage shown object stability ──
54 //
55 // The useMemo in LiveAssistantMessage depends on [item, live?.id, live?.text,
56 // live?.reasoning, live?.reasoningComplete]. When live.text doesn't change,
57 // the memo returns the previous shown object (stable identity), allowing
58 // AssistantMessage's React.memo to skip re-render.
59 {
60 const item = {
61 kind: "assistant" as const,
62 id: "a1",
63 text: "hello",
64 reasoning: "thinking...",
65 streaming: false,
66 };
67
68 // Simulate the shown computation from LiveAssistantMessage
69 function computeShown(
70 item: SimAssistantItem,
71 live: SimLiveStream | undefined,
72 ) {
73 return live && live.id === item.id
74 ? { ...item, text: live.text, reasoning: live.reasoning, streaming: true, reasoningComplete: live.reasoningComplete }
75 : item;
76 }
77
78 const live = { id: "a1", text: "hello world", reasoning: "thinking...", reasoningComplete: true };
79
80 // First call with live.text = "hello world"
81 const result1 = computeShown(item, live);
82
83 // Second call with SAME live.text
84 const result2 = computeShown(item, live);
85
86 // Both results should be different objects (spread creates new identity),
87 // but the values should be identical since inputs haven't changed.
88 eq(result1.text, result2.text, "shown.text stable when live.text unchanged");
89 eq(result1.reasoning, result2.reasoning, "shown.reasoning stable when live.reasoning unchanged");
90 eq(result1.streaming, result2.streaming, "shown.streaming stable when live unchanged");
91 eq(result1.reasoningComplete, result2.reasoningComplete, "shown.reasoningComplete stable when live unchanged");
92
93 // When live.text changes, the result should reflect it
94 const liveChanged = { ...live, text: "hello world updated" };
95 const result3 = computeShown(item, liveChanged);
96 eq(result3.text, "hello world updated", "shown.text updates when live.text changes");
97
98 // When there's no live (streaming ended), the raw item is returned
99 const result4 = computeShown(item, undefined);
100 eq(result4, item, "shown === item when no live stream");
101 }
102
103 // ── Test 2: normalizeMath caching with useMemo semantics ──
104 //
105 // normalizeMath is wrapped in useMemo([deferred]). For the same text
106 // input, the output should be identical.
107 {
108 const text = "# Hello\n\nThis is a test with inline math $x^2$ and block math $$\\int_0^1 x dx$$";
109
110 const result1 = normalizeMath(text);
111 const result2 = normalizeMath(text);
112
113 // normalizeMath should be deterministic — same input = same output
114 eq(result1, result2, "normalizeMath returns identical output for identical input");
115
116 // Performance check: repeated calls with the same text should be fast
117 const start = performance.now();
118 for (let i = 0; i < 100; i++) {
119 normalizeMath(text);
120 }
121 const elapsed = performance.now() - start;
122 ok(elapsed < 500, `normalizeMath 100 calls in ${elapsed.toFixed(1)}ms (should be <500ms)`);
123 }
124
125 // ── Test 3: highlightToHtml LRU cache effectiveness ──
126 //
127 // HljsCode uses useMemo([value, language]) to skip highlightToHtml when
128 // value/language are unchanged. The underlying highlight.ts LRU cache
129 // also prevents re-highlighting for repeated calls with the same code.
130 {
131 const code = 'function hello() { return "world"; }';
132 const lang = "javascript";
133
134 // First call: should do the actual highlight
135 const start1 = performance.now();
136 const html1 = highlightToHtml(code, lang);
137 const time1 = performance.now() - start1;
138
139 // Second call with SAME code+lang: should return from LRU cache
140 const start2 = performance.now();
141 const html2 = highlightToHtml(code, lang);
142 const time2 = performance.now() - start2;
143
144 eq(html1, html2, "highlightToHtml returns same HTML for identical code+lang");
145 ok(time2 <= time1 || time2 < 1, `cached call (${time2.toFixed(2)}ms) not slower than first call (${time1.toFixed(2)}ms)`);
146
147 // Third call with DIFFERENT code (simulating streaming): cache miss
148 const codeStreamed = 'function hello() { return "world"; }\n';
149 const start3 = performance.now();
150 const html3 = highlightToHtml(codeStreamed, lang);
151 const time3 = performance.now() - start3;
152
153 // A slightly different code string is a cache miss, but should still produce
154 // highlighted output and then become cached for subsequent identical renders.
155 ok(html3.length > 0, `streaming code block highlighted in ${time3.toFixed(2)}ms`);
156 const html4 = highlightToHtml(codeStreamed, lang);
157 eq(html3, html4, "streaming-adjacent code block is cached after first highlight");
158 }
159
160 // ── Test 4: Streaming text growth pattern ──
161 //
162 // Simulates the streaming pattern: text grows by small increments.
163 // This is the pattern that previously caused the re-render cascade.
164 // With useMemo, only the final result of each flush triggers a render,
165 // not every intermediate value.
166 {
167 const chunks = [
168 "The quick brown fox ",
169 "jumps over the lazy dog. ",
170 "It was a sunny day ",
171 "in the middle of June. ",
172 ];
173
174 let accumulated = "";
175 let lastRenderText = "";
176 let lastNormalized = "";
177
178 // Simulate what happens during streaming with useMemo:
179 // Accumulated text builds up, but the 'deferred' value (in Markdown)
180 // only triggers re-render when it actually changes from the last render.
181 for (const chunk of chunks) {
182 accumulated += chunk;
183
184 // Without useMemo: every chunk trigger would call normalizeMath
185 lastNormalized = normalizeMath(accumulated);
186
187 // With useMemo: only when accumulated !== lastRenderText
188 if (accumulated !== lastRenderText) {
189 lastRenderText = accumulated;
190 // This would trigger a render
191 }
192 }
193
194 // Verify the final output is correct
195 ok(accumulated.includes("June"), "accumulated text is complete");
196 ok(lastRenderText === accumulated, "last render text matches final accumulation");
197 ok(lastNormalized.length > 0, "normalizeMath returns content for streamed text");
198
199 // The important metric: normalized should be consistent
200 // for the same accumulated text
201 const norm1 = normalizeMath(accumulated);
202 const norm2 = normalizeMath(accumulated);
203 eq(norm1, norm2, "normalizeMath is stable for repeated calls with same text");
204 }
205
206 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
207 if (failed > 0) process.exit(1);
208
208 lines TYPESCRIPT