From c4a283b80919238ff4a0aa49661d6bfa9c4402dc Mon Sep 17 00:00:00 2001
From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com>
Date: Sun, 26 Apr 2026 14:17:12 +0200
Subject: [PATCH] fix(imageCache): share refcounted blob URLs across consumers
(Windows perf) (#321)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The previous design handed every
its own URL.createObjectURL for the
same cached Blob. WebKitGTK shrugged it off, but Chromium/WebView2 keys
its decoded-image cache by URL — so identical thumbnails were re-decoded
once per instance. On Windows this made cover/artist grids painfully slow
even when blobs were warm in memory.
Refactor the URL layer to be refcounted and shared:
- New acquireUrl(cacheKey) / releaseUrl(cacheKey) API. First acquire
creates the URL; subsequent acquires return the same string and bump
the refcount. Revoke is deferred 500 ms after the count hits zero so
in-flight decodes finish cleanly.
- useCachedUrl uses a lazy useState initializer: when the blob is hot,
the very first
is already the blob URL. No fetchUrl→blobUrl
swap, no decode thrash, no race against the LRU.
- CachedImage passes fallbackToFetch=false: previously the
briefly carried the raw server URL while the blob resolved, which
triggered an HTTP fetch that the browser then aborted when src
flipped to blob: — visible in DevTools as a flood of "Pending / 0 B"
requests. Memory hits remain instant via the synchronous acquire
path; cold paths now do a single fetch via getCachedBlob.
- invalidateCacheKey / clearImageCache now also purge URL entries.
Co-authored-by: Claude Opus 4.7 (1M context)
---
src/components/CachedImage.tsx | 81 ++++++++++++++++++++++------------
src/utils/imageCache.ts | 55 ++++++++++++++++++++++-
2 files changed, 107 insertions(+), 29 deletions(-)
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
/