diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go
index fd04c44c5..6449e46fd 100644
--- a/cmd/wire_gen.go
+++ b/cmd/wire_gen.go
@@ -89,13 +89,13 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
fileCache := artwork.GetImageCache()
imageStore := artwork.GetImageStore()
fFmpeg := ffmpeg.New()
- artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
+ broker := events.GetBroker()
+ artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg, broker)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
players := core.NewPlayers(dataStore)
- broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
@@ -119,12 +119,12 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
fileCache := artwork.GetImageCache()
imageStore := artwork.GetImageStore()
fFmpeg := ffmpeg.New()
- artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
+ broker := events.GetBroker()
+ artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg, broker)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
players := core.NewPlayers(dataStore)
- broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
@@ -145,7 +145,8 @@ func CreatePublicRouter() *public.Router {
fileCache := artwork.GetImageCache()
imageStore := artwork.GetImageStore()
fFmpeg := ffmpeg.New()
- artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
+ broker := events.GetBroker()
+ artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg, broker)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
share := core.NewShare(dataStore)
diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go
index fb67917b5..38fdef783 100644
--- a/core/artwork/artwork.go
+++ b/core/artwork/artwork.go
@@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
+ "github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/cache"
)
@@ -49,8 +50,8 @@ type Artwork interface {
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error)
}
-func NewArtwork(ds model.DataStore, cache cache.FileCache, store *ImageStore, ffm ffmpeg.FFmpeg) Artwork {
- return &service{ds: ds, cache: cache, store: store, ffmpeg: ffm}
+func NewArtwork(ds model.DataStore, cache cache.FileCache, store *ImageStore, ffm ffmpeg.FFmpeg, broker events.Broker) Artwork {
+ return &service{ds: ds, cache: cache, store: store, ffmpeg: ffm, broker: broker}
}
// entityExists reports whether the entity an artwork id points at is still there: state rows
@@ -86,6 +87,7 @@ type service struct {
cache cache.FileCache
store *ImageStore
ffmpeg ffmpeg.FFmpeg
+ broker events.Broker
}
func (s *service) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error) {
@@ -136,7 +138,7 @@ func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size i
// serveSource is the one place bytes become an Image. hash is the pixel identity ("" for disc art)
// and doubles as the full-size validator, so an ETag is only needed when resized or hash is "".
-func (s *service) serveSource(ctx context.Context, key, hash string, lastUpdate time.Time,
+func (s *service) serveSource(ctx context.Context, artID model.ArtworkID, key, hash string, lastUpdate time.Time,
size int, square bool, open func() (io.ReadCloser, error),
) (*Image, error) {
if size == 0 && !square {
@@ -158,7 +160,7 @@ func (s *service) serveSource(ctx context.Context, key, hash string, lastUpdate
deferAnimated: s.cache.Available(ctx)}
stream, err := s.cache.Get(ctx, item)
if deferred, ok := errors.AsType[*deferredAnimation](err); ok {
- s.convertInBackground(item, deferred.data)
+ s.convertInBackground(artID, item, deferred.data)
return &Image{ReadCloser: io.NopCloser(deferred.standIn), Hash: hash, LastUpdated: lastUpdate, Transient: true}, nil
}
if err != nil {
@@ -175,7 +177,7 @@ const animConvertTimeout = time.Minute
// convertInBackground caches item's conversion of data. When the slot is busy it does nothing,
// and a later request for the key tries again.
-func (s *service) convertInBackground(item *resizedItem, data []byte) {
+func (s *service) convertInBackground(artID model.ArtworkID, item *resizedItem, data []byte) {
select {
case animConvertSlot <- struct{}{}:
default:
@@ -195,6 +197,10 @@ func (s *service) convertInBackground(item *resizedItem, data []byte) {
return
}
log.Debug(ctx, "Artwork: Converted animated image", "key", key, "bytes", len(out), "elapsed", time.Since(start))
+ // The image URL is unchanged, so UIs holding the stand-in only reload it when told.
+ if res, ok := artworkKindToResource[artID.Kind]; ok {
+ s.broker.SendBroadcastMessage(ctx, (&events.RefreshResource{}).With(res, artID.ID))
+ }
}()
}
@@ -212,7 +218,7 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode
}
return nil, err
}
- img, err := s.serveSource(ctx, ia.Hash, ia.Hash, ia.UpdatedAt, size, square,
+ img, err := s.serveSource(ctx, artID, ia.Hash, ia.Hash, ia.UpdatedAt, size, square,
func() (io.ReadCloser, error) { return openOriginal(ia, art.Mime, s.store) })
if err != nil {
if errors.Is(err, context.Canceled) {
@@ -270,11 +276,11 @@ func (s *service) provisional(ctx context.Context, artID model.ArtworkID, size i
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
log.Debug(ctx, "Artwork: Provisional read-through, no state row yet", "artID", artID,
"source", res.source, "hit", res.reader != nil)
- return s.serveResolution(ctx, res, size, square)
+ return s.serveResolution(ctx, artID, res, size, square)
}
// serveResolution turns a local resolution's bytes into a servable Image (byte-hash only, no decode).
-func (s *service) serveResolution(ctx context.Context, res resolution, size int, square bool) (*Image, error) {
+func (s *service) serveResolution(ctx context.Context, artID model.ArtworkID, res resolution, size int, square bool) (*Image, error) {
if res.reader == nil {
return nil, ErrUnavailable
}
@@ -288,7 +294,7 @@ func (s *service) serveResolution(ctx context.Context, res resolution, size int,
return nil, ErrUnavailable
}
// Keyed by the byte-hash, so the entry lines up with the worker's eventual store entry.
- return s.serveSource(ctx, hash, hash, unixMtime(res.refMtime), size, square,
+ return s.serveSource(ctx, artID, hash, hash, unixMtime(res.refMtime), size, square,
func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil })
}
@@ -338,7 +344,7 @@ func (s *service) provisionalEmbedded(ctx context.Context, artID model.ArtworkID
// Eligible but unextractable: fall back the way CoverArtID does, not to a placeholder.
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
}
- return s.serveResolution(ctx, res, size, square)
+ return s.serveResolution(ctx, artID, res, size, square)
}
// serveDisc reads disc art through with no state row and no enqueue, falling back to the album cover.
@@ -356,7 +362,7 @@ func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int
// Disc art has no state row, hence no content hash: keying on id, album mtime and
// DiscArtPriority lets a warm cache answer without running the chain or touching the disk.
key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority)
- img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, selectImage)
+ img, err := s.serveSource(ctx, artID, key, "", dr.cacheTime(), size, square, selectImage)
if err != nil {
if errors.Is(err, context.Canceled) {
return nil, err
diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go
index bbbcdf43d..af6e75214 100644
--- a/core/artwork/artwork_test.go
+++ b/core/artwork/artwork_test.go
@@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
+ "github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/cache"
. "github.com/onsi/ginkgo/v2"
@@ -109,7 +110,7 @@ var _ = Describe("Artwork", func() {
return arg.(artworkReader).Reader(ctx)
})
Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue())
- svc = NewArtwork(ds, imgCache, store, ffm)
+ svc = NewArtwork(ds, imgCache, store, ffm, events.NoopBroker())
})
Describe("found state", func() {
@@ -428,6 +429,7 @@ var _ = Describe("Artwork", func() {
var (
gifBytes []byte
fake *animFFmpeg
+ broker *fakeEventBroker
)
get := func(id string) *Image {
@@ -453,7 +455,8 @@ var _ = Describe("Artwork", func() {
BeforeEach(func() {
gifBytes = createAnimatedGIF(3)
fake = &animFFmpeg{MockFFmpeg: tests.NewMockFFmpeg(""), out: []byte("animated-webp")}
- svc = NewArtwork(ds, imgCache, store, fake)
+ broker = &fakeEventBroker{}
+ svc = NewArtwork(ds, imgCache, store, fake, broker)
seedFoundStore("al", "al1", gifBytes)
DeferCleanup(waitForConversions)
})
@@ -470,6 +473,15 @@ var _ = Describe("Artwork", func() {
Expect(fake.calls.Load()).To(Equal(int32(1)))
})
+ It("tells the UI to reload the item once its conversion is cached", func() {
+ readAll(get("al-al1"))
+ waitForConversions()
+
+ sent := broker.getEvents()
+ Expect(sent).To(HaveLen(1))
+ Expect(sent[0].Data(sent[0])).To(MatchJSON(`{"album":["al1"]}`))
+ })
+
It("runs one conversion at a time and does not queue the others", func() {
fake.release = make(chan struct{})
release := sync.OnceFunc(func() { close(fake.release) })
diff --git a/core/artwork/e2e/acquire_serve_test.go b/core/artwork/e2e/acquire_serve_test.go
index 34dfb2cac..ea23fa08c 100644
--- a/core/artwork/e2e/acquire_serve_test.go
+++ b/core/artwork/e2e/acquire_serve_test.go
@@ -108,7 +108,7 @@ var _ = Describe("Acquisition → serve loop", func() {
})
Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue())
- svc = artwork.NewArtwork(ds, imgCache, store, ffm)
+ svc = artwork.NewArtwork(ds, imgCache, store, ffm, events.NoopBroker())
worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache)
})
diff --git a/core/artwork/e2e/resolution_harness_test.go b/core/artwork/e2e/resolution_harness_test.go
index fff62ce69..6427d7fce 100644
--- a/core/artwork/e2e/resolution_harness_test.go
+++ b/core/artwork/e2e/resolution_harness_test.go
@@ -119,7 +119,7 @@ func setupResolutionHarness() {
})
Eventually(func() bool { return imgCache.Available(rctx) }, 10*time.Second).Should(BeTrue())
- rsvc = artwork.NewArtwork(rds, imgCache, rstore, ffm)
+ rsvc = artwork.NewArtwork(rds, imgCache, rstore, ffm, events.NoopBroker())
rworker = artwork.NewWorker(rds, rstore, agents.GetAgents(rds, nil), ffm, events.NoopBroker(), imgCache)
}
diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go
index 9324ea9e3..57d916f64 100644
--- a/server/subsonic/e2e/subsonic_artwork_test.go
+++ b/server/subsonic/e2e/subsonic_artwork_test.go
@@ -132,7 +132,7 @@ var _ = Describe("Artwork Serving", Ordered, func() {
store := artwork.NewImageStore(GinkgoT().TempDir())
imgCache := newDummyImageCache(ctx)
ffm := harness.NoopFFmpeg{}
- artSvc = artwork.NewArtwork(ds, imgCache, store, ffm)
+ artSvc = artwork.NewArtwork(ds, imgCache, store, ffm, events.NoopBroker())
worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache)
artRouter = buildArtworkRouter(artSvc)
diff --git a/ui/src/common/Artwork.jsx b/ui/src/common/Artwork.jsx
index 21ac5e07b..3f39bc94a 100644
--- a/ui/src/common/Artwork.jsx
+++ b/ui/src/common/Artwork.jsx
@@ -5,6 +5,7 @@ import { makeStyles } from '@material-ui/core/styles'
import config from '../config'
import subsonic from '../subsonic'
import { useImageUrl } from './useImageUrl'
+import { useArtworkRefresh } from './useArtworkRefresh'
import { ThumbHashCanvas } from './ThumbHashCanvas'
// Drives both the CSS transition and the timer that retires the placeholder, so they cannot drift.
@@ -47,7 +48,8 @@ export const Artwork = ({
}) => {
const classes = useStyles()
const url = record ? subsonic.getCoverArtUrl(record, size, square) : ''
- const { imgUrl, fromCache } = useImageUrl(url)
+ const version = useArtworkRefresh(record?.id)
+ const { imgUrl, fromCache } = useImageUrl(url, version)
const [decoded, setDecoded] = useState(false)
const [faded, setFaded] = useState(false)
diff --git a/ui/src/common/Artwork.test.jsx b/ui/src/common/Artwork.test.jsx
index 18b3c916f..bbf744057 100644
--- a/ui/src/common/Artwork.test.jsx
+++ b/ui/src/common/Artwork.test.jsx
@@ -2,6 +2,7 @@ import { render, fireEvent, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('./useImageUrl', () => ({ useImageUrl: vi.fn() }))
+vi.mock('./useArtworkRefresh', () => ({ useArtworkRefresh: vi.fn() }))
vi.mock('../subsonic', () => ({
default: {
// Mirrors the real hash suffix, so a refreshed record yields a different URL.
@@ -13,6 +14,7 @@ vi.mock('../subsonic', () => ({
vi.mock('../config', () => ({ default: { uiCoverArtSize: 300 } }))
import { useImageUrl } from './useImageUrl'
+import { useArtworkRefresh } from './useArtworkRefresh'
import { Artwork } from './Artwork'
const withArt = {
@@ -28,6 +30,14 @@ describe('Artwork', () => {
HTMLCanvasElement.prototype.getContext = vi.fn(() => null)
})
+ it('loads the image under the version of the last refresh naming its record', () => {
+ useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
+ useArtworkRefresh.mockReturnValue(1234)
+ render()
+ expect(useArtworkRefresh).toHaveBeenCalledWith('al-1')
+ expect(useImageUrl).toHaveBeenCalledWith('/rest/getCoverArt?id=al-1', 1234)
+ })
+
it('renders nothing without a record', () => {
useImageUrl.mockReturnValue({ imgUrl: null, loading: false })
const { container } = render()
diff --git a/ui/src/common/useArtworkRefresh.js b/ui/src/common/useArtworkRefresh.js
new file mode 100644
index 000000000..2e87d139c
--- /dev/null
+++ b/ui/src/common/useArtworkRefresh.js
@@ -0,0 +1,23 @@
+import { useState } from 'react'
+import { useSelector } from 'react-redux'
+
+const namesId = (resources, id) =>
+ Object.values(resources || {}).some(
+ (ids) => Array.isArray(ids) && ids.includes(id),
+ )
+
+// Artwork can change behind an unchanged URL (a background conversion replacing its stand-in), so
+// this returns when the server last announced new artwork for id, for useImageUrl to version by.
+export const useArtworkRefresh = (id) => {
+ const refresh = useSelector((state) => state.activity?.refresh)
+ const [refreshedAt, setRefreshedAt] = useState()
+ if (
+ id &&
+ refresh &&
+ namesId(refresh.resources, id) &&
+ refresh.lastReceived !== refreshedAt
+ ) {
+ setRefreshedAt(refresh.lastReceived)
+ }
+ return refreshedAt
+}
diff --git a/ui/src/common/useArtworkRefresh.test.js b/ui/src/common/useArtworkRefresh.test.js
new file mode 100644
index 000000000..89e6f9a1d
--- /dev/null
+++ b/ui/src/common/useArtworkRefresh.test.js
@@ -0,0 +1,44 @@
+import { renderHook } from '@testing-library/react-hooks'
+import { vi, describe, it, expect, beforeEach } from 'vitest'
+import { useSelector } from 'react-redux'
+import { useArtworkRefresh } from './useArtworkRefresh'
+
+vi.mock('react-redux', () => ({ useSelector: vi.fn() }))
+
+const withRefresh = (refresh) =>
+ useSelector.mockImplementation((select) => select({ activity: { refresh } }))
+
+describe('useArtworkRefresh', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('has no version before any refresh event', () => {
+ withRefresh(undefined)
+ const { result } = renderHook(() => useArtworkRefresh('ar-1'))
+ expect(result.current).toBeUndefined()
+ })
+
+ it('versions the artwork with the time of an event naming its id', () => {
+ withRefresh({ lastReceived: 100, resources: { artist: ['ar-1'] } })
+ const { result } = renderHook(() => useArtworkRefresh('ar-1'))
+ expect(result.current).toBe(100)
+ })
+
+ it('keeps its version when a later event names something else', () => {
+ withRefresh({ lastReceived: 100, resources: { artist: ['ar-1'] } })
+ const { result, rerender } = renderHook(() => useArtworkRefresh('ar-1'))
+
+ withRefresh({ lastReceived: 200, resources: { album: ['al-9'] } })
+ rerender()
+
+ expect(result.current).toBe(100)
+ })
+
+ // Honoring a wildcard would refetch every cover on screen after each scan.
+ it('ignores wildcard events', () => {
+ withRefresh({ lastReceived: 100, resources: { '*': '*', artist: ['*'] } })
+ const { result } = renderHook(() => useArtworkRefresh('ar-1'))
+ expect(result.current).toBeUndefined()
+ })
+})
diff --git a/ui/src/common/useImageUrl.js b/ui/src/common/useImageUrl.js
index 8a7f2bcc7..f52d13352 100644
--- a/ui/src/common/useImageUrl.js
+++ b/ui/src/common/useImageUrl.js
@@ -34,9 +34,10 @@ const evictIfNeeded = () => {
/**
* 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.
+ * so remounting returns the cached blob URL instantly. A new version re-fetches a cached url whose
+ * image changed behind it, painting the old blob until the new one arrives.
*/
-export const useImageUrl = (url) => {
+export const useImageUrl = (url, version) => {
const cached = url ? cache.get(url) : null
const [imgUrl, setImgUrl] = useState(cached?.blobUrl || null)
const [loading, setLoading] = useState(!!url && !cached)
@@ -68,7 +69,7 @@ export const useImageUrl = (url) => {
// 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) {
+ if (entry && entry.version === version) {
entry.refCount++
setImgUrl(entry.blobUrl)
setLoading(false)
@@ -77,12 +78,15 @@ export const useImageUrl = (url) => {
entry.refCount--
}
}
+ const stale = entry?.blobUrl ? entry : null
const controller = new AbortController()
let queued = true
- setImgUrl(null)
- setLoading(true)
- setError(false)
+ if (!stale) {
+ setImgUrl(null)
+ setLoading(true)
+ setError(false)
+ }
const doFetch = () => {
queued = false
@@ -105,12 +109,15 @@ export const useImageUrl = (url) => {
// 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) {
+ if (existing && existing.blobUrl && existing.version === version) {
existing.refCount++
URL.revokeObjectURL(objectUrl)
setImgUrl(existing.blobUrl)
} else {
- cache.set(url, { blobUrl: objectUrl, refCount: 1 })
+ if (existing?.blobUrl && existing.refCount <= 0) {
+ URL.revokeObjectURL(existing.blobUrl)
+ }
+ cache.set(url, { blobUrl: objectUrl, refCount: 1, version })
evictIfNeeded()
setImgUrl(objectUrl)
}
@@ -122,8 +129,11 @@ export const useImageUrl = (url) => {
if (err.name === 'AbortError') {
return // Expected on unmount or URL change
}
+ if (stale) {
+ return // The old image is still valid; keep showing it
+ }
// Cache the error so repeated mounts don't re-fetch broken URLs
- cache.set(url, { blobUrl: null, error: true, refCount: 0 })
+ cache.set(url, { blobUrl: null, error: true, refCount: 0, version })
setError(true)
setLoading(false)
})
@@ -150,7 +160,7 @@ export const useImageUrl = (url) => {
entry.refCount--
}
}
- }, [url])
+ }, [url, version])
return { imgUrl, loading, error, fromCache }
}
diff --git a/ui/src/common/useImageUrl.test.js b/ui/src/common/useImageUrl.test.js
index 818732d06..bc6bc70fd 100644
--- a/ui/src/common/useImageUrl.test.js
+++ b/ui/src/common/useImageUrl.test.js
@@ -234,4 +234,90 @@ describe('useImageUrl', () => {
// A remembered failure has no blob, so callers must not treat it as instantly painted.
expect(result2.current.fromCache).toBe(false)
})
+
+ describe('version', () => {
+ const ok = (data) =>
+ Promise.resolve({
+ ok: true,
+ blob: () => Promise.resolve(new Blob([data])),
+ })
+ const renderVersioned = (version) =>
+ renderHook(
+ ({ version }) => useImageUrl('http://example.com/img.jpg', version),
+ { initialProps: { version } },
+ )
+
+ it('re-fetches a cached url when its version changes, showing the old image meanwhile', async () => {
+ let resolveNew
+ global.URL.createObjectURL = vi
+ .fn()
+ .mockReturnValueOnce('blob:old')
+ .mockReturnValueOnce('blob:new')
+ global.fetch = vi
+ .fn()
+ .mockImplementationOnce(() => ok('old'))
+ .mockImplementationOnce(
+ () => new Promise((resolve) => (resolveNew = resolve)),
+ )
+
+ const { result, rerender } = renderVersioned(undefined)
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(result.current.imgUrl).toBe('blob:old')
+
+ rerender({ version: 1 })
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(global.fetch).toHaveBeenCalledTimes(2)
+ expect(result.current.imgUrl).toBe('blob:old')
+ expect(result.current.loading).toBe(false)
+
+ await act(async () => {
+ resolveNew({ ok: true, blob: () => Promise.resolve(new Blob(['new'])) })
+ await flushPromises()
+ })
+ expect(result.current.imgUrl).toBe('blob:new')
+ })
+
+ it('keeps the old image when the re-fetch fails', async () => {
+ global.fetch = vi
+ .fn()
+ .mockImplementationOnce(() => ok('old'))
+ .mockImplementationOnce(() =>
+ Promise.resolve({ ok: false, status: 500 }),
+ )
+
+ const { result, rerender } = renderVersioned(undefined)
+ await act(async () => {
+ await flushPromises()
+ })
+ rerender({ version: 1 })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(global.fetch).toHaveBeenCalledTimes(2)
+ expect(result.current.imgUrl).toBe('blob:mock-url')
+ expect(result.current.error).toBe(false)
+ })
+
+ it('does not re-fetch on remount with the version it already has', async () => {
+ global.fetch = vi.fn(() => ok('data'))
+
+ const { unmount } = renderVersioned(1)
+ await act(async () => {
+ await flushPromises()
+ })
+ unmount()
+ const { result } = renderVersioned(1)
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(global.fetch).toHaveBeenCalledTimes(1)
+ expect(result.current.fromCache).toBe(true)
+ })
+ })
})