fix(imageCache): share refcounted blob URLs across consumers (Windows perf) (#321)

The previous design handed every <img> 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 <img src> is already the blob URL. No fetchUrl→blobUrl
  swap, no decode thrash, no race against the LRU.
- CachedImage passes fallbackToFetch=false: previously the <img>
  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) <noreply@anthropic.com>
This commit is contained in:
Frank Stellmacher
2026-04-26 14:17:12 +02:00
committed by GitHub
parent 6a39c5aaa1
commit c4a283b809
2 changed files with 107 additions and 29 deletions
+53 -2
View File
@@ -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 <img>/<canvas>/object URL still references it. Each consumer
// is responsible for its own URL.createObjectURL lifecycle.
// the GC once no <img>/<canvas>/object URL still references it.
const blobCache = new Map<string, Blob>();
// Refcounted object URLs shared across all consumers of the same cacheKey.
// Chromium/WebView2 keys its decoded-image cache by URL, so handing every
// <img> 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<typeof setTimeout> | null };
const urlEntries = new Map<string, UrlEntry>();
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<number> {
export async function invalidateCacheKey(cacheKey: string): Promise<void> {
blobCache.delete(cacheKey);
purgeUrlEntry(cacheKey);
try {
const database = await openDB();
await new Promise<void>(resolve => {
@@ -181,6 +231,7 @@ export async function invalidateCoverArt(entityId: string): Promise<void> {
export async function clearImageCache(): Promise<void> {
blobCache.clear();
for (const key of Array.from(urlEntries.keys())) purgeUrlEntry(key);
try {
const database = await openDB();
await new Promise<void>(resolve => {