1import type * as NodeWorkerThreads from "node:worker_threads";
2import {
3 FileRenderer,
4 getSharedHighlighter,
5 type CodeViewScrollTarget,
6 type FileContents,
7} from "@pierre/diffs";
8import { useWorkerPool, type CodeViewProps } from "@pierre/diffs/react";
9import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker";
10import {
11 act,
12 StrictMode,
13 useEffect,
14 useImperativeHandle,
15 useLayoutEffect,
16 useMemo,
17 useRef,
18 useState,
19 type Ref,
20} from "react";
21import { create, type ReactTestRenderer } from "react-test-renderer";
22import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
23
24const testState = vi.hoisted(() => ({
25 workers: [] as NodeWorkerThreads.Worker[],
26 terminations: [] as Promise<number>[],
27 requests: [] as WorkerRequest[],
28 pools: new Map<WorkerPoolManager, Promise<void>>(),
29 renderPools: [] as (WorkerPoolManager | undefined)[],
30 failWorkers: false,
31 holdInitialization: false,
32 heldResponses: [] as (() => void)[],
33 onInitializationHeld: undefined as (() => void) | undefined,
34}));
35
36vi.mock("../../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
37
38vi.mock("@pierre/diffs/worker/worker.js?worker", async () => {
39 const { Worker } = await import("node:worker_threads");
40 const moduleUrl = import.meta.resolve("@pierre/diffs/worker/worker.js");
41
42 // Adapt only the transport. Pierre's real worker loads its WASM and produces every response.
43 return {
44 default: class {
45 worker: NodeWorkerThreads.Worker;
46
47 constructor() {
48 if (testState.failWorkers) throw new Error("Worker creation failed");
49 this.worker = new Worker(
50 `const { parentPort, workerData } = require("node:worker_threads");
51 globalThis.self = {
52 addEventListener(type, listener) {
53 if (type === "message") parentPort.on("message", data => listener({ data }));
54 if (type === "error") process.on("uncaughtException", listener);
55 }
56 };
57 globalThis.postMessage = data => parentPort.postMessage(data);
58 import(workerData.moduleUrl);`,
59 { eval: true, workerData: { moduleUrl }, execArgv: [] },
60 );
61 testState.workers.push(this.worker);
62 }
63
64 addEventListener(
65 type: "message" | "error",
66 listener: (event: { data: WorkerResponse } | Error) => void,
67 ) {
68 if (type === "error") {
69 this.worker.on("error", listener);
70 return;
71 }
72 this.worker.on("message", (data: WorkerResponse) => {
73 const deliver = () => listener({ data });
74 if (
75 testState.holdInitialization &&
76 data.type === "success" &&
77 data.requestType === "initialize"
78 ) {
79 testState.heldResponses.push(deliver);
80 if (testState.heldResponses.length === 2) testState.onInitializationHeld?.();
81 } else {
82 deliver();
83 }
84 });
85 }
86
87 postMessage(message: WorkerRequest) {
88 testState.requests.push(message);
89 this.worker.postMessage(message, []);
90 }
91
92 terminate() {
93 testState.terminations.push(this.worker.terminate());
94 }
95 },
96 };
97});
98
99vi.mock("@pierre/diffs/react", async (importOriginal) => ({
100 ...(await importOriginal<typeof import("@pierre/diffs/react")>()),
101 CodeView: (props: CodeViewProps<undefined, undefined>) => {
102 return props.items?.map((item) =>
103 item.type === "file" ? <FileOutput key={item.id} file={item.file} /> : null,
104 );
105 },
106}));
107
108import { StyledDiffCodeView } from "./StyledDiffCodeView";
109import { useCodeViewFileReveal } from "./useCodeViewFileReveal";
110
111function FileOutput({ file }: { file: FileContents }) {
112 const pool = useWorkerPool();
113 const [html, setHtml] = useState("");
114 useEffect(() => {
115 testState.renderPools.push(pool);
116 const renderer = new FileRenderer(
117 { theme: "pierre-dark", preferredHighlighter: "shiki-wasm" },
118 undefined,
119 render,
120 pool,
121 );
122 function render() {
123 const result = renderer.renderFile(file);
124 if (result) setHtml(renderer.renderFullHTML(result));
125 }
126 render();
127 return () => renderer.cleanUp();
128 }, [file, pool]);
129 return <pre data-code-file={file.name}>{html}</pre>;
130}
131
132const files = [
133 { name: "notes.txt", contents: "Plain text is ready.\n", cacheKey: "plain-text" },
134 { name: "answer.ts", contents: "const answer = 42;\n", cacheKey: "typescript" },
135] satisfies FileContents[];
136
137function Views({ count }: { count: number }) {
138 const [draft, setDraft] = useState("Draft");
139 return (
140 <>
141 <button onClick={() => setDraft("Edited draft")}>{draft}</button>
142 {files.slice(0, count).map((file) => (
143 <StyledDiffCodeView key={file.name} items={[{ type: "file", id: file.name, file }]} />
144 ))}
145 </>
146 );
147}
148
149function renderViews(count: number) {
150 return (
151 <StrictMode>
152 <Views count={count} />
153 </StrictMode>
154 );
155}
156
157describe("code-view worker lifecycle", () => {
158 let renderer: ReactTestRenderer | undefined;
159
160 beforeEach(() => {
161 testState.workers = [];
162 testState.terminations = [];
163 testState.requests = [];
164 testState.pools.clear();
165 testState.renderPools = [];
166 testState.failWorkers = false;
167 testState.holdInitialization = false;
168 testState.heldResponses = [];
169 testState.onInitializationHeld = undefined;
170 vi.stubGlobal("window", {});
171 vi.stubGlobal("navigator", { hardwareConcurrency: 2 });
172 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
173 vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
174 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) =>
175 setImmediate(() => callback(0)),
176 );
177 vi.stubGlobal("cancelAnimationFrame", clearImmediate);
178 const initialize = WorkerPoolManager.prototype.initialize;
179 vi.spyOn(WorkerPoolManager.prototype, "initialize").mockImplementation(function (
180 this: WorkerPoolManager,
181 ...args
182 ) {
183 const pending = initialize.apply(this, args);
184 testState.pools.set(this, pending);
185 return pending;
186 });
187 });
188
189 afterEach(async () => {
190 await act(async () => renderer?.unmount());
191 renderer = undefined;
192 await vi.runOnlyPendingTimersAsync();
193 await Promise.all(testState.terminations);
194 vi.useRealTimers();
195 vi.restoreAllMocks();
196 vi.unstubAllGlobals();
197 });
198
199 it("starts on demand, keeps quick reopens warm, and expires the last closed pool", async () => {
200 await act(async () => {
201 renderer = create(renderViews(0));
202 });
203 const mounted = renderer!;
204 const button = mounted.root.findByType("button");
205 expect(testState.workers).toHaveLength(0);
206 expect(testState.pools.size).toBe(0);
207 await act(async () => {
208 (button.props as { onClick(): void }).onClick();
209 mounted.update(renderViews(1));
210 });
211 const pool = [...testState.pools.keys()][0]!;
212 await act(async () => testState.pools.get(pool));
213 expect(pool.isInitialized()).toBe(true);
214 expect(testState.workers).toHaveLength(2);
215 expect(mounted.root.findByProps({ "data-code-file": "notes.txt" }).children.join("")).toContain(
216 "Plain text is ready.",
217 );
218 expect(testState.requests.filter((request) => request.type === "file")).toHaveLength(0);
219
220 await act(async () => mounted.update(renderViews(2)));
221 await act(async () => pool.primeFileHighlightCache(files[1]!));
222 expect(testState.pools.size).toBe(1);
223 expect(testState.workers).toHaveLength(2);
224 expect(new Set(testState.renderPools)).toEqual(new Set([pool]));
225 expect(pool.getFileResultCache(files[1]!)).toBeDefined();
226 expect(mounted.root.findByProps({ "data-code-file": "answer.ts" }).children.join("")).toContain(
227 "answer",
228 );
229
230 await act(async () => mounted.update(renderViews(1)));
231 expect(pool.getStats().totalWorkers).toBe(2);
232 expect(testState.terminations).toHaveLength(0);
233 await act(async () => mounted.update(renderViews(0)));
234 await vi.advanceTimersByTimeAsync(29_999);
235 expect(pool.getStats().totalWorkers).toBe(2);
236 expect(testState.terminations).toHaveLength(0);
237
238 await act(async () => mounted.update(renderViews(1)));
239 expect(testState.pools.size).toBe(1);
240 expect(testState.workers).toHaveLength(2);
241 expect(mounted.root.findAllByProps({ role: "status" })).toHaveLength(0);
242 expect(pool.getFileResultCache(files[1]!)).toBeDefined();
243 await vi.advanceTimersByTimeAsync(30_000);
244 expect(pool.getStats().totalWorkers).toBe(2);
245
246 await act(async () => mounted.update(renderViews(0)));
247 await vi.advanceTimersByTimeAsync(30_000);
248 await Promise.all(testState.terminations);
249 expect(pool.getStats().totalWorkers).toBe(0);
250 expect(testState.terminations).toHaveLength(2);
251
252 await act(async () => mounted.update(renderViews(1)));
253 const reopenedPool = [...testState.pools.keys()][1]!;
254 await act(async () => testState.pools.get(reopenedPool));
255 expect(reopenedPool.isInitialized()).toBe(true);
256 expect(testState.workers).toHaveLength(4);
257 expect(mounted.root.findByType("button")).toBe(button);
258 expect(button.children).toEqual(["Edited draft"]);
259 expect(mounted.root.findByProps({ "data-code-file": "notes.txt" }).children.join("")).toContain(
260 "Plain text is ready.",
261 );
262 });
263
264 it("renders through Pierre's main-thread fallback when worker creation fails", async () => {
265 testState.failWorkers = true;
266 await act(async () => {
267 renderer = create(renderViews(1));
268 });
269 const pool = [...testState.pools.keys()][0]!;
270 await act(async () => {
271 await expect(testState.pools.get(pool)).rejects.toMatchObject({ _tag: "DiffWorkerError" });
272 await getSharedHighlighter({
273 themes: ["pierre-dark"],
274 langs: ["text"],
275 preferredHighlighter: "shiki-wasm",
276 });
277 });
278 expect(pool.isWorkingPool()).toBe(false);
279 expect(testState.workers).toHaveLength(0);
280 expect(
281 renderer!.root.findByProps({ "data-code-file": "notes.txt" }).children.join(""),
282 ).toContain("Plain text is ready.");
283 });
284
285 it("keeps an initializing pool until its last view closes and ignores late responses", async () => {
286 testState.holdInitialization = true;
287 const held = new Promise<void>((resolve) => {
288 testState.onInitializationHeld = resolve;
289 });
290 await act(async () => {
291 renderer = create(renderViews(2));
292 });
293 const pool = [...testState.pools.keys()][0]!;
294 const pending = testState.pools.get(pool)!;
295 await held;
296 expect(pool.isInitialized()).toBe(false);
297 expect(testState.pools.size).toBe(1);
298 expect(renderer!.root.findAllByProps({ role: "status" })).toHaveLength(2);
299 expect(testState.renderPools).toHaveLength(0);
300
301 await act(async () => renderer!.update(renderViews(1)));
302 expect(pool.getStats().totalWorkers).toBe(2);
303 expect(testState.terminations).toHaveLength(0);
304 await act(async () => renderer!.update(renderViews(0)));
305 // Pierre gives up on a held startup after 10 seconds; the idle timer then tears the pool down.
306 const timedOut = expect(pending).rejects.toThrow("worker initialization timed out");
307 await vi.advanceTimersByTimeAsync(30_000);
308 await timedOut;
309 await Promise.all(testState.terminations);
310 await act(async () => {
311 for (const deliver of testState.heldResponses) deliver();
312 });
313 expect(pool.getStats().totalWorkers).toBe(0);
314 expect(testState.workers).toHaveLength(2);
315 expect(testState.terminations).toHaveLength(2);
316 expect(testState.renderPools).toHaveLength(0);
317 expect(renderer!.root.findAllByType("pre")).toHaveLength(0);
318 });
319});
320
321type RevealHandle = NonNullable<Parameters<typeof useCodeViewFileReveal>[0]>;
322const revealCalls: {
323 scope: string;
324 fileKey: string;
325 mounted: boolean;
326 collapsed: boolean | undefined;
327}[] = [];
328
329function RevealViewer({
330 viewerRef,
331 scope,
332 collapsed,
333}: {
334 viewerRef: Ref<RevealHandle>;
335 scope: string;
336 collapsed: boolean;
337}) {
338 const instance = useRef<{ collapsed: boolean } | undefined>(undefined);
339 useLayoutEffect(() => {
340 instance.current = { collapsed };
341 return () => {
342 instance.current = undefined;
343 };
344 }, [collapsed]);
345 const handle = useMemo(
346 () => ({
347 getInstance: () => instance.current,
348 scrollTo: (target: CodeViewScrollTarget) => {
349 if (target.type !== "item") return;
350 revealCalls.push({
351 scope,
352 fileKey: target.id,
353 mounted: instance.current !== undefined,
354 collapsed: instance.current?.collapsed,
355 });
356 },
357 }),
358 [scope],
359 );
360 useImperativeHandle(viewerRef, () => handle, [handle]);
361 return null;
362}
363
364function RevealViews({
365 scope,
366 visible,
367 selectedFile = null,
368 requestId = 0,
369}: {
370 scope: string;
371 visible: boolean;
372 selectedFile?: string | null;
373 requestId?: number;
374}) {
375 const [viewer, setViewer] = useState<RevealHandle | null>(null);
376 const [collapsed, setCollapsed] = useState(true);
377 const selectedRequest = useMemo(() => ({ selectedFile, requestId }), [requestId, selectedFile]);
378 useEffect(() => {
379 if (!selectedRequest.selectedFile || !viewer?.getInstance()) return;
380 viewer.scrollTo({ type: "item", id: selectedRequest.selectedFile, align: "start" });
381 }, [selectedRequest, viewer]);
382 const revealScope = useMemo(() => ({ scope, selectedRequest }), [scope, selectedRequest]);
383 const requestReveal = useCodeViewFileReveal(viewer, revealScope);
384 return (
385 <>
386 {visible && (
387 <RevealViewer key={scope} viewerRef={setViewer} scope={scope} collapsed={collapsed} />
388 )}
389 <button
390 onClick={() => {
391 setCollapsed(false);
392 requestReveal("tree-file");
393 }}
394 >
395 Reveal file
396 </button>
397 </>
398 );
399}
400
401describe("code-view file reveals", () => {
402 let renderer: ReactTestRenderer | undefined;
403
404 beforeEach(() => {
405 revealCalls.length = 0;
406 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
407 });
408
409 afterEach(async () => {
410 await act(async () => renderer?.unmount());
411 renderer = undefined;
412 vi.unstubAllGlobals();
413 });
414
415 it("does not replay a handled tree click or scroll an old handle when a selected file remounts the view", async () => {
416 await act(async () => {
417 renderer = create(<RevealViews scope="first" visible selectedFile="external-a" />);
418 });
419 await act(async () => {
420 (renderer!.root.findByType("button").props as { onClick(): void }).onClick();
421 });
422 expect(revealCalls.at(-1)?.fileKey).toBe("tree-file");
423 revealCalls.length = 0;
424
425 await act(async () => {
426 renderer!.update(
427 <RevealViews scope="second" visible selectedFile="external-b" requestId={1} />,
428 );
429 });
430 expect(revealCalls).toEqual([
431 { scope: "second", fileKey: "external-b", mounted: true, collapsed: false },
432 ]);
433 });
434
435 it("waits for a live viewer and applies each pending tree click only once", async () => {
436 await act(async () => {
437 renderer = create(<RevealViews scope="diff" visible={false} selectedFile="external" />);
438 });
439 await act(async () => {
440 (renderer!.root.findByType("button").props as { onClick(): void }).onClick();
441 });
442 expect(revealCalls).toEqual([]);
443
444 await act(async () => {
445 renderer!.update(<RevealViews scope="diff" visible selectedFile="external" />);
446 });
447 expect(revealCalls.map((call) => call.fileKey)).toEqual(["external", "tree-file"]);
448 revealCalls.length = 0;
449 await act(async () => {
450 renderer!.update(<RevealViews scope="diff" visible={false} selectedFile="external" />);
451 });
452 await act(async () => {
453 renderer!.update(<RevealViews scope="diff" visible selectedFile="external" />);
454 });
455 expect(revealCalls).toEqual([
456 { scope: "diff", fileKey: "external", mounted: true, collapsed: false },
457 ]);
458
459 revealCalls.length = 0;
460 await act(async () => {
461 (renderer!.root.findByType("button").props as { onClick(): void }).onClick();
462 renderer!.update(<RevealViews scope="diff" visible={false} selectedFile="external" />);
463 });
464 expect(revealCalls).toEqual([]);
465 await act(async () => {
466 renderer!.update(<RevealViews scope="diff" visible selectedFile="external" />);
467 });
468 expect(revealCalls).toEqual([
469 { scope: "diff", fileKey: "external", mounted: true, collapsed: false },
470 { scope: "diff", fileKey: "tree-file", mounted: true, collapsed: false },
471 ]);
472 });
473
474 it("cancels pending tree clicks when an external selection or diff scope changes", async () => {
475 await act(async () => {
476 renderer = create(<RevealViews scope="first" visible={false} selectedFile="external-a" />);
477 });
478 await act(async () => {
479 (renderer!.root.findByType("button").props as { onClick(): void }).onClick();
480 });
481 await act(async () => {
482 renderer!.update(
483 <RevealViews scope="first" visible selectedFile="external-b" requestId={1} />,
484 );
485 });
486 expect(revealCalls.map((call) => call.fileKey)).toEqual(["external-b"]);
487 revealCalls.length = 0;
488
489 await act(async () => renderer!.update(<RevealViews scope="first" visible={false} />));
490 await act(async () => {
491 (renderer!.root.findByType("button").props as { onClick(): void }).onClick();
492 });
493 await act(async () => renderer!.update(<RevealViews scope="second" visible={false} />));
494 await act(async () => renderer!.update(<RevealViews scope="first" visible />));
495 expect(revealCalls).toEqual([]);
496 });
497
498 it("reveals after a collapsed file expands and honors another click on the same file", async () => {
499 await act(async () => {
500 renderer = create(<RevealViews scope="diff" visible />);
501 });
502 const reveal = renderer!.root.findByType("button").props as { onClick(): void };
503 await act(async () => reveal.onClick());
504 await act(async () => reveal.onClick());
505 expect(revealCalls).toEqual([
506 { scope: "diff", fileKey: "tree-file", mounted: true, collapsed: false },
507 { scope: "diff", fileKey: "tree-file", mounted: true, collapsed: false },
508 ]);
509 });
510});