|
1 | 1 | import "@testing-library/jest-dom/vitest"; |
2 | | -import { render, screen, act } from "@testing-library/react"; |
| 2 | +import { render, screen, act, fireEvent } from "@testing-library/react"; |
3 | 3 | import userEvent from "@testing-library/user-event"; |
4 | 4 | import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; |
5 | 5 |
|
@@ -54,6 +54,12 @@ import { PageSearch } from "./page-search"; |
54 | 54 | describe("PageSearch", () => { |
55 | 55 | beforeEach(() => { |
56 | 56 | vi.useFakeTimers({ shouldAdvanceTime: true }); |
| 57 | + // Reset workspace mock — vi.restoreAllMocks() in afterEach clears |
| 58 | + // the mock implementation, so we must re-set it each test. |
| 59 | + mockMaybeSingle.mockResolvedValue({ |
| 60 | + data: { id: "ws-uuid-123" }, |
| 61 | + error: null, |
| 62 | + }); |
57 | 63 | fetchMock = vi.fn().mockResolvedValue({ |
58 | 64 | ok: true, |
59 | 65 | json: async () => ({ results: [] }), |
@@ -279,13 +285,12 @@ describe("PageSearch", () => { |
279 | 285 | }); |
280 | 286 |
|
281 | 287 | it("shows empty state even when aborted fetch finally block races with new cycle", async () => { |
282 | | - // Regression test for the microtask ordering bug: when the debounce |
283 | | - // effect re-runs (e.g. because workspaceId resolved), it aborts the |
284 | | - // old controller and sets loading=true synchronously. The aborted |
285 | | - // fetch's finally block runs later as a microtask. If the finally |
286 | | - // block used signal.aborted to decide whether to clear loading, a |
287 | | - // race could leave loading=true permanently. The generation counter |
288 | | - // prevents this. |
| 288 | + // Regression test for the microtask ordering bug: when the effect |
| 289 | + // re-runs (e.g. because workspaceId resolved), it aborts the old |
| 290 | + // controller and sets loading=true synchronously. The aborted |
| 291 | + // fetch's finally block runs later as a microtask. The cancelled |
| 292 | + // flag (set in the effect cleanup) prevents stale callbacks from |
| 293 | + // updating state. |
289 | 294 |
|
290 | 295 | // First fetch resolves synchronously (simulating a fetch that |
291 | 296 | // completes at the same tick the abort fires) |
@@ -623,12 +628,101 @@ describe("PageSearch", () => { |
623 | 628 | expect(searchResults.querySelectorAll(".animate-pulse").length).toBe(0); |
624 | 629 | }); |
625 | 630 |
|
| 631 | + it("cancelled flag prevents stale finally block from blocking new cycle (#192)", async () => { |
| 632 | + // Regression test for #192: the generation counter pattern could |
| 633 | + // leave searchStatus stuck at "loading" if the effect re-ran between |
| 634 | + // fetch start and completion. The cancelled flag fixes this: it's |
| 635 | + // set once in cleanup and stays true, so the stale finally block is |
| 636 | + // discarded and the new cycle completes independently. |
| 637 | + |
| 638 | + // First fetch hangs, second resolves immediately. |
| 639 | + let resolveFetch1: (value: Response) => void; |
| 640 | + const fetchCalls: string[] = []; |
| 641 | + fetchMock.mockImplementation( |
| 642 | + (url: string | URL | Request, init?: RequestInit) => { |
| 643 | + const urlStr = typeof url === "string" ? url : url.toString(); |
| 644 | + fetchCalls.push(urlStr); |
| 645 | + const signal = init?.signal; |
| 646 | + if (fetchCalls.length === 1) { |
| 647 | + return new Promise<Response>((resolve, reject) => { |
| 648 | + resolveFetch1 = resolve; |
| 649 | + signal?.addEventListener("abort", () => { |
| 650 | + reject(new DOMException("Aborted", "AbortError")); |
| 651 | + }); |
| 652 | + }); |
| 653 | + } |
| 654 | + return Promise.resolve( |
| 655 | + new Response(JSON.stringify({ results: [] }), { |
| 656 | + status: 200, |
| 657 | + headers: { "Content-Type": "application/json" }, |
| 658 | + }), |
| 659 | + ); |
| 660 | + }, |
| 661 | + ); |
| 662 | + |
| 663 | + render(<PageSearch />); |
| 664 | + |
| 665 | + // Wait for workspace resolution |
| 666 | + await act(async () => { |
| 667 | + await vi.advanceTimersByTimeAsync(50); |
| 668 | + }); |
| 669 | + |
| 670 | + const input = screen.getByRole("combobox", { name: /search pages/i }); |
| 671 | + |
| 672 | + // Set the first query |
| 673 | + await act(async () => { |
| 674 | + fireEvent.focus(input); |
| 675 | + fireEvent.change(input, { target: { value: "first" } }); |
| 676 | + }); |
| 677 | + |
| 678 | + // Advance past debounce — first fetch fires (hangs) |
| 679 | + await act(async () => { |
| 680 | + await vi.advanceTimersByTimeAsync(350); |
| 681 | + }); |
| 682 | + |
| 683 | + expect(fetchCalls.length).toBe(1); |
| 684 | + expect(fetchCalls[0]).toContain("first"); |
| 685 | + |
| 686 | + // Change query — effect cleanup cancels first cycle |
| 687 | + await act(async () => { |
| 688 | + fireEvent.change(input, { target: { value: "second" } }); |
| 689 | + }); |
| 690 | + |
| 691 | + // Advance past debounce — second fetch fires and resolves |
| 692 | + await act(async () => { |
| 693 | + await vi.advanceTimersByTimeAsync(350); |
| 694 | + }); |
| 695 | + |
| 696 | + expect(fetchCalls.length).toBe(2); |
| 697 | + expect(fetchCalls[1]).toContain("second"); |
| 698 | + |
| 699 | + // Now resolve the first (stale) fetch — its .then() runs but |
| 700 | + // cancelledRef is true for that cycle, so it's discarded. |
| 701 | + await act(async () => { |
| 702 | + resolveFetch1!( |
| 703 | + new Response(JSON.stringify({ results: [] }), { |
| 704 | + status: 200, |
| 705 | + headers: { "Content-Type": "application/json" }, |
| 706 | + }), |
| 707 | + ); |
| 708 | + await vi.advanceTimersByTimeAsync(50); |
| 709 | + }); |
| 710 | + |
| 711 | + // The empty state should be visible (from the second fetch) |
| 712 | + expect( |
| 713 | + screen.getByText("No pages match your search"), |
| 714 | + ).toBeInTheDocument(); |
| 715 | + |
| 716 | + // No skeletons should be stuck |
| 717 | + const searchResults = screen.getByRole("listbox"); |
| 718 | + expect(searchResults.querySelectorAll(".animate-pulse").length).toBe(0); |
| 719 | + }); |
| 720 | + |
626 | 721 | it("never leaves skeletons stuck when workspace resolves mid-debounce (#181)", async () => { |
627 | | - // Regression test for #181: the two-effect architecture (debounce + |
628 | | - // re-trigger) could leave searchStatus stuck at "loading" when the |
629 | | - // workspace resolved at specific timings. The unified effect approach |
630 | | - // eliminates this by handling workspace resolution as a dependency |
631 | | - // change that naturally re-runs the effect. |
| 722 | + // Regression test for #181/#192: workspace resolution mid-debounce |
| 723 | + // must not leave searchStatus stuck at "loading". The cancelled flag |
| 724 | + // ensures the old cycle's callbacks are discarded and the new cycle |
| 725 | + // completes normally. |
632 | 726 |
|
633 | 727 | // Make workspace resolution resolve after 150ms (mid-debounce) |
634 | 728 | mockMaybeSingle.mockReturnValue( |
|
0 commit comments