@@ -17,8 +17,6 @@ export interface RetryableImageProps extends ImageProps {
1717 retryDelay ?: number
1818 onRetryExhausted ?: ( ) => void
1919 onRetry ?: ( attempt : number ) => void
20- nonRetryableStatusCodes ?: number [ ]
21- onNonRetryableError ?: ( statusCode ?: number ) => void
2220 showRetryButton ?: boolean
2321 compactError ?: boolean
2422 isLoading ?: boolean
@@ -27,18 +25,6 @@ export interface RetryableImageProps extends ImageProps {
2725 intersectionRoot ?: Element | null
2826}
2927
30- const DEFAULT_NON_RETRYABLE = [
31- 400 , 401 , 402 , 403 , 404 , 405 , 406 , 407 , 408 , 409 , 410 , 411 , 412 , 413 , 414 ,
32- 415 , 416 , 417 , 418 , 421 , 422 , 423 , 424 , 425 , 426 , 428 , 429 , 431 , 451 , 500 ,
33- 501 , 502 , 503 , 504 , 505 , 506 , 507 , 508 , 510 , 511 ,
34- ]
35-
36- // Minimal in-flight dedupe map
37- const inflight = new Map <
38- string ,
39- Promise < { ok : true } | { ok : false ; status ?: number ; message : string } >
40- > ( )
41-
4228const baseUrl = ( url ?: string ) => {
4329 if ( ! url ) return ""
4430 try {
@@ -59,8 +45,6 @@ export default function RetryableImage(props: RetryableImageProps) {
5945 retryDelay = 10000 ,
6046 onRetryExhausted,
6147 onRetry,
62- nonRetryableStatusCodes = DEFAULT_NON_RETRYABLE ,
63- onNonRetryableError,
6448 showRetryButton = true ,
6549 compactError = false ,
6650 isLoading : externalLoading ,
@@ -72,30 +56,26 @@ export default function RetryableImage(props: RetryableImageProps) {
7256 } = props
7357
7458 const [ loading , setLoading ] = useState < boolean > ( false )
75- const [ error , setError ] = useState < {
76- status ?: number
77- message : string
78- } | null > ( null )
59+ const [ error , setError ] = useState < { message : string } | null > ( null )
7960 const [ attempt , setAttempt ] = useState < number > ( 0 )
8061 const [ displayedSrc , setDisplayedSrc ] = useState < string | undefined > (
8162 undefined ,
82- )
63+ ) // last good
64+ const [ pendingSrc , setPendingSrc ] = useState < string | undefined > ( undefined ) // hidden probe via <img>
8365
8466 const currentSrcBase = useMemo (
8567 ( ) => baseUrl ( typeof src === "string" ? src : undefined ) ,
8668 [ src ] ,
8769 )
8870 const lastSuccessBaseRef = useRef < string > ( "" )
8971
90- const abortRef = useRef < AbortController | null > ( null )
9172 const retryTimeoutRef = useRef < number | null > ( null )
9273 const mountedRef = useRef ( true )
9374
9475 useEffect ( ( ) => {
9576 mountedRef . current = true
9677 return ( ) => {
9778 mountedRef . current = false
98- if ( abortRef . current ) abortRef . current . abort ( )
9979 if ( retryTimeoutRef . current ) window . clearTimeout ( retryTimeoutRef . current )
10080 }
10181 } , [ ] )
@@ -106,142 +86,69 @@ export default function RetryableImage(props: RetryableImageProps) {
10686 intersectionRoot ?? undefined ,
10787 )
10888
109- const preflight = useCallback (
110- async (
111- signal : AbortSignal ,
112- ) : Promise <
113- { ok : true } | { ok : false ; status ?: number ; message : string }
114- > => {
115- try {
116- const url = new URL ( String ( src ) )
117-
118- url . searchParams . set ( "ik-version" , Date . now ( ) . toString ( ) )
119-
120- const res = await fetch ( url . toString ( ) , {
121- method : "GET" ,
122- signal,
123- } )
124-
125- if ( ! res . headers . get ( "content-type" ) ?. includes ( "image" ) ) {
126- return {
127- ok : false ,
128- status : res . status ,
129- message : `HTTP ${ res . status } ` ,
130- }
131- }
132- return { ok : true }
133- } catch ( e : any ) {
134- return { ok : false , message : e ?. message || "Network error" }
135- }
136- } ,
137- [ src ] ,
138- )
139-
140- const beginLoad = useCallback (
141- ( tryIdx = 0 ) => {
142- if ( ! mountedRef . current || ! src ) return
143-
144- // If only query params changed and we have a prior success for the same base, keep old image until new resolves
145- if (
146- lastSuccessBaseRef . current &&
147- lastSuccessBaseRef . current === currentSrcBase
148- ) {
149- setLoading ( true )
150- setError ( null )
151- } else {
152- setDisplayedSrc ( undefined )
153- setLoading ( true )
154- setError ( null )
155- }
156-
157- const controller = new AbortController ( )
158- abortRef . current = controller
89+ const beginLoad = useCallback ( ( ) => {
90+ if ( ! mountedRef . current || ! src ) return
15991
160- let p = inflight . get ( src as string )
161- if ( ! p ) {
162- p = preflight ( controller . signal )
163- inflight . set ( src as string , p )
164- }
165-
166- p . then ( ( result ) => {
167- if ( ! mountedRef . current || controller . signal . aborted ) return
168- if ( inflight . get ( src as string ) === p ) inflight . delete ( src as string )
169-
170- if ( ! result . ok ) {
171- if (
172- result . status &&
173- nonRetryableStatusCodes . includes ( result . status )
174- ) {
175- setError ( { status : result . status , message : result . message } )
176- setLoading ( false )
177- onNonRetryableError ?.( result . status )
178- return
179- }
180- if ( tryIdx < maxRetries ) {
181- const next = tryIdx + 1
182- onRetry ?.( next )
183- setAttempt ( next )
184- retryTimeoutRef . current = window . setTimeout (
185- ( ) => beginLoad ( next ) ,
186- retryDelay ,
187- )
188- return
189- }
190- setError ( { status : result . status , message : result . message } )
191- setLoading ( false )
192- onRetryExhausted ?.( )
193- return
194- }
92+ if (
93+ lastSuccessBaseRef . current &&
94+ lastSuccessBaseRef . current === currentSrcBase
95+ ) {
96+ setLoading ( true )
97+ setError ( null )
98+ } else {
99+ setDisplayedSrc ( undefined )
100+ setLoading ( true )
101+ setError ( null )
102+ }
195103
196- // Status 200: render direct URL; decode validation happens via onLoad/onError events
197- setDisplayedSrc ( src as string )
198- // keep loading true until onLoad fires to confirm decode
199- } )
200- } ,
201- [
202- currentSrcBase ,
203- maxRetries ,
204- nonRetryableStatusCodes ,
205- onNonRetryableError ,
206- onRetry ,
207- onRetryExhausted ,
208- preflight ,
209- retryDelay ,
210- src ,
211- ] ,
212- )
104+ setPendingSrc ( String ( src ) )
105+ } , [ currentSrcBase , src ] )
213106
214- // React to src/visibility changes
215107 useEffect ( ( ) => {
216108 if ( ! src ) return
217109 if ( lazy && ! visible ) return
110+ setAttempt ( 0 )
218111 beginLoad ( 0 )
219- // eslint-disable-next-line react-hooks/exhaustive-deps
220112 } , [ src , visible , lazy ] )
221113
114+ const scheduleRetry = useCallback ( ( ) => {
115+ if ( attempt + 1 > maxRetries ) {
116+ setLoading ( false )
117+ setError ( { message : "Image failed to load after retries." } )
118+ onRetryExhausted ?.( )
119+ return
120+ }
121+ const next = attempt + 1
122+ setAttempt ( next )
123+ onRetry ?.( next )
124+ retryTimeoutRef . current = window . setTimeout ( ( ) => {
125+ if ( ! mountedRef . current ) return
126+ beginLoad ( next )
127+ } , retryDelay )
128+ } , [ attempt , maxRetries , onRetry , onRetryExhausted , retryDelay , beginLoad ] )
129+
130+ const handleProbeLoad = ( ) => {
131+ if ( ! pendingSrc ) return
132+ setDisplayedSrc ( pendingSrc )
133+ setPendingSrc ( undefined )
134+ setLoading ( false )
135+ setError ( null )
136+ lastSuccessBaseRef . current = currentSrcBase
137+ }
138+
139+ const handleProbeError = ( ) => {
140+ scheduleRetry ( )
141+ }
142+
222143 const overlayActive = ! ! externalLoading || loading
223144
224- // Image element event handlers for decode validation
225- const handleImgLoad = ( ) => {
145+ const handleVisibleLoad = ( ) => {
226146 setLoading ( false )
227147 setError ( null )
228148 lastSuccessBaseRef . current = currentSrcBase
229149 }
230- const handleImgError = ( ) => {
231- // We had a 200 but decode failed (bad image). Retry according to policy
232- if ( attempt < ( maxRetries ?? 0 ) ) {
233- const next = attempt + 1
234- setAttempt ( next )
235- onRetry ?.( next )
236- retryTimeoutRef . current = window . setTimeout (
237- ( ) => beginLoad ( next ) ,
238- retryDelay ,
239- )
240- } else {
241- setLoading ( false )
242- setError ( { message : "Invalid or undecodable image" } )
243- onRetryExhausted ?.( )
244- }
150+ const handleVisibleError = ( ) => {
151+ scheduleRetry ( )
245152 }
246153
247154 return (
@@ -273,10 +180,8 @@ export default function RetryableImage(props: RetryableImageProps) {
273180 < VStack spacing = { compactError ? 1 : 3 } >
274181 { ! compactError && (
275182 < Text fontSize = "md" color = "gray.500" textAlign = "center" >
276- { error . status &&
277- nonRetryableStatusCodes . includes ( error . status )
278- ? `Failed to load image (Error ${ error . status } )`
279- : `Failed to load image after ${ maxRetries } attempts` }
183+ Failed to load image
184+ { maxRetries ? ` after ${ maxRetries } attempts` : "" }
280185 </ Text >
281186 ) }
282187 { compactError && (
@@ -296,32 +201,53 @@ export default function RetryableImage(props: RetryableImageProps) {
296201 </ VStack >
297202 </ Flex >
298203 </ Center >
299- ) : displayedSrc ? (
204+ ) : (
300205 < >
301- < ChakraImage
302- src = { displayedSrc }
303- alt = { alt }
304- onLoad = { handleImgLoad }
305- onError = { handleImgError }
306- loading = "lazy"
307- { ...imgProps }
308- />
309- { overlayActive && (
310- < Center position = "absolute" inset = { 0 } bg = "blackAlpha.400" >
311- < Spinner thickness = "3px" />
206+ { displayedSrc ? (
207+ < >
208+ < ChakraImage
209+ src = { displayedSrc }
210+ alt = { alt }
211+ onLoad = { handleVisibleLoad }
212+ onError = { handleVisibleError }
213+ loading = "lazy"
214+ { ...imgProps }
215+ />
216+ { overlayActive && (
217+ < Center position = "absolute" inset = { 0 } bg = "blackAlpha.400" >
218+ < Spinner thickness = "3px" />
219+ </ Center >
220+ ) }
221+ </ >
222+ ) : (
223+ < Center
224+ w = { imgProps . width || "full" }
225+ h = { imgProps . height || 24 }
226+ minW = { imgProps . minW ?? 32 }
227+ borderWidth = "0"
228+ borderRadius = "md"
229+ >
230+ { lazy && ! visible ? < span /> : < Spinner thickness = "3px" /> }
312231 </ Center >
313232 ) }
233+
234+ { /* Hidden pending image that actually drives the new request */ }
235+ { pendingSrc && (
236+ < img
237+ src = { pendingSrc }
238+ alt = ""
239+ onLoad = { handleProbeLoad }
240+ onError = { handleProbeError }
241+ style = { {
242+ position : "absolute" ,
243+ width : 0 ,
244+ height : 0 ,
245+ opacity : 0 ,
246+ pointerEvents : "none" ,
247+ } }
248+ />
249+ ) }
314250 </ >
315- ) : (
316- < Center
317- w = { imgProps . width || "full" }
318- h = { imgProps . height || 24 }
319- minW = { imgProps . minW ?? 32 }
320- borderWidth = "0"
321- borderRadius = "md"
322- >
323- { lazy && ! visible ? < span /> : < Spinner thickness = "3px" /> }
324- </ Center >
325251 ) }
326252 </ Box >
327253 )
0 commit comments