1// @vitest-environment jsdom
2
3import { act } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6import { collectThreadFindRanges, useThreadFindHighlights } from "./threadFindHighlights";
7
8function Probe(props: Parameters<typeof useThreadFindHighlights>[0]) {
9 useThreadFindHighlights(props);
10 return null;
11}
12
13let root: Root;
14let host: HTMLElement;
15let container: HTMLElement;
16const onActiveRange = vi.fn();
17const activeRanges = () => [
18 ...(CSS.highlights.get("t3-thread-find-active") as unknown as Set<Range>),
19];
20
21beforeEach(() => {
22 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
23 vi.stubGlobal("CSS", { highlights: new Map() });
24 vi.stubGlobal(
25 "Highlight",
26 class extends Set<Range> {
27 constructor(...ranges: Range[]) {
28 super(ranges);
29 }
30 },
31 );
32 host = document.createElement("div");
33 container = document.createElement("div");
34 container.innerHTML =
35 '<div data-timeline-row-id="first"><p data-thread-find-text>COD4 and COD4</p></div>' +
36 '<div data-timeline-row-id="second"><p data-thread-find-text>COD4</p></div>';
37 document.body.append(host, container);
38 root = createRoot(host);
39});
40afterEach(async () => {
41 await act(async () => root.unmount());
42 host.remove();
43 container.remove();
44 vi.restoreAllMocks();
45 vi.clearAllMocks();
46 vi.unstubAllGlobals();
47});
48
49describe("find highlight caching", () => {
50 it("checks only the old and new occurrence when cycling a message with many clipped matches", async () => {
51 container.innerHTML =
52 '<div data-timeline-row-id="first"><div data-thread-find-fold><p data-thread-find-text>' +
53 "COD4 ".repeat(1500) +
54 "</p></div></div>";
55 const bounds = vi.fn(() => new DOMRect(0, 0, 40, 20));
56 Object.defineProperty(Range.prototype, "getBoundingClientRect", {
57 configurable: true,
58 value: bounds,
59 });
60 container.querySelector<HTMLElement>("[data-thread-find-fold]")!.getBoundingClientRect = () =>
61 new DOMRect(0, 0, 800, 100);
62 const props = {
63 container,
64 query: "COD4",
65 activeRowId: "first",
66 activeOccurrence: 0,
67 onActiveRange,
68 };
69 try {
70 await act(async () => root.render(<Probe {...props} />));
71 const inactive = CSS.highlights.get("t3-thread-find");
72 const first = activeRanges()[0];
73 bounds.mockClear();
74 await act(async () => root.render(<Probe {...props} activeOccurrence={1} />));
75 expect(bounds).toHaveBeenCalledTimes(2);
76 expect(CSS.highlights.get("t3-thread-find")).toBe(inactive);
77 expect(inactive?.size).toBe(1499);
78 expect(inactive?.has(first!)).toBe(true);
79 expect(activeRanges()[0]?.startOffset).toBe(5);
80 expect(inactive?.has(activeRanges()[0]!)).toBe(false);
81 } finally {
82 Reflect.deleteProperty(Range.prototype, "getBoundingClientRect");
83 }
84 });
85
86 it("repaints for searchable text changes but not for timers outside it", async () => {
87 const timer = document.createElement("span");
88 container.firstElementChild!.append(timer);
89 await act(async () =>
90 root.render(
91 <Probe
92 container={container}
93 query="COD4"
94 activeRowId="first"
95 activeOccurrence={0}
96 onActiveRange={onActiveRange}
97 />,
98 ),
99 );
100 onActiveRange.mockClear();
101 await act(async () => {
102 timer.textContent = "0:01";
103 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
104 });
105 expect(onActiveRange).not.toHaveBeenCalled();
106 await act(async () => {
107 container.querySelector("p")!.append(" COD4");
108 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
109 });
110 expect(onActiveRange).toHaveBeenCalled();
111 });
112
113 it("opens only the selected fold while reusing its cached text", async () => {
114 container.innerHTML =
115 '<div data-timeline-row-id="first"><div hidden><p data-thread-find-text>COD4 COD4</p></div></div>' +
116 '<div data-timeline-row-id="second"><div hidden><p data-thread-find-text>COD4</p></div></div>';
117 const folds = [...container.querySelectorAll<HTMLElement>("[hidden]")];
118 for (const fold of folds)
119 fold.addEventListener("beforematch", () => fold.removeAttribute("hidden"));
120 const createRange = vi.spyOn(document, "createRange");
121 await act(async () =>
122 root.render(
123 <Probe
124 container={container}
125 query="COD4"
126 activeRowId="first"
127 activeOccurrence={0}
128 onActiveRange={onActiveRange}
129 />,
130 ),
131 );
132 await act(async () => {
133 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
134 });
135 expect(folds[0]?.hasAttribute("hidden")).toBe(false);
136 expect(folds[1]?.hasAttribute("hidden")).toBe(true);
137 expect(activeRanges()[0]?.toString()).toBe("COD4");
138 expect(CSS.highlights.get("t3-thread-find")?.size).toBe(1);
139 const scanned = createRange.mock.calls.length;
140 await act(async () =>
141 root.render(
142 <Probe
143 container={container}
144 query="COD4"
145 activeRowId="first"
146 activeOccurrence={1}
147 onActiveRange={onActiveRange}
148 />,
149 ),
150 );
151 expect(createRange).toHaveBeenCalledTimes(scanned);
152 expect(activeRanges()[0]?.startOffset).toBe(5);
153 });
154
155 it("observes rows mounted synchronously while revealing the first match", async () => {
156 container.replaceChildren();
157 onActiveRange.mockImplementationOnce(() => {
158 container.innerHTML =
159 '<div data-timeline-row-id="first"><p data-thread-find-text>COD4</p></div>';
160 });
161 await act(async () => {
162 root.render(
163 <Probe
164 container={container}
165 query="COD4"
166 activeRowId="first"
167 activeOccurrence={0}
168 onActiveRange={onActiveRange}
169 />,
170 );
171 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
172 });
173 await act(async () => {
174 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
175 });
176 expect(activeRanges()[0]?.toString()).toBe("COD4");
177 });
178
179 it("rebuilds ranges when the list moves or recycles an existing row", async () => {
180 const props = {
181 container,
182 query: "COD4",
183 activeRowId: "first",
184 activeOccurrence: 0,
185 onActiveRange,
186 };
187 await act(async () => root.render(<Probe {...props} />));
188 const initial = activeRanges()[0];
189 const row = container.querySelector<HTMLElement>('[data-timeline-row-id="first"]')!;
190 await act(async () => {
191 row.remove();
192 container.append(row);
193 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
194 });
195 expect(activeRanges()[0]).not.toBe(initial);
196 expect(activeRanges()[0]?.toString()).toBe("COD4");
197 await act(async () => {
198 row.dataset.timelineRowId = "recycled";
199 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
200 });
201 expect(activeRanges()).toHaveLength(0);
202 await act(async () => root.render(<Probe {...props} activeRowId="recycled" />));
203 expect(activeRanges()[0]?.toString()).toBe("COD4");
204 });
205
206 it("reuses unchanged text ranges on navigation and scans only the streaming row", async () => {
207 const createRange = vi.spyOn(document, "createRange");
208 const props = {
209 container,
210 query: "COD4",
211 activeRowId: "first",
212 activeOccurrence: 0,
213 onActiveRange,
214 };
215 await act(async () => root.render(<Probe {...props} />));
216 expect(createRange).toHaveBeenCalledTimes(3);
217 const initial = activeRanges()[0];
218 await act(async () => root.render(<Probe {...props} activeOccurrence={1} />));
219 expect(createRange).toHaveBeenCalledTimes(3);
220 expect(activeRanges()[0]?.startOffset).toBe(9);
221 await act(async () => root.render(<Probe {...props} />));
222 expect(activeRanges()[0]).toBe(initial);
223 await act(async () => {
224 container.querySelector('[data-timeline-row-id="second"] p')!.firstChild!.nodeValue =
225 "COD4 COD4";
226 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
227 });
228 expect(createRange).toHaveBeenCalledTimes(5);
229 expect(activeRanges()[0]).toBe(initial);
230 await act(async () =>
231 root.render(<Probe {...props} activeRowId="second" activeOccurrence={1} />),
232 );
233 expect(createRange).toHaveBeenCalledTimes(5);
234 expect(activeRanges()[0]?.toString()).toBe("COD4");
235 expect(activeRanges()[0]?.startOffset).toBe(5);
236 await act(async () => {
237 container.querySelector('[data-timeline-row-id="second"]')!.remove();
238 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
239 });
240 expect(activeRanges()).toHaveLength(0);
241 expect(onActiveRange).toHaveBeenLastCalledWith(null);
242 expect(CSS.highlights.get("t3-thread-find")?.size).toBe(2);
243 await act(async () => root.render(<Probe {...props} query="missing" />));
244 expect(CSS.highlights.get("t3-thread-find")?.size).toBe(0);
245 });
246});
247
248function row(html: string) {
249 const container = document.createElement("div");
250 container.innerHTML = `<div data-timeline-row-id="row"><div data-thread-find-text>${html}</div></div>`;
251 return container;
252}
253
254describe("collectThreadFindRanges", () => {
255 it("finds matches split across inline token spans, in order", () => {
256 // Shiki-style tokens split one identifier over several text nodes.
257 const container = row(
258 "<pre><code><span>con</span><span>st</span> a = <span>c</span>onst; const</code></pre>",
259 );
260 const ranges = collectThreadFindRanges(container, "const");
261 expect(ranges.map((match) => [match.occurrence, match.range.toString()])).toEqual([
262 [0, "const"],
263 [1, "const"],
264 [2, "const"],
265 ]);
266 });
267
268 it("finds every match across thousands of token nodes", () => {
269 const tokens = Array.from({ length: 4000 }, (_, i) => `<span>id</span><span>${i} </span>`);
270 const container = row(`<pre><code>${tokens.join("")}</code></pre>`);
271 const ranges = collectThreadFindRanges(container, "id");
272 expect(ranges).toHaveLength(4000);
273 expect(ranges.map((match) => match.occurrence)).toEqual([...ranges.keys()]);
274 expect(ranges.every((match) => match.range.toString() === "id")).toBe(true);
275 });
276});