navidrome/ui/src/common/useImageUrl.js
Deluan Quintão 33e20d355e
fix(ui): cancel in-flight image requests on pagination, cache across remounts (#5249)
* feat(ui): cancel in-flight image requests on pagination and cache across remounts

When paginating quickly through list/grid views, image requests for previous
pages were never canceled, queuing on the server and blocking new images.
This adds a useImageUrl hook that loads images via fetch() with AbortController,
so requests are canceled when components unmount. A module-level cache (URL →
blob URL) with reference counting ensures React Admin refreshes display images
instantly without re-fetching.

* feat(ui): update AlbumListPagination to conditionally render based on albumListType

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): abort all in-flight image fetches on pagination change

Pagination component now watches page/perPage via useListContext and
calls abortAllInFlight() when either changes, freeing the browser
connection pool immediately for the next page's data request.

Also adds empty placeholder style to CoverArtAvatar so it renders as a
clean transparent area while loading instead of the default person icon.

* Revert "feat(ui): abort all in-flight image fetches on pagination change"

This reverts commit 3bc09f9d03.

* fix(ui): limit concurrent image fetches to prevent connection pool saturation

With <img src>, the browser prioritizes API requests over image loads.
With fetch(), all requests compete equally for the HTTP/1.1 connection
pool (6 per origin), causing API requests to queue behind images and
making pagination feel unresponsive. Caps concurrent image fetches at
4 with a pending queue, leaving connections free for API requests.
Queued fetches for unmounted components are removed without ever
hitting the network.

* fix(ui): fix queued fetch not aborted on unmount

Set queued=false when doFetch executes from the pending queue, so
cleanup correctly calls controller.abort() instead of searching an
already-drained queue.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-25 21:30:40 -04:00

144 lines
4.1 KiB
JavaScript

import { useEffect, useState, useRef } from 'react'
// Persists across component mount/unmount cycles so that
// React Admin refreshes (which remount list items) don't re-fetch images.
const cache = new Map()
const MAX_CACHE_SIZE = 300
// Limit concurrent fetches to leave browser connections free for API requests.
// Browsers allow ~6 connections per origin on HTTP/1.1; reserving 2 for API
// calls prevents image fetches from blocking pagination/data requests.
const MAX_CONCURRENT = 4
let activeFetches = 0
const pendingQueue = []
const processQueue = () => {
while (pendingQueue.length > 0 && activeFetches < MAX_CONCURRENT) {
const next = pendingQueue.shift()
next()
}
}
// Evicts oldest unused entries (Map iterates in insertion order).
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
}
// Re-check: another component's effect may have populated the cache
// between this component's render and effect execution.
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()
// Guard against late resolution after abort
if (abortedRef.current) {
return
}
const objectUrl = URL.createObjectURL(blob)
// Handle concurrent fetches: if another component already cached
// this URL, use its entry and discard our 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 // Expected on unmount or URL change
}
// Cache the error so repeated mounts don't re-fetch broken URLs
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) {
// Remove from queue if not yet started
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 }
}