fix(ui): keep the placeholder for a cover whose fetch failed

useImageUrl caches a failed fetch as an entry with no blob, so treating every
cached entry as instantly painted suppressed the placeholder on remount: the
cover became an empty box instead of falling back to its thumbhash. Only a
cached blob counts as instant.

Caught by Codex on the previous commit, which introduced fromCache with the
looser predicate.
This commit is contained in:
Deluan 2026-08-02 14:21:35 -04:00
parent a4ab19d605
commit a4e78d949f
3 changed files with 19 additions and 4 deletions

View File

@ -192,6 +192,18 @@ describe('Artwork', () => {
expect(onClick).toHaveBeenCalledTimes(1)
})
it('shows the placeholder for a cached fetch failure, which has no blob', () => {
useImageUrl.mockReturnValue({
imgUrl: null,
loading: false,
error: true,
fromCache: false,
})
const { container } = render(<Artwork record={withArt} />)
expect(container.querySelector('img')).toBeNull()
expect(container.querySelector('canvas')).not.toBeNull()
})
it('falls back to the placeholder when a refresh swaps in an uncached hash', () => {
useImageUrl.mockReturnValue({
imgUrl: 'blob:old',

View File

@ -43,16 +43,16 @@ export const useImageUrl = (url) => {
const [error, setError] = useState(cached?.error || false)
const abortedRef = useRef(false)
// Resync during render, not in the effect below: for one render otherwise imgUrl still holds the
// previous url's blob, so only fromCache can answer "was this url already cached when it started".
const [fromCache, setFromCache] = useState(!!cached)
// Resync during render: otherwise imgUrl holds the previous url's blob for one render, so only
// fromCache can answer "already cached" - and a remembered failure has no blob to paint.
const [fromCache, setFromCache] = useState(!!cached?.blobUrl)
const [trackedUrl, setTrackedUrl] = useState(url)
if (trackedUrl !== url) {
setTrackedUrl(url)
setImgUrl(cached?.blobUrl || null)
setLoading(!!url && !cached)
setError(cached?.error || false)
setFromCache(!!cached)
setFromCache(!!cached?.blobUrl)
}
useEffect(() => {

View File

@ -200,6 +200,7 @@ describe('useImageUrl', () => {
expect(global.fetch).toHaveBeenCalledTimes(1)
expect(result2.current.imgUrl).toBe('blob:mock-url')
expect(result2.current.loading).toBe(false)
expect(result2.current.fromCache).toBe(true)
})
it('should cache errors and not re-fetch broken URLs', async () => {
@ -230,5 +231,7 @@ describe('useImageUrl', () => {
expect(result2.current.error).toBe(true)
expect(result2.current.imgUrl).toBeNull()
expect(result2.current.loading).toBe(false)
// A remembered failure has no blob, so callers must not treat it as instantly painted.
expect(result2.current.fromCache).toBe(false)
})
})