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) + }) + }) })