Skip to content

Commit e8b15ed

Browse files
committed
refactor: simplify RetryableImage component with hidden probe element and cleaner retry logic
1 parent 323ced1 commit e8b15ed

File tree

6 files changed

+118
-183
lines changed

6 files changed

+118
-183
lines changed

examples/react-example/src/index.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,12 @@ function App() {
3636
requireSignedUrl: false,
3737
},
3838
},
39-
...Array.from({ length: 10000 }).map((_, i) => ({
40-
url: `https://ik.imagekit.io/v3sxk1svj/placeholder.jpg?updatedAt=${Date.now()}&v=${i}`,
41-
metadata: {
42-
requireSignedUrl: false,
43-
},
44-
})),
39+
// ...Array.from({ length: 10000 }).map((_, i) => ({
40+
// url: `https://ik.imagekit.io/v3sxk1svj/placeholder.jpg?updatedAt=${Date.now()}&v=${i}`,
41+
// metadata: {
42+
// requireSignedUrl: false,
43+
// },
44+
// })),
4545
],
4646
onAddImage: handleAddImage,
4747
onClose: () => setOpen(false),

packages/imagekit-editor-dev/src/components/RetryableImage.tsx

Lines changed: 96 additions & 170 deletions
Original file line numberDiff line numberDiff line change
@@ -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-
4228
const 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
)

packages/imagekit-editor-dev/src/components/common/AnchorField.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Box, Button, Flex, Text, Tooltip } from "@chakra-ui/react"
1+
import { Box, Button, Flex, Tooltip } from "@chakra-ui/react"
22
import { memo } from "react"
33

44
type AnchorPosition =

packages/imagekit-editor-dev/src/components/sidebar/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Box, Button, Flex, HStack, Icon, Text, VStack } from "@chakra-ui/react"
1+
import { Box, Button, HStack, Icon, Text, VStack } from "@chakra-ui/react"
22
import type {
33
DragEndEvent,
44
DragStartEvent,

0 commit comments

Comments
 (0)