fix(image-pipeline): bound source bitmap cache lifecycle

This commit is contained in:
Matthias
2026-04-04 11:37:26 +02:00
parent 77f8736579
commit c0534e04e0
2 changed files with 148 additions and 5 deletions

View File

@@ -1,4 +1,11 @@
const imageBitmapCache = new Map<string, Promise<ImageBitmap>>(); export const SOURCE_BITMAP_CACHE_MAX_ENTRIES = 32;
type CacheEntry = {
promise: Promise<ImageBitmap>;
bitmap?: ImageBitmap;
};
const imageBitmapCache = new Map<string, CacheEntry>();
type LoadSourceBitmapOptions = { type LoadSourceBitmapOptions = {
signal?: AbortSignal; signal?: AbortSignal;
@@ -10,12 +17,58 @@ function throwIfAborted(signal: AbortSignal | undefined): void {
} }
} }
function closeBitmap(bitmap: ImageBitmap | undefined): void {
if (typeof bitmap?.close === "function") {
bitmap.close();
}
}
function deleteCacheEntry(sourceUrl: string): void {
const entry = imageBitmapCache.get(sourceUrl);
if (!entry) {
return;
}
imageBitmapCache.delete(sourceUrl);
closeBitmap(entry.bitmap);
}
function touchCacheEntry(sourceUrl: string, entry: CacheEntry): void {
imageBitmapCache.delete(sourceUrl);
imageBitmapCache.set(sourceUrl, entry);
}
function evictIfNeeded(excludeSourceUrl?: string): void {
while (imageBitmapCache.size > SOURCE_BITMAP_CACHE_MAX_ENTRIES) {
const oldestSourceUrl = [...imageBitmapCache.entries()].find(
([key, entry]) => key !== excludeSourceUrl && entry.bitmap,
)?.[0];
if (!oldestSourceUrl) {
return;
}
deleteCacheEntry(oldestSourceUrl);
}
}
export function clearSourceBitmapCache(): void {
for (const sourceUrl of [...imageBitmapCache.keys()]) {
deleteCacheEntry(sourceUrl);
}
}
function getOrCreateSourceBitmapPromise(sourceUrl: string): Promise<ImageBitmap> { function getOrCreateSourceBitmapPromise(sourceUrl: string): Promise<ImageBitmap> {
const cached = imageBitmapCache.get(sourceUrl); const cached = imageBitmapCache.get(sourceUrl);
if (cached) { if (cached) {
return cached; touchCacheEntry(sourceUrl, cached);
return cached.promise;
} }
const entry: CacheEntry = {
promise: Promise.resolve(undefined as never),
};
const promise = (async () => { const promise = (async () => {
const response = await fetch(sourceUrl); const response = await fetch(sourceUrl);
if (!response.ok) { if (!response.ok) {
@@ -23,13 +76,17 @@ function getOrCreateSourceBitmapPromise(sourceUrl: string): Promise<ImageBitmap>
} }
const blob = await response.blob(); const blob = await response.blob();
return await createImageBitmap(blob); const bitmap = await createImageBitmap(blob);
entry.bitmap = bitmap;
evictIfNeeded(sourceUrl);
return bitmap;
})(); })();
imageBitmapCache.set(sourceUrl, promise); entry.promise = promise;
imageBitmapCache.set(sourceUrl, entry);
void promise.catch(() => { void promise.catch(() => {
if (imageBitmapCache.get(sourceUrl) === promise) { if (imageBitmapCache.get(sourceUrl) === entry) {
imageBitmapCache.delete(sourceUrl); imageBitmapCache.delete(sourceUrl);
} }
}); });

View File

@@ -167,4 +167,90 @@ describe("loadSourceBitmap", () => {
expect(succeedingResponse.blob).toHaveBeenCalledTimes(1); expect(succeedingResponse.blob).toHaveBeenCalledTimes(1);
expect(createImageBitmap).toHaveBeenCalledTimes(1); expect(createImageBitmap).toHaveBeenCalledTimes(1);
}); });
it("evicts the least-recently-used bitmap once the cache limit is exceeded", async () => {
const subject = (await importSubject()) as typeof import("@/lib/image-pipeline/source-loader") & {
SOURCE_BITMAP_CACHE_MAX_ENTRIES?: number;
};
const { SOURCE_BITMAP_CACHE_MAX_ENTRIES, loadSourceBitmap } = subject;
expect(SOURCE_BITMAP_CACHE_MAX_ENTRIES).toBeTypeOf("number");
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(async (input: string | URL | Request) => ({
ok: true,
status: 200,
blob: vi.fn().mockResolvedValue(new Blob([String(input)])),
})),
);
const urls = Array.from(
{ length: SOURCE_BITMAP_CACHE_MAX_ENTRIES! + 1 },
(_, index) => `https://cdn.example.com/source-${index}.png`,
);
for (const url of urls) {
await expect(loadSourceBitmap(url)).resolves.toBe(bitmap);
}
expect(fetch).toHaveBeenCalledTimes(urls.length);
await expect(loadSourceBitmap(urls[urls.length - 1])).resolves.toBe(bitmap);
expect(fetch).toHaveBeenCalledTimes(urls.length);
await expect(loadSourceBitmap(urls[0])).resolves.toBe(bitmap);
expect(fetch).toHaveBeenCalledTimes(urls.length + 1);
});
it("closes evicted bitmaps and disposes the remaining cache explicitly", async () => {
const subject = (await importSubject()) as typeof import("@/lib/image-pipeline/source-loader") & {
SOURCE_BITMAP_CACHE_MAX_ENTRIES?: number;
clearSourceBitmapCache?: () => void;
};
const { SOURCE_BITMAP_CACHE_MAX_ENTRIES, clearSourceBitmapCache, loadSourceBitmap } = subject;
expect(SOURCE_BITMAP_CACHE_MAX_ENTRIES).toBeGreaterThanOrEqual(2);
expect(clearSourceBitmapCache).toBeTypeOf("function");
const bitmaps = Array.from(
{ length: SOURCE_BITMAP_CACHE_MAX_ENTRIES! + 1 },
() => ({ close: vi.fn() }) as unknown as ImageBitmap,
);
let bitmapIndex = 0;
vi.stubGlobal(
"createImageBitmap",
vi.fn().mockImplementation(async () => bitmaps[bitmapIndex++]),
);
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(async (input: string | URL | Request) => ({
ok: true,
status: 200,
blob: vi.fn().mockResolvedValue(new Blob([String(input)])),
})),
);
const urls = Array.from(
{ length: SOURCE_BITMAP_CACHE_MAX_ENTRIES! + 1 },
(_, index) => `https://cdn.example.com/source-${index}.png`,
);
for (const [index, url] of urls.entries()) {
await expect(loadSourceBitmap(url)).resolves.toBe(bitmaps[index]);
}
expect(bitmaps[0].close).toHaveBeenCalledTimes(1);
for (const bitmap of bitmaps.slice(1)) {
expect(bitmap.close).not.toHaveBeenCalled();
}
clearSourceBitmapCache!();
expect(bitmaps[0].close).toHaveBeenCalledTimes(1);
for (const bitmap of bitmaps.slice(1)) {
expect(bitmap.close).toHaveBeenCalledTimes(1);
}
});
}); });