import { useEffect, useState, useRef } from 'react'
const cache = new Map()
const MAX_CACHE_SIZE = 300
const MAX_CONCURRENT = 4
let activeFetches = 0
const pendingQueue = []
const processQueue = () => {
while (pendingQueue.length > 0 && activeFetches < MAX_CONCURRENT) {
const next = pendingQueue.shift()
next()
}
}
const evictIfNeeded = () => {
if (cache.size <= MAX_CACHE_SIZE) return
for (const [key, entry] of cache) {
if (cache.size <= MAX_CACHE_SIZE) break
if (entry.refCount === 0) {
if (entry.blobUrl) URL.revokeObjectURL(entry.blobUrl)
cache.delete(key)
}
}
}
* Loads an image via fetch() with AbortController so that in-flight requests
* are canceled on unmount (e.g., during pagination). Uses a module-level cache
* so remounting returns the cached blob URL instantly.
*/
export const useImageUrl = (url) => {
const cached = url ? cache.get(url) : null
const [imgUrl, setImgUrl] = useState(cached?.blobUrl || null)
const [loading, setLoading] = useState(!!url && !cached)
const [error, setError] = useState(cached?.error || false)
const abortedRef = useRef(false)
useEffect(() => {
abortedRef.current = false
if (!url) {
setImgUrl(null)
setLoading(false)
setError(false)
return
}
const entry = cache.get(url)
if (entry) {
entry.refCount++
setImgUrl(entry.blobUrl)
setLoading(false)
setError(entry.error || false)
return () => {
entry.refCount--
}
}
const controller = new AbortController()
let queued = true
setImgUrl(null)
setLoading(true)
setError(false)
const doFetch = () => {
queued = false
activeFetches++
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
return res.blob()
})
.then((blob) => {
activeFetches--
processQueue()
if (abortedRef.current) {
return
}
const objectUrl = URL.createObjectURL(blob)
const existing = cache.get(url)
if (existing && existing.blobUrl) {
existing.refCount++
URL.revokeObjectURL(objectUrl)
setImgUrl(existing.blobUrl)
} else {
cache.set(url, { blobUrl: objectUrl, refCount: 1 })
evictIfNeeded()
setImgUrl(objectUrl)
}
setLoading(false)
})
.catch((err) => {
activeFetches--
processQueue()
if (err.name === 'AbortError') {
return
}
cache.set(url, { blobUrl: null, error: true, refCount: 0 })
setError(true)
setLoading(false)
})
}
if (activeFetches < MAX_CONCURRENT) {
queued = false
doFetch()
} else {
pendingQueue.push(doFetch)
}
return () => {
abortedRef.current = true
if (queued) {
const idx = pendingQueue.indexOf(doFetch)
if (idx !== -1) pendingQueue.splice(idx, 1)
} else {
controller.abort()
}
const entry = cache.get(url)
if (entry) {
entry.refCount--
}
}
}, [url])
return { imgUrl, loading, error }
}