1// @vitest-environment jsdom
2
3import type { LegendListRef } from "@legendapp/list/react";
4import { act, type ComponentProps } from "react";
5import { createRoot, type Root } from "react-dom/client";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
7import { useThreadFindNavigation } from "./useThreadFindNavigation";
8
9const renders = vi.fn();
10function Probe(props: Parameters<typeof useThreadFindNavigation>[0]) {
11 renders();
12 useThreadFindNavigation(props);
13 return null;
14}
15
16let root: Root;
17let host: HTMLDivElement;
18let container: HTMLDivElement;
19let rect: DOMRect;
20const scrollToIndex = vi.fn<LegendListRef["scrollToIndex"]>();
21const scrollToOffset = vi.fn<LegendListRef["scrollToOffset"]>();
22let props: ComponentProps<typeof Probe>;
23
24beforeEach(() => {
25 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
26 vi.stubGlobal("CSS", { highlights: new Map() });
27 vi.stubGlobal(
28 "Highlight",
29 class extends Set<Range> {
30 constructor(...ranges: Range[]) {
31 super(ranges);
32 }
33 },
34 );
35 rect = new DOMRect(0, 200, 40, 20);
36 Object.defineProperty(Range.prototype, "getBoundingClientRect", {
37 configurable: true,
38 value: () => rect,
39 });
40 host = document.createElement("div");
41 container = document.createElement("div");
42 container.innerHTML =
43 '<div data-timeline-row-id="message"><p data-thread-find-text>COD4 and COD4</p></div>';
44 container.getBoundingClientRect = () => new DOMRect(0, 0, 800, 600);
45 document.body.append(host, container);
46 root = createRoot(host);
47 scrollToIndex.mockResolvedValue();
48 props = {
49 container,
50 query: "COD4",
51 match: { entryId: "message", runId: null, occurrence: 0 },
52 navigationId: 0,
53 rowIndex: 0,
54 entries: [{ id: "message" }],
55 listRef: {
56 current: {
57 scrollToIndex,
58 scrollToOffset,
59 getState: () => ({ scroll: 300 }),
60 } as unknown as LegendListRef,
61 },
62 contentInsetEndAdjustment: 100,
63 listReady: true,
64 };
65});
66
67afterEach(async () => {
68 await act(async () => root.unmount());
69 host.remove();
70 container.remove();
71 Reflect.deleteProperty(Range.prototype, "getBoundingClientRect");
72 vi.clearAllMocks();
73 vi.unstubAllGlobals();
74});
75
76describe("find result navigation", () => {
77 it("retargets a pending first jump when progressive history moves the matching row", async () => {
78 container.replaceChildren();
79 let first!: () => void;
80 let second!: () => void;
81 scrollToIndex
82 .mockImplementationOnce(
83 () =>
84 new Promise<void>((resolve) => {
85 first = resolve;
86 }),
87 )
88 .mockImplementationOnce(
89 () =>
90 new Promise<void>((resolve) => {
91 second = resolve;
92 }),
93 );
94 await act(async () => root.render(<Probe {...props} rowIndex={2} />));
95 await act(async () =>
96 root.render(<Probe {...props} rowIndex={6} entries={[{ id: "tool" }, ...props.entries]} />),
97 );
98 expect(scrollToIndex.mock.calls.map(([request]) => request.index)).toEqual([2, 6]);
99 await act(async () => first());
100 expect(scrollToOffset).not.toHaveBeenCalled();
101 await act(async () => {
102 container.innerHTML =
103 '<div data-timeline-row-id="message"><p data-thread-find-text>COD4</p></div>';
104 rect = new DOMRect(0, 450, 40, 20);
105 second();
106 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
107 });
108 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false });
109 });
110
111 it("keeps the highlighted text at the same position when progressive activity arrives", async () => {
112 const historyControls = {
113 hasMoreHistory: true,
114 loading: true,
115 error: null,
116 onLoadEarlier: vi.fn(),
117 };
118 await act(async () => root.render(<Probe {...props} historyControls={historyControls} />));
119 rect = new DOMRect(0, 368, 40, 20);
120 scrollToOffset.mockImplementationOnce(async () => {
121 rect = new DOMRect(0, 200, 40, 20);
122 });
123 await act(async () =>
124 root.render(
125 <Probe
126 {...props}
127 entries={[{ id: "tool" }, { id: "message" }]}
128 historyControls={{ ...historyControls, loading: false }}
129 />,
130 ),
131 );
132 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 468, animated: false });
133 expect(scrollToIndex).not.toHaveBeenCalled();
134 });
135
136 it("respects scrolling away from a partial match before activity arrives", async () => {
137 const historyControls = {
138 hasMoreHistory: true,
139 loading: true,
140 error: null,
141 onLoadEarlier: vi.fn(),
142 };
143 await act(async () => root.render(<Probe {...props} historyControls={historyControls} />));
144 rect = new DOMRect(0, -600, 40, 20);
145 container.dispatchEvent(new Event("scroll"));
146 await act(async () =>
147 root.render(
148 <Probe
149 {...props}
150 entries={[{ id: "tool" }, { id: "message" }]}
151 historyControls={{ ...historyControls, loading: false }}
152 />,
153 ),
154 );
155 expect(scrollToOffset).not.toHaveBeenCalled();
156 });
157
158 it("reveals settled virtual text without another timeline render", async () => {
159 let complete!: () => void;
160 scrollToIndex.mockImplementationOnce(
161 () =>
162 new Promise<void>((resolve) => {
163 complete = resolve;
164 }),
165 );
166 rect = new DOMRect(0, 1800, 40, 20);
167 await act(async () => root.render(<Probe {...props} />));
168 expect(renders).toHaveBeenCalledTimes(1);
169 expect(scrollToOffset).not.toHaveBeenCalled();
170 rect = new DOMRect(0, 450, 40, 20);
171 await act(async () => {
172 complete();
173 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
174 });
175 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false });
176 expect(renders).toHaveBeenCalledTimes(1);
177 });
178
179 it("ignores an older scroll completion after navigation moves to another occurrence", async () => {
180 let first!: () => void;
181 let second!: () => void;
182 scrollToIndex
183 .mockImplementationOnce(
184 () =>
185 new Promise<void>((resolve) => {
186 first = resolve;
187 }),
188 )
189 .mockImplementationOnce(
190 () =>
191 new Promise<void>((resolve) => {
192 second = resolve;
193 }),
194 );
195 rect = new DOMRect(0, 1800, 40, 20);
196 await act(async () => root.render(<Probe {...props} />));
197 await act(async () =>
198 root.render(
199 <Probe
200 {...props}
201 navigationId={1}
202 match={{ entryId: "message", runId: null, occurrence: 1 }}
203 />,
204 ),
205 );
206 rect = new DOMRect(0, 450, 40, 20);
207 await act(async () => first());
208 expect(scrollToOffset).not.toHaveBeenCalled();
209 await act(async () => {
210 second();
211 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
212 });
213 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false });
214 });
215
216 it("loads history until a distant match can be revealed in the conversation", async () => {
217 const onLoadEarlier = vi.fn();
218 const historyControls = { hasMoreHistory: true, loading: false, error: null, onLoadEarlier };
219 const pending = { ...props, rowIndex: -1, entries: [{ id: "recent" }], historyControls };
220 await act(async () => root.render(<Probe {...pending} />));
221 expect(onLoadEarlier).toHaveBeenCalledTimes(1);
222 expect(scrollToIndex).not.toHaveBeenCalled();
223 await act(async () =>
224 root.render(<Probe {...pending} historyControls={{ ...historyControls, loading: true }} />),
225 );
226 expect(onLoadEarlier).toHaveBeenCalledTimes(1);
227 await act(async () =>
228 root.render(<Probe {...pending} entries={[{ id: "older" }, ...pending.entries]} />),
229 );
230 expect(onLoadEarlier).toHaveBeenCalledTimes(2);
231 rect = new DOMRect(0, 1800, 40, 20);
232 scrollToIndex.mockImplementationOnce(async () => {
233 rect = new DOMRect(0, 200, 40, 20);
234 });
235 await act(async () => root.render(<Probe {...props} historyControls={historyControls} />));
236 expect(onLoadEarlier).toHaveBeenCalledTimes(2);
237 expect(scrollToIndex).toHaveBeenCalledTimes(1);
238 });
239
240 it("waits for list bootstrap before positioning the first result", async () => {
241 rect = new DOMRect(0, 1800, 40, 20);
242 await act(async () => root.render(<Probe {...props} listReady={false} />));
243 expect(scrollToIndex).not.toHaveBeenCalled();
244 expect(scrollToOffset).not.toHaveBeenCalled();
245 scrollToIndex.mockImplementationOnce(async () => {
246 rect = new DOMRect(0, 200, 40, 20);
247 });
248 await act(async () => root.render(<Probe {...props} />));
249 expect(scrollToIndex).toHaveBeenCalledTimes(1);
250 expect(scrollToOffset).not.toHaveBeenCalled();
251 });
252 it("leaves the scroll position unchanged between already visible occurrences", async () => {
253 await act(async () => root.render(<Probe {...props} />));
254 await act(async () =>
255 root.render(
256 <Probe
257 {...props}
258 navigationId={1}
259 match={{ entryId: "message", runId: null, occurrence: 1 }}
260 />,
261 ),
262 );
263 expect(scrollToIndex).not.toHaveBeenCalled();
264 expect(scrollToOffset).not.toHaveBeenCalled();
265 const highlight = CSS.highlights.get("t3-thread-find-active");
266 const ranges = [...(highlight as unknown as Set<Range>)];
267 expect(ranges).toHaveLength(1);
268 expect(ranges[0]?.startOffset).toBe(9);
269 expect(ranges[0]?.toString()).toBe("COD4");
270 });
271
272 it("reveals the same match again when search restarts after scrolling away", async () => {
273 await act(async () => root.render(<Probe {...props} />));
274 rect = new DOMRect(0, -1800, 40, 20);
275 props.listRef.current = {
276 scrollToIndex,
277 scrollToOffset,
278 getState: () => ({ scroll: 2400 }),
279 } as unknown as LegendListRef;
280 await act(async () => root.render(<Probe {...props} navigationId={1} />));
281 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 504, animated: false });
282 expect(scrollToIndex).not.toHaveBeenCalled();
283 expect(
284 Array.from(CSS.highlights.get("t3-thread-find-active") ?? [], (range) => range.toString()),
285 ).toEqual(["COD4"]);
286 });
287
288 it("moves only far enough to reveal text below the composer", async () => {
289 await act(async () => root.render(<Probe {...props} />));
290 rect = new DOMRect(0, 450, 40, 20);
291 await act(async () =>
292 root.render(
293 <Probe
294 {...props}
295 navigationId={1}
296 match={{ entryId: "message", runId: null, occurrence: 1 }}
297 />,
298 ),
299 );
300 expect(scrollToIndex).not.toHaveBeenCalled();
301 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false });
302 });
303
304 it("moves only far enough to reveal text above the viewport margin", async () => {
305 await act(async () => root.render(<Probe {...props} />));
306 rect = new DOMRect(0, 40, 40, 20);
307 await act(async () =>
308 root.render(
309 <Probe
310 {...props}
311 navigationId={1}
312 match={{ entryId: "message", runId: null, occurrence: 1 }}
313 />,
314 ),
315 );
316 expect(scrollToIndex).not.toHaveBeenCalled();
317 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 244, animated: false });
318 });
319
320 it("keeps the selected text visible when newly measured rows shift during scrolling", async () => {
321 let scroll = 300;
322 props.listRef.current = {
323 scrollToIndex,
324 scrollToOffset,
325 getState: () => ({ scroll }),
326 } as unknown as LegendListRef;
327 scrollToOffset
328 .mockImplementationOnce(async ({ offset }) => {
329 scroll = offset;
330 // Newly mounted row measurements offset the first scroll movement.
331 })
332 .mockImplementationOnce(async ({ offset }) => {
333 scroll = offset;
334 rect = new DOMRect(0, 200, 40, 20);
335 });
336 await act(async () => root.render(<Probe {...props} />));
337 rect = new DOMRect(0, 450, 40, 20);
338 await act(async () =>
339 root.render(
340 <Probe
341 {...props}
342 navigationId={1}
343 match={{ entryId: "message", runId: null, occurrence: 1 }}
344 />,
345 ),
346 );
347 await act(async () => {
348 await new Promise<void>((resolve) =>
349 requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
350 );
351 });
352 expect(rect.top).toBe(200);
353 expect(scrollToOffset.mock.calls).toEqual([
354 [{ offset: 366, animated: false }],
355 [{ offset: 432, animated: false }],
356 ]);
357 expect(renders).toHaveBeenCalledTimes(2);
358 });
359
360 it("waits for a new context window to settle before revealing an offscreen result", async () => {
361 rect = new DOMRect(0, 1800, 40, 20);
362 scrollToIndex.mockImplementationOnce(async () => {
363 rect = new DOMRect(0, 450, 40, 20);
364 });
365 await act(async () => {
366 root.render(<Probe {...props} />);
367 });
368 await act(async () => {
369 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
370 });
371 expect(scrollToIndex).toHaveBeenCalledTimes(1);
372 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false });
373 });
374
375 it("materializes a virtualized message before revealing its occurrence", async () => {
376 container.replaceChildren();
377 await act(async () => root.render(<Probe {...props} rowIndex={4} />));
378 expect(scrollToIndex).toHaveBeenCalledExactlyOnceWith({
379 index: 4,
380 animated: false,
381 viewOffset: 96,
382 });
383 expect(scrollToOffset).not.toHaveBeenCalled();
384 rect = new DOMRect(0, 450, 40, 20);
385 await act(async () => {
386 container.innerHTML =
387 '<div data-timeline-row-id="message"><p data-thread-find-text>COD4</p></div>';
388 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
389 });
390 expect(scrollToIndex).toHaveBeenCalledTimes(1);
391 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false });
392 });
393});