Skip to content

Commit 1f533cb

Browse files
fix: unify search debounce and workspace resolution into single effect (#181) (#188)
Co-authored-by: Ona <no-reply@ona.com>
1 parent 72f30c9 commit 1f533cb

2 files changed

Lines changed: 131 additions & 108 deletions

File tree

src/components/sidebar/page-search.test.tsx

Lines changed: 96 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -364,23 +364,24 @@ describe("PageSearch", () => {
364364
await user.click(input);
365365
await user.type(input, "test query");
366366

367-
// Advance past debounce — search fires but workspaceId is null so it
368-
// returns immediately with searchStatus="done"
367+
// Advance time — the unified effect stays in "loading" because
368+
// workspace hasn't resolved (no fetch fires).
369369
await act(async () => {
370370
await vi.advanceTimersByTimeAsync(350);
371371
});
372372

373-
// Even though search is done, workspace hasn't resolved so skeletons
374-
// should still show (not the empty state)
373+
// Skeletons should show while workspace is resolving
375374
const searchResults = screen.getByRole("listbox");
376375
const skeletons = searchResults.querySelectorAll(".animate-pulse");
377376
expect(skeletons.length).toBeGreaterThan(0);
378377
expect(
379378
screen.queryByText("No pages match your search")
380379
).not.toBeInTheDocument();
381380

382-
// Now resolve the workspace — the workspace-resolved effect
383-
// re-triggers the search immediately (no debounce).
381+
// No fetch should have been made (workspace not resolved)
382+
expect(fetchMock).not.toHaveBeenCalled();
383+
384+
// Resolve workspace — let the promise settle and React re-render.
384385
await act(async () => {
385386
resolveWorkspace!({
386387
data: { id: "ws-uuid-123" },
@@ -389,6 +390,11 @@ describe("PageSearch", () => {
389390
await vi.advanceTimersByTimeAsync(50);
390391
});
391392

393+
// Advance past the 300ms debounce so the search fires.
394+
await act(async () => {
395+
await vi.advanceTimersByTimeAsync(350);
396+
});
397+
392398
// Now the empty state should show
393399
expect(
394400
screen.getByText("No pages match your search")
@@ -397,10 +403,8 @@ describe("PageSearch", () => {
397403

398404
it("shows empty state when workspace resolves to null (workspace not found)", async () => {
399405
// Regression test for #162: when workspaceId is null and
400-
// workspaceResolved is true, the old code left `searched=false`
401-
// in the early return path, causing neither skeletons nor empty
402-
// state to render. The new searchStatus="done" transition in the
403-
// early return path fixes this.
406+
// workspaceResolved is true, the unified effect transitions
407+
// directly to "done" without firing a fetch.
404408
mockMaybeSingle.mockResolvedValue({
405409
data: null,
406410
error: null,
@@ -421,10 +425,10 @@ describe("PageSearch", () => {
421425
await user.click(input);
422426
await user.type(input, "zzzyyyxxxnonexistent999");
423427

424-
// Advance past debounce — search fires, workspaceId is null,
425-
// early return sets searchStatus="done"
428+
// The unified effect sees workspaceResolved=true but workspaceId=null,
429+
// so it transitions directly to "done" (no debounce, no fetch).
426430
await act(async () => {
427-
await vi.advanceTimersByTimeAsync(350);
431+
await vi.advanceTimersByTimeAsync(50);
428432
});
429433

430434
// The empty state should show (not stuck in a blank dropdown)
@@ -507,13 +511,10 @@ describe("PageSearch", () => {
507511
});
508512

509513
it("shows empty state when workspace resolves after debounce fires (#178)", async () => {
510-
// Regression test for #178: when workspace resolution is slow and
511-
// completes after the debounce timer fires, the old code re-ran the
512-
// debounce effect (because the search callback changed identity),
513-
// creating a cascade of loading→done→loading transitions that could
514-
// leave searchStatus stuck at "loading". The fix decouples the search
515-
// callback from workspaceId by using a ref, and adds a dedicated
516-
// effect to re-trigger search when the workspace resolves.
514+
// Regression test for #178/#181: when workspace resolution is slow,
515+
// the unified effect stays in "loading" until the workspace resolves.
516+
// Once resolved, the effect re-runs, debounces, and fires the search.
517+
// This eliminates the two-effect race condition that caused #178/#181.
517518

518519
// Make workspace resolution slow
519520
let resolveWorkspace: (value: unknown) => void;
@@ -533,18 +534,21 @@ describe("PageSearch", () => {
533534
await user.click(input);
534535
await user.type(input, "zzzyyyxxxnonexistent999");
535536

536-
// Advance past debounce — search fires but workspaceId is null
537+
// Advance time — the unified effect stays in "loading" because
538+
// workspace hasn't resolved (no fetch fires).
537539
await act(async () => {
538540
await vi.advanceTimersByTimeAsync(350);
539541
});
540542

541-
// Skeletons should show (workspace not resolved)
543+
// Skeletons should show (workspace not resolved, status is "loading")
542544
const searchResults = screen.getByRole("listbox");
543545
expect(searchResults.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
544546
expect(screen.queryByText("No pages match your search")).not.toBeInTheDocument();
545547

546-
// Resolve workspace — the workspace-resolved effect fires a new
547-
// search immediately (no 300ms debounce delay).
548+
// No fetch yet
549+
expect(fetchMock).not.toHaveBeenCalled();
550+
551+
// Resolve workspace — let the promise settle and React re-render.
548552
await act(async () => {
549553
resolveWorkspace!({
550554
data: { id: "ws-uuid-123" },
@@ -553,6 +557,11 @@ describe("PageSearch", () => {
553557
await vi.advanceTimersByTimeAsync(50);
554558
});
555559

560+
// Advance past the 300ms debounce so the search fires.
561+
await act(async () => {
562+
await vi.advanceTimersByTimeAsync(350);
563+
});
564+
556565
// The fetch should have been called with the workspace ID
557566
expect(fetchMock).toHaveBeenCalledWith(
558567
expect.stringContaining("workspace_id=ws-uuid-123"),
@@ -567,9 +576,9 @@ describe("PageSearch", () => {
567576
});
568577

569578
it("shows empty state when workspace resolution rejects (#178)", async () => {
570-
// Regression test for #178: the old code had no .catch() on the
571-
// workspace resolution promise. If retryOnNetworkError rejected,
572-
// workspaceResolved stayed false forever, causing infinite skeletons.
579+
// Regression test for #178: if retryOnNetworkError rejects,
580+
// workspaceResolved is set to true (from .catch()) and workspaceId
581+
// stays null. The unified effect transitions directly to "done".
573582
const captureException = vi.mocked(
574583
(await import("@sentry/nextjs")).captureException
575584
);
@@ -593,18 +602,18 @@ describe("PageSearch", () => {
593602
await user.click(input);
594603
await user.type(input, "zzzyyyxxxnonexistent999");
595604

596-
// Advance past debounce
605+
// The unified effect sees workspaceResolved=true but workspaceId=null,
606+
// so it transitions directly to "done" (no debounce, no fetch).
597607
await act(async () => {
598-
await vi.advanceTimersByTimeAsync(350);
608+
await vi.advanceTimersByTimeAsync(50);
599609
});
600610

601611
// The error should have been captured in Sentry
602612
expect(captureException).toHaveBeenCalledWith(
603613
expect.objectContaining({ message: "Unexpected Supabase error" })
604614
);
605615

606-
// Empty state should show (workspaceResolved=true from .catch(),
607-
// workspaceId=null, so search early-returns with done status)
616+
// Empty state should show (workspaceResolved=true, workspaceId=null)
608617
expect(
609618
screen.getByText("No pages match your search")
610619
).toBeInTheDocument();
@@ -613,4 +622,60 @@ describe("PageSearch", () => {
613622
const searchResults = screen.getByRole("listbox");
614623
expect(searchResults.querySelectorAll(".animate-pulse").length).toBe(0);
615624
});
625+
626+
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.
632+
633+
// Make workspace resolution resolve after 150ms (mid-debounce)
634+
mockMaybeSingle.mockReturnValue(
635+
new Promise((resolve) => {
636+
setTimeout(() => {
637+
resolve({ data: { id: "ws-uuid-123" }, error: null });
638+
}, 150);
639+
})
640+
);
641+
642+
const user = userEvent.setup({
643+
advanceTimers: vi.advanceTimersByTime,
644+
});
645+
646+
render(<PageSearch />);
647+
648+
const input = screen.getByRole("combobox", { name: /search pages/i });
649+
await user.click(input);
650+
await user.type(input, "zzzyyyxxxnonexistent999");
651+
652+
// At this point: query is set, workspace is resolving, effect is
653+
// in "loading" state waiting for workspace.
654+
655+
// Advance 150ms — workspace resolves mid-debounce
656+
await act(async () => {
657+
await vi.advanceTimersByTimeAsync(150);
658+
});
659+
660+
// Skeletons should still show (debounce hasn't fired yet)
661+
const searchResults = screen.getByRole("listbox");
662+
expect(searchResults.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
663+
664+
// Advance past the 300ms debounce
665+
await act(async () => {
666+
await vi.advanceTimersByTimeAsync(350);
667+
});
668+
669+
// Empty state should show — skeletons must NOT be stuck
670+
expect(
671+
screen.getByText("No pages match your search")
672+
).toBeInTheDocument();
673+
expect(searchResults.querySelectorAll(".animate-pulse").length).toBe(0);
674+
675+
// Fetch should have been called with the workspace ID
676+
expect(fetchMock).toHaveBeenCalledWith(
677+
expect.stringContaining("workspace_id=ws-uuid-123"),
678+
expect.objectContaining({ signal: expect.any(AbortSignal) })
679+
);
680+
});
616681
});

0 commit comments

Comments
 (0)