1- import { Component , type ReactNode , Suspense , useState } from 'react' ;
1+ import { Suspense , useState } from 'react' ;
22import { useDebouncedValue } from '@tanstack/react-pacer' ;
3+ import { ErrorBoundary } from 'react-error-boundary' ;
4+ import { QueryErrorResetBoundary } from '@tanstack/react-query' ;
35import { PageIdFetcher } from './PageIdFetcher' ;
46
57interface PageTitleInputProps {
68 initialPageTitle : string ;
79 wikiUrl : URL ;
810}
911
10- interface ErrorBoundaryProps {
11- children : ReactNode ;
12- fallback : ReactNode ;
13- }
14-
15- interface ErrorBoundaryState {
16- hasError : boolean ;
17- }
18-
19- class ErrorBoundary extends Component < ErrorBoundaryProps , ErrorBoundaryState > {
20- constructor ( props : ErrorBoundaryProps ) {
21- super ( props ) ;
22- this . state = { hasError : false } ;
23- }
24-
25- static getDerivedStateFromError ( ) : ErrorBoundaryState {
26- return { hasError : true } ;
27- }
28-
29- componentDidCatch ( error : Error ) : void {
30- console . error ( 'Error fetching page ID:' , error ) ;
31- }
32-
33- render ( ) {
34- if ( this . state . hasError ) {
35- return this . props . fallback ;
36- }
37- return this . props . children ;
38- }
39- }
12+ const isNotFoundError = ( error : unknown ) : boolean =>
13+ error instanceof Error &&
14+ error . cause instanceof Response &&
15+ error . cause . status === 404 ;
4016
4117export function PageTitleInput ( {
4218 initialPageTitle,
@@ -58,33 +34,48 @@ export function PageTitleInput({
5834 required
5935 />
6036
61- < ErrorBoundary
62- fallback = {
63- < >
64- < div className = "status-message-container" >
65- < span className = "error-message" >
66- Error fetching page ID. Please try again.
67- </ span >
68- </ div >
69- < input
70- type = "hidden"
71- name = "pageId"
72- value = ""
73- data-testid = "pageId-input"
74- />
75- </ >
76- }
77- >
78- < Suspense
79- fallback = {
80- < div className = "status-message-container" >
81- < span className = "loading-indicator" > Checking title...</ span >
82- </ div >
83- }
84- >
85- < PageIdFetcher wikiUrl = { wikiUrl } pageTitle = { debouncedTitle } />
86- </ Suspense >
87- </ ErrorBoundary >
37+ < QueryErrorResetBoundary >
38+ { ( { reset } ) => (
39+ < ErrorBoundary
40+ resetKeys = { [ debouncedTitle ] }
41+ onReset = { reset }
42+ fallbackRender = { ( { error, resetErrorBoundary } ) => (
43+ < >
44+ < div className = "status-message-container" >
45+ < span className = "error-message" >
46+ { isNotFoundError ( error ) ? (
47+ 'Page not found.'
48+ ) : (
49+ < >
50+ Error fetching page ID.{ ' ' }
51+ < button type = "button" onClick = { resetErrorBoundary } >
52+ Try again
53+ </ button >
54+ </ >
55+ ) }
56+ </ span >
57+ </ div >
58+ < input
59+ type = "hidden"
60+ name = "pageId"
61+ value = ""
62+ data-testid = "pageId-input"
63+ />
64+ </ >
65+ ) }
66+ >
67+ < Suspense
68+ fallback = {
69+ < div className = "status-message-container" >
70+ < span className = "loading-indicator" > Checking title...</ span >
71+ </ div >
72+ }
73+ >
74+ < PageIdFetcher wikiUrl = { wikiUrl } pageTitle = { debouncedTitle } />
75+ </ Suspense >
76+ </ ErrorBoundary >
77+ ) }
78+ </ QueryErrorResetBoundary >
8879 </ div >
8980 ) ;
9081}
@@ -259,8 +250,11 @@ if (import.meta.vitest) {
259250 expect ( hiddenInput . value ) . toBe ( '123' ) ;
260251 } ) ;
261252
262- it ( 'displays error message and sets empty hidden input when fetch fails' , async ( ) => {
263- mockFetchPageId . mockRejectedValue ( new Error ( 'API Error' ) ) ;
253+ it ( 'displays 404 error message without retry button' , async ( ) => {
254+ const mockResponse = new Response ( 'Not Found' , { status : 404 } ) ;
255+ mockFetchPageId . mockRejectedValue (
256+ new Error ( 'Failed to fetch page ID' , { cause : mockResponse } )
257+ ) ;
264258 const consoleErrorSpy = vi
265259 . spyOn ( console , 'error' )
266260 . mockImplementation ( ( ) => { } ) ;
@@ -273,26 +267,187 @@ if (import.meta.vitest) {
273267 />
274268 )
275269 ) ;
276- // Wait for the API call
277- expect ( mockFetchPageId ) . toHaveBeenCalledTimes ( 1 ) ;
278- expect ( screen . getByTestId ( 'pageId-input' ) ) . toBeInTheDocument ( ) ;
270+
271+ const errorMessage = await screen . findByText ( 'Page not found.' ) ;
272+ expect ( errorMessage ) . toBeInTheDocument ( ) ;
273+
274+ // Should NOT have a retry button for 404
275+ expect (
276+ screen . queryByRole ( 'button' , { name : / t r y a g a i n / i } )
277+ ) . not . toBeInTheDocument ( ) ;
279278
280279 const hiddenInput = screen . getByTestId (
281280 'pageId-input'
282281 ) as HTMLInputElement ;
283282 expect ( hiddenInput . value ) . toBe ( '' ) ;
284- expect ( consoleErrorSpy ) . toHaveBeenCalledWith (
285- 'Error fetching page ID:' ,
286- expect . any ( Error )
283+ consoleErrorSpy . mockRestore ( ) ;
284+ } ) ;
285+
286+ it ( 'displays error message with retry button for network errors' , async ( ) => {
287+ mockFetchPageId . mockRejectedValue ( new Error ( 'Network error' ) ) ;
288+ const consoleErrorSpy = vi
289+ . spyOn ( console , 'error' )
290+ . mockImplementation ( ( ) => { } ) ;
291+
292+ await act ( async ( ) =>
293+ renderWithQuery (
294+ < PageTitleInput
295+ initialPageTitle = "Test Title"
296+ wikiUrl = { stubWikiUrl }
297+ />
298+ )
287299 ) ;
300+
288301 const errorMessage = await screen . findByText (
289- 'Error fetching page ID. Please try again.' ,
290- { } ,
291- {
292- timeout : 300 ,
293- }
302+ / E r r o r f e t c h i n g p a g e I D \. /
303+ ) ;
304+ expect ( errorMessage ) . toBeInTheDocument ( ) ;
305+
306+ // Should have a retry button for network errors
307+ const retryButton = screen . getByRole ( 'button' , { name : / t r y a g a i n / i } ) ;
308+ expect ( retryButton ) . toBeInTheDocument ( ) ;
309+ expect ( retryButton ) . toHaveAttribute ( 'type' , 'button' ) ;
310+
311+ const hiddenInput = screen . getByTestId (
312+ 'pageId-input'
313+ ) as HTMLInputElement ;
314+ expect ( hiddenInput . value ) . toBe ( '' ) ;
315+ consoleErrorSpy . mockRestore ( ) ;
316+ } ) ;
317+
318+ it ( 'displays error message with retry button for 5XX server errors' , async ( ) => {
319+ const mockResponse = new Response ( 'Server Error' , { status : 500 } ) ;
320+ mockFetchPageId . mockRejectedValue (
321+ new Error ( 'Failed to fetch page ID' , { cause : mockResponse } )
322+ ) ;
323+ const consoleErrorSpy = vi
324+ . spyOn ( console , 'error' )
325+ . mockImplementation ( ( ) => { } ) ;
326+
327+ await act ( async ( ) =>
328+ renderWithQuery (
329+ < PageTitleInput
330+ initialPageTitle = "Test Title"
331+ wikiUrl = { stubWikiUrl }
332+ />
333+ )
334+ ) ;
335+
336+ const errorMessage = await screen . findByText (
337+ / E r r o r f e t c h i n g p a g e I D \. /
338+ ) ;
339+ expect ( errorMessage ) . toBeInTheDocument ( ) ;
340+
341+ // Should have a retry button for 5XX errors
342+ const retryButton = screen . getByRole ( 'button' , { name : / t r y a g a i n / i } ) ;
343+ expect ( retryButton ) . toBeInTheDocument ( ) ;
344+
345+ const hiddenInput = screen . getByTestId (
346+ 'pageId-input'
347+ ) as HTMLInputElement ;
348+ expect ( hiddenInput . value ) . toBe ( '' ) ;
349+ consoleErrorSpy . mockRestore ( ) ;
350+ } ) ;
351+
352+ it ( 'clicking retry button re-fetches page ID successfully' , async ( ) => {
353+ const user = userEvent . setup ( ) ;
354+ // First call fails, second succeeds
355+ mockFetchPageId
356+ . mockRejectedValueOnce ( new Error ( 'Network error' ) )
357+ . mockResolvedValueOnce ( 456 ) ;
358+
359+ const consoleErrorSpy = vi
360+ . spyOn ( console , 'error' )
361+ . mockImplementation ( ( ) => { } ) ;
362+
363+ await act ( async ( ) =>
364+ renderWithQuery (
365+ < PageTitleInput
366+ initialPageTitle = "Test Title"
367+ wikiUrl = { stubWikiUrl }
368+ />
369+ )
370+ ) ;
371+
372+ // Wait for error state
373+ const retryButton = await screen . findByRole ( 'button' , {
374+ name : / t r y a g a i n / i,
375+ } ) ;
376+ expect ( retryButton ) . toBeInTheDocument ( ) ;
377+ expect ( mockFetchPageId ) . toHaveBeenCalledTimes ( 1 ) ;
378+
379+ // Click retry
380+ await user . click ( retryButton ) ;
381+
382+ // Should show loading state
383+ const loadingIndicator = await screen . findByText ( 'Checking title...' ) ;
384+ expect ( loadingIndicator ) . toBeInTheDocument ( ) ;
385+
386+ // Wait for success state
387+ await waitForElementToBeRemoved ( ( ) =>
388+ screen . getByText ( 'Checking title...' )
389+ ) ;
390+
391+ expect ( mockFetchPageId ) . toHaveBeenCalledTimes ( 2 ) ;
392+ const hiddenInput = screen . getByTestId (
393+ 'pageId-input'
394+ ) as HTMLInputElement ;
395+ expect ( hiddenInput . value ) . toBe ( '456' ) ;
396+ consoleErrorSpy . mockRestore ( ) ;
397+ } ) ;
398+
399+ it ( 'recovers from error when typing a different title' , async ( ) => {
400+ const user = userEvent . setup ( ) ;
401+ // First call fails for "Test Title", second succeeds for "New Title"
402+ mockFetchPageId
403+ . mockRejectedValueOnce ( new Error ( 'Network error' ) )
404+ . mockResolvedValueOnce ( 789 ) ;
405+
406+ const consoleErrorSpy = vi
407+ . spyOn ( console , 'error' )
408+ . mockImplementation ( ( ) => { } ) ;
409+
410+ await act ( async ( ) =>
411+ renderWithQuery (
412+ < PageTitleInput
413+ initialPageTitle = "Test Title"
414+ wikiUrl = { stubWikiUrl }
415+ />
416+ )
417+ ) ;
418+
419+ // Wait for error state
420+ const errorMessage = await screen . findByText (
421+ / E r r o r f e t c h i n g p a g e I D \. /
294422 ) ;
295423 expect ( errorMessage ) . toBeInTheDocument ( ) ;
424+ expect ( mockFetchPageId ) . toHaveBeenCalledTimes ( 1 ) ;
425+
426+ // Change the title
427+ const inputElement = screen . getByLabelText ( / W i k i A r t i c l e T i t l e : / i) ;
428+ await user . clear ( inputElement ) ;
429+ await user . type ( inputElement , 'New Title' ) ;
430+
431+ // Should show loading state
432+ const loadingIndicator = await screen . findByText (
433+ 'Checking title...' ,
434+ { } ,
435+ { timeout : 350 }
436+ ) ;
437+ expect ( loadingIndicator ) . toBeInTheDocument ( ) ;
438+
439+ // Wait for success state
440+ await waitForElementToBeRemoved (
441+ ( ) => screen . getByText ( 'Checking title...' ) ,
442+ { timeout : 1000 }
443+ ) ;
444+
445+ expect ( mockFetchPageId ) . toHaveBeenCalledTimes ( 2 ) ;
446+ const hiddenInput = screen . getByTestId (
447+ 'pageId-input'
448+ ) as HTMLInputElement ;
449+ expect ( hiddenInput . value ) . toBe ( '789' ) ;
450+ consoleErrorSpy . mockRestore ( ) ;
296451 } ) ;
297452 } ) ;
298453
0 commit comments