mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-15 05:47:13 +02:00
* 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>
144 lines
4.1 KiB
JavaScript
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 }
|
|
}
|