| 1 | // @vitest-environment jsdom |
| 2 | |
| 3 | import type { LegendListRef } from "@legendapp/list/react"; |
| 4 | import { act, type ComponentProps } from "react"; |
| 5 | import { createRoot, type Root } from "react-dom/client"; |
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 7 | import { useThreadFindNavigation } from "./useThreadFindNavigation"; |
| 8 | |
| 9 | const renders = vi.fn(); |
| 10 | function Probe(props: Parameters<typeof useThreadFindNavigation>[0]) { |
| 11 | renders(); |
| 12 | useThreadFindNavigation(props); |
| 13 | return null; |
| 14 | } |
| 15 | |
| 16 | let root: Root; |
| 17 | let host: HTMLDivElement; |
| 18 | let container: HTMLDivElement; |
| 19 | let rect: DOMRect; |
| 20 | const scrollToIndex = vi.fn<LegendListRef["scrollToIndex"]>(); |
| 21 | const scrollToOffset = vi.fn<LegendListRef["scrollToOffset"]>(); |
| 22 | let props: ComponentProps<typeof Probe>; |
| 23 | |
| 24 | beforeEach(() => { |
| 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 | |
| 67 | afterEach(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 | |
| 76 | describe("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 | }); |