authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 21:52:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 21:52:21-07:00
logdf8e0eb46bca3dc01c17cc74a53b19aaacbe252b
treed1ca936f52476e4aba26d7ce25db824db35fdaea
parent2fb99a7a664faf045f1884f38c620016b34874cb
signature Signed by PGP key B5690EEEBB952194

fix(web): discard stale highlights after file edits (#9902)


3 files changed, 281 insertions(+), 5 deletions(-)

apps/web/src/components/files/fileEditorHighlight.test.ts created+264
...@@ -0,0 +1,264 @@
1import {
2 FileRenderer,
3 getSharedHighlighter,
4 type BaseCodeOptions,
5 type DiffsHighlighter,
6 type FileContents,
7 type HighlightedToken,
8 type RenderRange,
9} from "@pierre/diffs";
10import { TextDocument } from "@pierre/diffs/editor";
11import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker";
12import * as NodeWorkerThreads from "node:worker_threads";
13import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
14
15type DocumentChange = NonNullable<ReturnType<TextDocument<unknown>["applyEdits"]>>;
16interface Tokenizer {
17 readonly themeType: "light" | "dark";
18 tokenize(change: DocumentChange, range: RenderRange): Map<number, HighlightedToken[]>;
19 cleanUp(): void;
20}
21
22// This dependency-internal tokenizer is the one used by Editor.#rerender.
23const tokenizerUrl = new URL("./editor/tokenizer.js", import.meta.resolve("@pierre/diffs"));
24const { EditorTokenizer } = (await import(/* @vite-ignore */ tokenizerUrl.href)) as {
25 EditorTokenizer: new (options: {
26 codeOptions: BaseCodeOptions;
27 highlighter: DiffsHighlighter;
28 textDocument: TextDocument<unknown>;
29 setStyle: (style: string) => void;
30 onDeferTokenize: (lines: Map<number, HighlightedToken[]>, theme: "light" | "dark") => void;
31 }) => Tokenizer;
32};
33
34const workerModule = import.meta.resolve("@pierre/diffs/worker/worker.js");
35const source = Array.from(
36 { length: 7_000 },
37 (_, index) =>
38 `export const section${index} = <p>Long wrapped source line ${index} for the file editor.</p>;`,
39).join("\n");
40const options = {
41 theme: "pierre-dark",
42 themeType: "dark",
43 preferredHighlighter: "shiki-wasm",
44 useTokenTransformer: true,
45 overflow: "wrap",
46 disableFileHeader: true,
47} as const;
48const range: RenderRange = {
49 startingLine: 6_950,
50 totalLines: 150,
51 bufferBefore: 0,
52 bufferAfter: 0,
53};
54
55interface HeldResponse {
56 data: WorkerResponse;
57 deliver: () => void;
58}
59let responses: HeldResponse[];
60let responseWaiters: ((response: HeldResponse) => void)[];
61let terminationPromises: Promise<number>[];
62let pool: WorkerPoolManager;
63let renderer: FileRenderer;
64let tokenizer: Tokenizer;
65let file: FileContents;
66let document: TextDocument<unknown>;
67
68function nextResponse(): Promise<HeldResponse> {
69 const response = responses.shift();
70 return response
71 ? Promise.resolve(response)
72 : new Promise((resolve) => responseWaiters.push(resolve));
73}
74
75class WorkerTransport {
76 private readonly worker = new NodeWorkerThreads.Worker(
77 `const { parentPort, workerData } = require("node:worker_threads");
78 globalThis.self = {
79 addEventListener(type, listener) {
80 if (type === "message") parentPort.on("message", data => listener({ data }));
81 if (type === "error") process.on("uncaughtException", listener);
82 }
83 };
84 globalThis.postMessage = data => parentPort.postMessage(data);
85 import(workerData.moduleUrl);`,
86 { eval: true, workerData: { moduleUrl: workerModule }, execArgv: [] },
87 );
88
89 addEventListener(
90 type: "message" | "error",
91 listener: (event: { data: WorkerResponse } | Error) => void,
92 ) {
93 if (type === "error") {
94 this.worker.on("error", listener);
95 return;
96 }
97 this.worker.on("message", (data: WorkerResponse) => {
98 const response = { data, deliver: () => listener({ data }) };
99 if (data.type !== "success" || data.requestType !== "file") {
100 response.deliver();
101 return;
102 }
103 const waiter = responseWaiters.shift();
104 if (waiter) waiter(response);
105 else responses.push(response);
106 });
107 }
108
109 postMessage(message: WorkerRequest) {
110 this.worker.postMessage(message, []);
111 }
112
113 terminate() {
114 terminationPromises.push(this.worker.terminate());
115 }
116}
117
118function applyChange(change: DocumentChange) {
119 // Keep the installed editor's non-DOM order, including the existing contents patch.
120 renderer.updateRenderCache(tokenizer.tokenize(change, range), tokenizer.themeType);
121 file.contents = document.getText();
122 if (change.lineDelta !== 0) renderer.applyDocumentChange(document);
123}
124
125function append(text: string) {
126 const position = document.positionAt(document.getText().length);
127 const change = document.applyEdits([
128 { range: { start: position, end: position }, newText: text },
129 ]);
130 expect(change).toBeDefined();
131 applyChange(change!);
132}
133
134function undo() {
135 const change = document.undo()?.[0];
136 expect(change).toBeDefined();
137 applyChange(change!);
138}
139
140function renderContents() {
141 const result = renderer.renderFile(file, range);
142 expect(result?.totalLines).toBe(document.lineCount);
143 return renderer.renderFullHTML(result!);
144}
145
146beforeEach(async () => {
147 responses = [];
148 responseWaiters = [];
149 terminationPromises = [];
150 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) =>
151 setImmediate(() => callback(0)),
152 );
153 vi.stubGlobal("cancelAnimationFrame", clearImmediate);
154 vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) });
155 pool = new WorkerPoolManager(
156 // Adapt browser transport only; Pierre's real worker produces each response.
157 { workerFactory: () => new WorkerTransport() as unknown as globalThis.Worker, poolSize: 1 },
158 options,
159 );
160 await pool.initialize(["tsx"]);
161 const highlighter = await getSharedHighlighter({
162 themes: ["pierre-dark"],
163 langs: ["tsx"],
164 preferredHighlighter: "shiki-wasm",
165 });
166 file = { name: "wrapped.tsx", contents: source, cacheKey: "editable-file" };
167 document = new TextDocument(file.name, source, "tsx");
168 renderer = new FileRenderer(options, () => {}, pool);
169 tokenizer = new EditorTokenizer({
170 codeOptions: options,
171 highlighter,
172 textDocument: document,
173 setStyle: () => {},
174 onDeferTokenize: (lines, theme) => renderer.updateRenderCache(lines, theme),
175 });
176 renderContents();
177});
178
179afterEach(async () => {
180 tokenizer?.cleanUp();
181 renderer?.cleanUp();
182 pool?.terminate();
183 await Promise.all(terminationPromises);
184 vi.unstubAllGlobals();
185});
186
187describe("editable file highlighting", () => {
188 it("still accepts an asynchronous highlight when the file has not changed", async () => {
189 expect(renderContents()).not.toContain('style="color:');
190 (await nextResponse()).deliver();
191 expect(renderContents()).toContain('style="color:');
192 expect(pool.getFileResultCache(file)).toBeDefined();
193 });
194
195 it.each([1, 60])(
196 "ignores a dispatched highlight after %i Enter edits and highlights the new version",
197 async (count) => {
198 const oldResponse = await nextResponse();
199 for (let index = 0; index < count; index += 1) append("\n");
200 append("export const EDITED_MARKER = 1;");
201 oldResponse.deliver();
202 expect(renderContents()).toContain("EDITED_MARKER");
203 const currentResponse = await nextResponse();
204 currentResponse.deliver();
205 expect(renderContents()).toContain("EDITED_MARKER");
206 const firstLines = renderer.renderFile(file, { ...range, startingLine: 0, totalLines: 20 });
207 expect(renderer.renderFullHTML(firstLines!)).toContain('style="color:');
208 expect(document.lineCount).toBe(7_000 + count);
209 },
210 );
211
212 it("does not replace a same-line edit with stale tokens", async () => {
213 const oldResponse = await nextResponse();
214 append(" EDITED_MARKER");
215 oldResponse.deliver();
216 expect(renderContents()).toContain("EDITED_MARKER");
217 expect(document.lineCount).toBe(7_000);
218 (await nextResponse()).deliver();
219 expect(renderContents()).toContain("EDITED_MARKER");
220 });
221
222 it("keeps undo edits after an older highlight arrives", async () => {
223 const oldResponse = await nextResponse();
224 append("\nexport const RETAINED_MARKER = 1;");
225 append("\nexport const UNDONE_MARKER = 2;");
226 undo();
227 oldResponse.deliver();
228 const html = renderContents();
229 expect(html).toContain("RETAINED_MARKER");
230 expect(html).not.toContain("UNDONE_MARKER");
231 (await nextResponse()).deliver();
232 expect(renderContents()).toContain("RETAINED_MARKER");
233 undo();
234 expect(document.getText()).toBe(source);
235 expect(renderContents()).not.toContain("RETAINED_MARKER");
236 const redone = document.redo()?.[0];
237 expect(redone).toBeDefined();
238 applyChange(redone!);
239 expect(renderContents()).toContain("RETAINED_MARKER");
240 });
241
242 it("evicts the pre-edit shared cache without losing already-highlighted lines", async () => {
243 (await nextResponse()).deliver();
244 expect(pool.getFileResultCache(file)).toBeDefined();
245 append("\nexport const EDITED_MARKER = 1;");
246 expect(pool.getFileResultCache(file)).toBeUndefined();
247 expect(renderContents()).toContain("EDITED_MARKER");
248 const firstLines = renderer.renderFile(file, { ...range, startingLine: 0, totalLines: 20 });
249 expect(renderer.renderFullHTML(firstLines!)).toContain('style="color:');
250 });
251
252 it("reopens the edited file with the same cache key while an old response is pending", async () => {
253 const oldResponse = await nextResponse();
254 append("\nexport const REOPENED_MARKER = 1;");
255 renderer.cleanUp();
256 renderer = new FileRenderer(options, () => {}, pool);
257 file = { ...file };
258 expect(renderContents()).toContain("REOPENED_MARKER");
259 oldResponse.deliver();
260 (await nextResponse()).deliver();
261 expect(renderContents()).toContain("REOPENED_MARKER");
262 expect(renderContents()).toContain('style="color:');
263 });
264});
patches/@pierre%2Fdiffs@1.3.0-beta.10.patch+12
...@@ -60,6 +60,18 @@ index e9f62f5..af82a46 100644...@@ -60,6 +60,18 @@ index e9f62f5..af82a46 100644
60 };60 };
61 return merged;61 return merged;
62 }62 }
63diff --git a/dist/renderers/FileRenderer.js b/dist/renderers/FileRenderer.js
64--- a/dist/renderers/FileRenderer.js
65+++ b/dist/renderers/FileRenderer.js
66@@ -163,6 +163,8 @@
67 if (this.renderCache == null) return;
68 const { file, result } = this.renderCache;
69 if (result == null) return;
70+ this.workerManager?.cleanUpTasks(this);
71+ if (file.cacheKey != null) this.workerManager?.evictFileFromCache(file.cacheKey);
72 const lineCache = this.lineCache != null && isLineCacheForFile(this.lineCache, file) ? this.lineCache : void 0;
73 for (const [line, tokens] of dirtyLines) {
74 if (lineCache != null && line < lineCache.lines.length) {
63diff --git a/package.json b/package.json75diff --git a/package.json b/package.json
64index ff61c90..1e170e5 10064476index ff61c90..1e170e5 100644
65--- a/package.json77--- a/package.json
pnpm-lock.yaml+5-5
...@@ -92,7 +92,7 @@ patchedDependencies:...@@ -92,7 +92,7 @@ patchedDependencies:
92 '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc292 '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2
93 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca36893 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368
94 '@legendapp/list@3.3.5': 03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be4394 '@legendapp/list@3.3.5': 03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43
95 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa95 '@pierre/diffs@1.3.0-beta.10': c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e
96 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d96 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d
97 '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd97 '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd
98 '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e229555298 '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552
...@@ -245,7 +245,7 @@ importers:...@@ -245,7 +245,7 @@ importers:
245 version: 1.9.1245 version: 1.9.1
246 '@pierre/diffs':246 '@pierre/diffs':
247 specifier: 'catalog:'247 specifier: 'catalog:'
248 version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)248 version: 1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
249 '@react-native-ai/apple':249 '@react-native-ai/apple':
250 specifier: 0.12.0250 specifier: 0.12.0
251 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))251 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
...@@ -586,7 +586,7 @@ importers:...@@ -586,7 +586,7 @@ importers:
586 version: 1.8.0586 version: 1.8.0
587 '@pierre/diffs':587 '@pierre/diffs':
588 specifier: 'catalog:'588 specifier: 'catalog:'
589 version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)589 version: 1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
590 '@pierre/trees':590 '@pierre/trees':
591 specifier: 1.0.0-beta.4591 specifier: 1.0.0-beta.4
592 version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)592 version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
...@@ -13613,7 +13613,7 @@ snapshots:...@@ -13613,7 +13613,7 @@ snapshots:
13613 tslib: 2.8.113613 tslib: 2.8.1
13614 webcrypto-core: 1.9.213614 webcrypto-core: 1.9.2
1361513615
13616 '@pierre/diffs@1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':13616 '@pierre/diffs@1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
13617 dependencies:13617 dependencies:
13618 '@pierre/theme': 1.1.013618 '@pierre/theme': 1.1.0
13619 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0)13619 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0)
...@@ -13627,7 +13627,7 @@ snapshots:...@@ -13627,7 +13627,7 @@ snapshots:
13627 transitivePeerDependencies:13627 transitivePeerDependencies:
13628 - '@shikijs/themes'13628 - '@shikijs/themes'
1362913629
13630 '@pierre/diffs@1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':13630 '@pierre/diffs@1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
13631 dependencies:13631 dependencies:
13632 '@pierre/theme': 1.1.013632 '@pierre/theme': 1.1.0
13633 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)13633 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)