|
1 | 1 | "use client"; |
2 | 2 |
|
3 | 3 | import NextImage from "next/image"; |
4 | | -import { useState } from "react"; |
| 4 | +import { useEffect, useRef, useState } from "react"; |
5 | 5 | import { getUploadCdnOrigin, normalizeImageUrlToUploadCdn } from "@/utils/cdnUrl"; |
6 | 6 |
|
7 | 7 | const DEFAULT_FALLBACK_SRC = "/svgs/placeholders/image-placeholder.svg"; |
@@ -34,29 +34,79 @@ const resolveCdnUrl = (src: string, cdnHostType?: CdnHostType) => { |
34 | 34 | type FallbackImageProps = React.ComponentProps<typeof NextImage> & { |
35 | 35 | fallbackSrc?: string; |
36 | 36 | cdnHostType?: CdnHostType; |
| 37 | + retryOnError?: boolean; |
| 38 | + retryLimit?: number; |
| 39 | + retryDelayMs?: number; |
37 | 40 | }; |
38 | 41 |
|
39 | 42 | const FallbackImage = ({ |
40 | 43 | src, |
41 | 44 | fallbackSrc = DEFAULT_FALLBACK_SRC, |
42 | 45 | cdnHostType, |
| 46 | + retryOnError = false, |
| 47 | + retryLimit = 0, |
| 48 | + retryDelayMs = 1000, |
43 | 49 | onError, |
44 | 50 | ...props |
45 | 51 | }: FallbackImageProps) => { |
46 | 52 | const [failedSource, setFailedSource] = useState<string | null>(null); |
| 53 | + const [retryAttempt, setRetryAttempt] = useState(0); |
| 54 | + const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 55 | + const sourceKeyRef = useRef<string | null>(null); |
47 | 56 |
|
48 | 57 | const normalizedSrc = typeof src === "string" ? resolveCdnUrl(src, cdnHostType) || fallbackSrc : src; |
49 | 58 | const sourceKey = typeof normalizedSrc === "string" ? normalizedSrc : JSON.stringify(normalizedSrc); |
50 | 59 | const hasError = failedSource === sourceKey; |
51 | 60 | const resolvedSrc = hasError ? fallbackSrc : normalizedSrc; |
| 61 | + const normalizedRetryLimit = Math.max(0, retryLimit); |
| 62 | + const normalizedRetryDelayMs = Math.max(0, retryDelayMs); |
| 63 | + const canRetry = |
| 64 | + retryOnError && |
| 65 | + retryAttempt < normalizedRetryLimit && |
| 66 | + typeof normalizedSrc === "string" && |
| 67 | + normalizedSrc !== fallbackSrc; |
| 68 | + |
| 69 | + useEffect(() => { |
| 70 | + if (sourceKeyRef.current === sourceKey) return; |
| 71 | + |
| 72 | + sourceKeyRef.current = sourceKey; |
| 73 | + setFailedSource(null); |
| 74 | + setRetryAttempt(0); |
| 75 | + |
| 76 | + if (retryTimeoutRef.current) { |
| 77 | + clearTimeout(retryTimeoutRef.current); |
| 78 | + retryTimeoutRef.current = null; |
| 79 | + } |
| 80 | + }, [sourceKey]); |
| 81 | + |
| 82 | + useEffect(() => { |
| 83 | + return () => { |
| 84 | + if (retryTimeoutRef.current) { |
| 85 | + clearTimeout(retryTimeoutRef.current); |
| 86 | + } |
| 87 | + }; |
| 88 | + }, []); |
52 | 89 |
|
53 | 90 | return ( |
54 | 91 | <NextImage |
55 | 92 | {...props} |
| 93 | + key={`${sourceKey}:${hasError ? "fallback" : "source"}:${retryAttempt}`} |
56 | 94 | src={resolvedSrc} |
57 | 95 | onError={(event) => { |
58 | 96 | if (!hasError && resolvedSrc !== fallbackSrc) { |
59 | 97 | setFailedSource(sourceKey); |
| 98 | + |
| 99 | + if (canRetry) { |
| 100 | + if (retryTimeoutRef.current) { |
| 101 | + clearTimeout(retryTimeoutRef.current); |
| 102 | + } |
| 103 | + |
| 104 | + retryTimeoutRef.current = setTimeout(() => { |
| 105 | + setRetryAttempt((prev) => prev + 1); |
| 106 | + setFailedSource((current) => (current === sourceKey ? null : current)); |
| 107 | + retryTimeoutRef.current = null; |
| 108 | + }, normalizedRetryDelayMs); |
| 109 | + } |
60 | 110 | } |
61 | 111 | onError?.(event); |
62 | 112 | }} |
|
0 commit comments