mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
committed by
GitHub
parent
6a39c5aaa1
commit
c4a283b809
@@ -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<HTMLImageElement> {
|
||||
src: string;
|
||||
cacheKey: string;
|
||||
}
|
||||
|
||||
// Delay between the consumer dropping a cached URL and the actual revoke,
|
||||
// giving the DOM <img> 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 <img> 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 <img src> 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<string | null>(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
|
||||
|
||||
+53
-2
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user