diff --git a/src/components/CachedImage.tsx b/src/components/CachedImage.tsx index 3b46fbdc..f2009851 100644 --- a/src/components/CachedImage.tsx +++ b/src/components/CachedImage.tsx @@ -1,23 +1,16 @@ import React, { useEffect, useRef, useState } from 'react'; -import { getCachedBlob } from '../utils/imageCache'; +import { acquireUrl, getCachedBlob, releaseUrl } from '../utils/imageCache'; interface CachedImageProps extends React.ImgHTMLAttributes { src: string; cacheKey: string; } -// Delay between the consumer dropping a cached URL and the actual revoke, -// giving the DOM time to finish its decode of the URL we just took -// away. 500 ms is comfortably above any realistic decode latency without -// being long enough to leak meaningful memory. -const URL_REVOKE_DELAY_MS = 500; - /** - * Returns an object URL for a cached image. Each call owns its own URL: it - * is created when the blob arrives and revoked (after a small grace delay) - * on cleanup. There is no shared URL pool — the previous global LRU caused - * "Failed to load resource" errors when an in-use URL got revoked because a - * different consumer pushed it out of the cache. + * Returns a shared, refcounted object URL for a cached image. Multiple + * consumers of the same cacheKey see the exact same URL string, so the + * browser's decoded-image cache hits across instances — critical on + * Chromium/WebView2 (Windows), which keys decode results by URL. * * @param fallbackToFetch If true (default), returns the raw fetchUrl while the * blob is still resolving — useful for tags so the browser starts @@ -25,27 +18,58 @@ const URL_REVOKE_DELAY_MS = 500; * should only see a stable blob URL (prevents a double crossfade). */ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch = true): string { - const [resolved, setResolved] = useState(''); + // Synchronously acquire on first render when the blob is already hot. This + // makes the very first a blob URL, avoiding a fetchUrl→blobUrl + // swap that would trigger a redundant network request and decode pass. + const [resolved, setResolved] = useState(() => fetchUrl ? (acquireUrl(cacheKey) ?? '') : ''); + // Tracks whichever cacheKey we currently hold a refcount on, so we know + // exactly what to release on cleanup or when keys change. + const ownedKeyRef = useRef(resolved ? cacheKey : null); + useEffect(() => { - if (!fetchUrl) { setResolved(''); return; } - const controller = new AbortController(); - let createdUrl: string | null = null; - setResolved(''); - getCachedBlob(fetchUrl, cacheKey, controller.signal).then(blob => { - if (controller.signal.aborted) return; - if (blob) { - createdUrl = URL.createObjectURL(blob); - setResolved(createdUrl); + const release = () => { + if (ownedKeyRef.current) { + releaseUrl(ownedKeyRef.current); + ownedKeyRef.current = null; } + }; + + if (!fetchUrl) { + release(); + setResolved(''); + return; + } + + // Lazy initializer (or a previous run) already acquired the right key. + if (ownedKeyRef.current === cacheKey) return release; + + // Different key than we're currently holding: drop the old one. + release(); + + // Fast path: blob is hot in memory → grab the shared URL synchronously. + const sync = acquireUrl(cacheKey); + if (sync) { + ownedKeyRef.current = cacheKey; + setResolved(sync); + return release; + } + + // Slow path: fetch (or read from IDB), then acquire. + setResolved(''); + const controller = new AbortController(); + getCachedBlob(fetchUrl, cacheKey, controller.signal).then(blob => { + if (controller.signal.aborted || !blob) return; + const url = acquireUrl(cacheKey); + if (!url) return; + ownedKeyRef.current = cacheKey; + setResolved(url); }); return () => { controller.abort(); - if (createdUrl) { - const url = createdUrl; - setTimeout(() => URL.revokeObjectURL(url), URL_REVOKE_DELAY_MS); - } + release(); }; }, [fetchUrl, cacheKey]); + return fallbackToFetch ? (resolved || fetchUrl) : resolved; } @@ -66,7 +90,10 @@ export default function CachedImage({ src, cacheKey, style, onLoad, onError, ... }, []); // Pass empty string when not yet in view so useCachedUrl skips the fetch entirely. - const resolvedSrc = useCachedUrl(inView ? src : '', cacheKey); + // fallbackToFetch=false: avoid the fetchUrl→blobUrl src swap, which causes the browser + // to start a server fetch, then abort it when we replace src with the blob URL — + // visible in DevTools as a flood of "Pending / 0 B" requests on Chromium/WebView2. + const resolvedSrc = useCachedUrl(inView ? src : '', cacheKey, false); const [loaded, setLoaded] = useState(false); // Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl diff --git a/src/utils/imageCache.ts b/src/utils/imageCache.ts index 54bb0103..0b9ed528 100644 --- a/src/utils/imageCache.ts +++ b/src/utils/imageCache.ts @@ -8,10 +8,59 @@ const MAX_CONCURRENT_FETCHES = 5; // In-memory blob cache: cacheKey → Blob (insertion-order = LRU approximation). // Only the Map entry is dropped on overflow — the underlying Blob is freed by -// the GC once no //object URL still references it. Each consumer -// is responsible for its own URL.createObjectURL lifecycle. +// the GC once no //object URL still references it. const blobCache = new Map(); +// Refcounted object URLs shared across all consumers of the same cacheKey. +// Chromium/WebView2 keys its decoded-image cache by URL, so handing every +// its own URL.createObjectURL forces a fresh decode for each instance — +// catastrophic on Windows even for tiny cover thumbnails. Sharing a single +// URL per cacheKey lets the renderer reuse the decoded bitmap. +const URL_REVOKE_DELAY_MS = 500; +type UrlEntry = { url: string; refs: number; revokeTimer: ReturnType | null }; +const urlEntries = new Map(); + +function purgeUrlEntry(cacheKey: string): void { + const entry = urlEntries.get(cacheKey); + if (!entry) return; + if (entry.revokeTimer) clearTimeout(entry.revokeTimer); + URL.revokeObjectURL(entry.url); + urlEntries.delete(cacheKey); +} + +/** + * Returns a shared object URL for the cached blob of `cacheKey`, or null if + * not currently in memory. Pair every successful call with releaseUrl(). + * Subsequent acquires reuse the same URL and just bump the refcount. + */ +export function acquireUrl(cacheKey: string): string | null { + const blob = blobCache.get(cacheKey); + if (!blob) return null; + rememberBlob(cacheKey, blob); // refresh LRU position + let entry = urlEntries.get(cacheKey); + if (!entry) { + entry = { url: URL.createObjectURL(blob), refs: 0, revokeTimer: null }; + urlEntries.set(cacheKey, entry); + } else if (entry.revokeTimer) { + clearTimeout(entry.revokeTimer); + entry.revokeTimer = null; + } + entry.refs++; + return entry.url; +} + +/** Decrements the refcount; revokes (after grace delay) when it reaches zero. */ +export function releaseUrl(cacheKey: string): void { + const entry = urlEntries.get(cacheKey); + if (!entry) return; + entry.refs--; + if (entry.refs > 0) return; + entry.revokeTimer = setTimeout(() => { + URL.revokeObjectURL(entry.url); + urlEntries.delete(cacheKey); + }, URL_REVOKE_DELAY_MS); +} + let activeFetches = 0; const fetchQueue: Array<() => void> = []; @@ -160,6 +209,7 @@ export async function getImageCacheSize(): Promise { export async function invalidateCacheKey(cacheKey: string): Promise { blobCache.delete(cacheKey); + purgeUrlEntry(cacheKey); try { const database = await openDB(); await new Promise(resolve => { @@ -181,6 +231,7 @@ export async function invalidateCoverArt(entityId: string): Promise { export async function clearImageCache(): Promise { blobCache.clear(); + for (const key of Array.from(urlEntries.keys())) purgeUrlEntry(key); try { const database = await openDB(); await new Promise(resolve => {