@@ -79,9 +79,10 @@ describe("PageSearch", () => {
7979 await vi . advanceTimersByTimeAsync ( 350 ) ;
8080 } ) ;
8181
82- // The fetch should have been called
82+ // The fetch should have been called with the query and an abort signal
8383 expect ( fetchMock ) . toHaveBeenCalledWith (
84- expect . stringContaining ( "zzzyyyxxxnonexistent999" )
84+ expect . stringContaining ( "zzzyyyxxxnonexistent999" ) ,
85+ expect . objectContaining ( { signal : expect . any ( AbortSignal ) } )
8586 ) ;
8687
8788 // The empty state message should be visible
@@ -144,6 +145,72 @@ describe("PageSearch", () => {
144145 ) . toBeInTheDocument ( ) ;
145146 } ) ;
146147
148+ it ( "aborts stale fetch when query changes rapidly" , async ( ) => {
149+ // Track abort signals to verify cancellation
150+ const signals : AbortSignal [ ] = [ ] ;
151+ fetchMock . mockImplementation ( ( _url : string | URL | Request , init ?: RequestInit ) => {
152+ if ( init ?. signal ) signals . push ( init . signal ) ;
153+ return new Promise < Response > ( ( resolve ) => {
154+ // Resolve after a delay, but only if not aborted
155+ setTimeout ( ( ) => {
156+ resolve ( new Response ( JSON . stringify ( { results : [ ] } ) , {
157+ status : 200 ,
158+ headers : { "Content-Type" : "application/json" } ,
159+ } ) ) ;
160+ } , 100 ) ;
161+ } ) ;
162+ } ) ;
163+
164+ const user = userEvent . setup ( {
165+ advanceTimers : vi . advanceTimersByTime ,
166+ } ) ;
167+
168+ render ( < PageSearch /> ) ;
169+
170+ // Wait for workspace resolution
171+ await act ( async ( ) => {
172+ await vi . advanceTimersByTimeAsync ( 50 ) ;
173+ } ) ;
174+
175+ const input = screen . getByRole ( "combobox" , { name : / s e a r c h p a g e s / i } ) ;
176+ await user . click ( input ) ;
177+ await user . type ( input , "first" ) ;
178+
179+ // Advance past debounce to trigger first fetch
180+ await act ( async ( ) => {
181+ await vi . advanceTimersByTimeAsync ( 350 ) ;
182+ } ) ;
183+
184+ // First fetch should be in-flight
185+ expect ( signals . length ) . toBe ( 1 ) ;
186+ expect ( signals [ 0 ] . aborted ) . toBe ( false ) ;
187+
188+ // Type a new query before first fetch resolves
189+ await user . clear ( input ) ;
190+ await user . type ( input , "second" ) ;
191+
192+ // The first signal should now be aborted
193+ expect ( signals [ 0 ] . aborted ) . toBe ( true ) ;
194+
195+ // Advance past debounce for second query
196+ await act ( async ( ) => {
197+ await vi . advanceTimersByTimeAsync ( 350 ) ;
198+ } ) ;
199+
200+ // Second fetch should have been called
201+ expect ( signals . length ) . toBe ( 2 ) ;
202+
203+ // Let second fetch resolve
204+ await act ( async ( ) => {
205+ await vi . advanceTimersByTimeAsync ( 150 ) ;
206+ } ) ;
207+
208+ // Should show empty state (second query returned no results)
209+ expect (
210+ screen . getByText ( "No pages match your search" )
211+ ) . toBeInTheDocument ( ) ;
212+ } ) ;
213+
147214 it ( "shows skeletons while workspace is resolving even after search completes" , async ( ) => {
148215 // Make workspace resolution hang
149216 let resolveWorkspace : ( value : unknown ) => void ;
0 commit comments