@@ -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 : / s e a r c h p a g e s / 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