Merge branch 'master' into radio-browser-search/5239

This commit is contained in:
Markus Busche 2026-03-26 21:20:22 +01:00 committed by GitHub
commit 1592712977
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 438 additions and 79 deletions

59
ui/package-lock.json generated
View File

@ -117,7 +117,6 @@
"node_modules/@babel/core": {
"version": "7.28.6",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@ -1530,7 +1529,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -1552,7 +1550,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -2296,7 +2293,6 @@
"node_modules/@jsonforms/core": {
"version": "2.5.2",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.3",
"ajv": "^6.10.2",
@ -2340,7 +2336,6 @@
"node_modules/@jsonforms/react": {
"version": "2.5.2",
"license": "MIT",
"peer": true,
"dependencies": {
"lodash": "^4.17.15",
"object-hash": "^2.0.0"
@ -2353,7 +2348,6 @@
"node_modules/@material-ui/core": {
"version": "4.12.4",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.4.4",
"@material-ui/styles": "^4.11.5",
@ -2396,7 +2390,6 @@
"node_modules/@material-ui/icons": {
"version": "4.11.3",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.4.4"
},
@ -2794,7 +2787,9 @@
"license": "MIT"
},
"node_modules/@rollup/pluginutils/node_modules/picomatch": {
"version": "4.0.3",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
@ -3013,7 +3008,6 @@
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"hoist-non-react-statics": "^3.3.0"
},
@ -3054,7 +3048,6 @@
"version": "24.10.9",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@ -3070,7 +3063,6 @@
"node_modules/@types/react": {
"version": "17.0.90",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "^0.16",
@ -3206,7 +3198,6 @@
"version": "6.21.0",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@ -3504,7 +3495,6 @@
"node_modules/acorn": {
"version": "8.15.0",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -4076,7 +4066,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -4390,7 +4379,6 @@
"node_modules/connected-react-router": {
"version": "6.9.3",
"license": "MIT",
"peer": true,
"dependencies": {
"lodash.isequalwith": "^4.4.0",
"prop-types": "^15.7.2"
@ -5142,7 +5130,6 @@
"version": "8.57.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@ -5640,7 +5627,6 @@
"node_modules/final-form": {
"version": "4.20.10",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.10.0"
},
@ -5655,7 +5641,6 @@
"node_modules/final-form-arrays": {
"version": "3.1.0",
"license": "MIT",
"peer": true,
"peerDependencies": {
"final-form": "^4.20.8"
}
@ -6004,7 +5989,6 @@
"version": "20.3.3",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
@ -6100,7 +6084,6 @@
"node_modules/history": {
"version": "4.10.1",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.1.2",
"loose-envify": "^1.2.0",
@ -7433,7 +7416,6 @@
"node_modules/moment": {
"version": "2.30.1",
"license": "MIT",
"peer": true,
"engines": {
"node": "*"
}
@ -7932,7 +7914,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -8039,7 +8023,6 @@
"node_modules/prop-types": {
"version": "15.8.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
@ -8115,7 +8098,6 @@
"node_modules/ra-core": {
"version": "3.19.12",
"license": "MIT",
"peer": true,
"dependencies": {
"classnames": "~2.3.1",
"date-fns": "^1.29.0",
@ -8469,7 +8451,6 @@
"node_modules/react": {
"version": "17.0.2",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
@ -8543,7 +8524,6 @@
"node_modules/react-dom": {
"version": "17.0.2",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1",
@ -8608,7 +8588,6 @@
"node_modules/react-final-form": {
"version": "6.5.9",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.15.4"
},
@ -8624,7 +8603,6 @@
"node_modules/react-final-form-arrays": {
"version": "3.1.4",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.19.4"
},
@ -8711,7 +8689,6 @@
"node_modules/react-redux": {
"version": "7.2.9",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.15.4",
"@types/react-redux": "^7.1.20",
@ -8743,7 +8720,6 @@
"node_modules/react-router": {
"version": "5.3.4",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
@ -8762,7 +8738,6 @@
"node_modules/react-router-dom": {
"version": "5.3.4",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
@ -8916,7 +8891,6 @@
"node_modules/redux": {
"version": "4.2.1",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.9.2"
}
@ -8924,7 +8898,6 @@
"node_modules/redux-saga": {
"version": "1.4.2",
"license": "MIT",
"peer": true,
"dependencies": {
"@redux-saga/core": "^1.4.2"
}
@ -9133,7 +9106,6 @@
"version": "4.55.2",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -9925,10 +9897,11 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -10116,7 +10089,6 @@
"version": "5.9.3",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -10319,7 +10291,6 @@
"version": "7.3.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -10435,10 +10406,11 @@
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -10450,7 +10422,6 @@
"version": "4.0.17",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.17",
"@vitest/mocker": "4.0.17",
@ -10524,7 +10495,9 @@
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.3",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@ -10904,7 +10877,6 @@
"node_modules/workbox-build/node_modules/ajv": {
"version": "8.18.0",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -10978,7 +10950,6 @@
"node_modules/workbox-build/node_modules/rollup": {
"version": "2.79.2",
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},

View File

@ -18,6 +18,7 @@ import {
PlayButton,
ArtistLinkField,
OverflowTooltip,
useImageUrl,
} from '../common'
import { COVER_ART_SIZE, DraggableTypes } from '../consts'
import clsx from 'clsx'
@ -105,7 +106,7 @@ const useCoverStyles = makeStyles({
transition: 'opacity 0.3s ease-in-out',
},
coverLoading: {
opacity: 0.5,
opacity: 0,
},
})
@ -125,8 +126,6 @@ const Cover = withContentRect('bounds')(({
// Force height to be the same as the width determined by the GridList
// noinspection JSSuspiciousNameCombination
const classes = useCoverStyles({ height: contentRect.bounds.width })
const [imageLoading, setImageLoading] = React.useState(true)
const [imageError, setImageError] = React.useState(false)
const [, dragAlbumRef] = useDrag(
() => ({
type: DraggableTypes.ALBUM,
@ -136,32 +135,16 @@ const Cover = withContentRect('bounds')(({
[record],
)
// Reset image state when record changes
React.useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [record.id])
const handleImageLoad = React.useCallback(() => {
setImageLoading(false)
setImageError(false)
}, [])
const handleImageError = React.useCallback(() => {
setImageLoading(false)
setImageError(true)
}, [])
const url = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)
const { imgUrl, loading: imageLoading } = useImageUrl(url)
return (
<div ref={measureRef} className={classes.coverContainer}>
<div ref={dragAlbumRef}>
<img
key={record.id} // Force re-render when record changes
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)}
src={imgUrl || undefined}
alt={record.name}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onLoad={handleImageLoad}
onError={handleImageError}
/>
</div>
</div>

View File

@ -175,12 +175,12 @@ const AlbumListTitle = ({ albumListType }) => {
return <Title subTitle={title} args={{ smart_count: 2 }} />
}
const AlbumListPagination = (props) => {
const AlbumListPagination = ({ albumListType, ...rest }) => {
const { loading } = useListContext()
if (loading) {
if (loading && albumListType === 'random') {
return null
}
return <Pagination {...props} />
return <Pagination {...rest} />
}
const randomStartingSeed = Math.random().toString()
@ -243,7 +243,12 @@ const AlbumList = (props) => {
actions={<AlbumListActions />}
filters={<AlbumFilter />}
perPage={perPage}
pagination={<AlbumListPagination rowsPerPageOptions={perPageOptions} />}
pagination={
<AlbumListPagination
rowsPerPageOptions={perPageOptions}
albumListType={albumListType}
/>
}
title={<AlbumListTitle albumListType={albumListType} />}
>
{albumView.grid ? (

View File

@ -4,12 +4,16 @@ import { makeStyles } from '@material-ui/core/styles'
import clsx from 'clsx'
import { COVER_ART_SIZE } from '../consts'
import subsonic from '../subsonic'
import { useImageUrl } from './useImageUrl'
const useStyles = makeStyles({
avatar: {
width: '55px',
height: '55px',
},
avatarEmpty: {
backgroundColor: 'transparent',
},
square: {
borderRadius: '4px',
},
@ -22,15 +26,26 @@ export const CoverArtAvatar = ({
const classes = useStyles()
const recordContext = useRecordContext()
const record = recordProp || recordContext
if (!record) return null
const square = variant !== 'circular'
const url = record
? subsonic.getCoverArtUrl(record, COVER_ART_SIZE, square)
: null
const { imgUrl } = useImageUrl(url)
if (!record) return null
return (
<Avatar
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, square)}
src={imgUrl || undefined}
variant={variant}
className={clsx(classes.avatar, square && classes.square)}
className={clsx(
classes.avatar,
square && classes.square,
!imgUrl && classes.avatarEmpty,
)}
alt={record.name}
/>
>
{/* Empty child prevents default person icon while loading */}
{!imgUrl && <span />}
</Avatar>
)
}

View File

@ -46,3 +46,4 @@ export * from './useSearchRefocus'
export * from './ImageUploadOverlay'
export * from './CoverArtAvatar'
export * from './useImageLoadingState'
export * from './useImageUrl'

View File

@ -0,0 +1,144 @@
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 }
}

View File

@ -0,0 +1,234 @@
import { renderHook, act } from '@testing-library/react-hooks'
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
// Helper to flush all pending promises
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
// We need a fresh module for each test to reset the module-level cache
let useImageUrl
describe('useImageUrl', () => {
let abortSpy
let OriginalAbortController
let originalCreateObjectURL
let originalRevokeObjectURL
let originalFetch
beforeEach(async () => {
// Reset module to clear the cache
vi.resetModules()
const mod = await import('./useImageUrl')
useImageUrl = mod.useImageUrl
abortSpy = vi.fn()
OriginalAbortController = global.AbortController
originalCreateObjectURL = global.URL.createObjectURL
originalRevokeObjectURL = global.URL.revokeObjectURL
originalFetch = global.fetch
global.AbortController = function () {
this.signal = 'mock-signal'
this.abort = abortSpy
}
global.URL.createObjectURL = vi.fn(() => 'blob:mock-url')
global.URL.revokeObjectURL = vi.fn()
})
afterEach(() => {
global.AbortController = OriginalAbortController
global.URL.createObjectURL = originalCreateObjectURL
global.URL.revokeObjectURL = originalRevokeObjectURL
global.fetch = originalFetch
vi.restoreAllMocks()
})
it('should return null values when url is null', () => {
const { result } = renderHook(() => useImageUrl(null))
expect(result.current.loading).toBe(false)
expect(result.current.imgUrl).toBeNull()
expect(result.current.error).toBe(false)
})
it('should return loading state initially', () => {
global.fetch = vi.fn(() => new Promise(() => {}))
const { result } = renderHook(() =>
useImageUrl('http://example.com/img.jpg'),
)
expect(result.current.loading).toBe(true)
expect(result.current.imgUrl).toBeNull()
expect(result.current.error).toBe(false)
})
it('should fetch image and return blob URL on success', async () => {
const mockBlob = new Blob(['image-data'], { type: 'image/png' })
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
blob: () => Promise.resolve(mockBlob),
}),
)
const { result } = renderHook(() =>
useImageUrl('http://example.com/img.jpg'),
)
await act(async () => {
await flushPromises()
})
expect(result.current.loading).toBe(false)
expect(result.current.imgUrl).toBe('blob:mock-url')
expect(result.current.error).toBe(false)
expect(global.fetch).toHaveBeenCalledWith('http://example.com/img.jpg', {
signal: 'mock-signal',
})
})
it('should set error on HTTP failure', async () => {
global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 }))
const { result } = renderHook(() =>
useImageUrl('http://example.com/missing.jpg'),
)
await act(async () => {
await flushPromises()
})
expect(result.current.loading).toBe(false)
expect(result.current.imgUrl).toBeNull()
expect(result.current.error).toBe(true)
})
it('should abort fetch on unmount', async () => {
global.fetch = vi.fn(() => new Promise(() => {}))
const { unmount } = renderHook(() =>
useImageUrl('http://example.com/img.jpg'),
)
await act(async () => {
await flushPromises()
})
unmount()
expect(abortSpy).toHaveBeenCalled()
})
it('should abort previous fetch when URL changes', async () => {
const abortSpies = []
global.AbortController = function () {
const spy = vi.fn()
abortSpies.push(spy)
this.signal = `signal-${abortSpies.length}`
this.abort = spy
}
const mockBlob = new Blob(['data'], { type: 'image/png' })
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
blob: () => Promise.resolve(mockBlob),
}),
)
const { rerender } = renderHook(({ url }) => useImageUrl(url), {
initialProps: { url: 'http://example.com/img1.jpg' },
})
await act(async () => {
await flushPromises()
})
// Change URL - should abort the first controller
rerender({ url: 'http://example.com/img2.jpg' })
expect(abortSpies[0]).toHaveBeenCalled()
})
it('should not set error on AbortError', async () => {
const abortError = new DOMException('Aborted', 'AbortError')
global.fetch = vi.fn(() => Promise.reject(abortError))
const { result } = renderHook(() =>
useImageUrl('http://example.com/img.jpg'),
)
await act(async () => {
await flushPromises()
})
expect(result.current.error).toBe(false)
})
it('should use cached blob URL on remount without re-fetching', async () => {
const mockBlob = new Blob(['data'], { type: 'image/png' })
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
blob: () => Promise.resolve(mockBlob),
}),
)
// First mount — fetches and caches
const { unmount } = renderHook(() =>
useImageUrl('http://example.com/img.jpg'),
)
await act(async () => {
await flushPromises()
})
expect(global.fetch).toHaveBeenCalledTimes(1)
// Unmount (simulates React Admin refresh)
unmount()
// Remount with same URL — should use cache
const { result: result2 } = renderHook(() =>
useImageUrl('http://example.com/img.jpg'),
)
await act(async () => {
await flushPromises()
})
// Should NOT have fetched again
expect(global.fetch).toHaveBeenCalledTimes(1)
expect(result2.current.imgUrl).toBe('blob:mock-url')
expect(result2.current.loading).toBe(false)
})
it('should cache errors and not re-fetch broken URLs', async () => {
global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 }))
// First mount — fetch fails and error is cached
const { unmount } = renderHook(() =>
useImageUrl('http://example.com/broken.jpg'),
)
await act(async () => {
await flushPromises()
})
expect(global.fetch).toHaveBeenCalledTimes(1)
unmount()
// Remount with same URL — should use cached error, not re-fetch
const { result: result2 } = renderHook(() =>
useImageUrl('http://example.com/broken.jpg'),
)
await act(async () => {
await flushPromises()
})
expect(global.fetch).toHaveBeenCalledTimes(1)
expect(result2.current.error).toBe(true)
expect(result2.current.imgUrl).toBeNull()
expect(result2.current.loading).toBe(false)
})
})

View File

@ -14,8 +14,12 @@ import {
UrlField,
useTranslate,
} from 'react-admin'
import { List } from '../common'
import { ToggleFieldsMenu, useSelectedFields } from '../common'
import {
List,
useImageUrl,
ToggleFieldsMenu,
useSelectedFields,
} from '../common'
import subsonic from '../subsonic'
import { StreamField } from './StreamField'
import { setTrack } from '../actions'
@ -78,10 +82,12 @@ const RadioListActions = ({
const avatarStyle = { width: 40, height: 40 }
const CoverArtField = ({ record }) => {
if (!record) return null
const src = record.uploadedImage
const directUrl = record?.uploadedImage
? subsonic.getCoverArtUrl(record, 40, true)
: RADIO_PLACEHOLDER_IMAGE
: null
const { imgUrl } = useImageUrl(directUrl)
if (!record) return null
const src = imgUrl || RADIO_PLACEHOLDER_IMAGE
return (
<Avatar src={src} variant="rounded" style={avatarStyle} alt={record.name} />
)