From c0534e04e09f54c593fa493ea9357b0bfd818e49 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 11:37:26 +0200 Subject: [PATCH] fix(image-pipeline): bound source bitmap cache lifecycle --- lib/image-pipeline/source-loader.ts | 67 +++++++++++++++-- tests/image-pipeline/source-loader.test.ts | 86 ++++++++++++++++++++++ 2 files changed, 148 insertions(+), 5 deletions(-) diff --git a/lib/image-pipeline/source-loader.ts b/lib/image-pipeline/source-loader.ts index dbb5164..cfa6c06 100644 --- a/lib/image-pipeline/source-loader.ts +++ b/lib/image-pipeline/source-loader.ts @@ -1,4 +1,11 @@ -const imageBitmapCache = new Map>(); +export const SOURCE_BITMAP_CACHE_MAX_ENTRIES = 32; + +type CacheEntry = { + promise: Promise; + bitmap?: ImageBitmap; +}; + +const imageBitmapCache = new Map(); type LoadSourceBitmapOptions = { 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 { const cached = imageBitmapCache.get(sourceUrl); if (cached) { - return cached; + touchCacheEntry(sourceUrl, cached); + return cached.promise; } + const entry: CacheEntry = { + promise: Promise.resolve(undefined as never), + }; + const promise = (async () => { const response = await fetch(sourceUrl); if (!response.ok) { @@ -23,13 +76,17 @@ function getOrCreateSourceBitmapPromise(sourceUrl: string): Promise } 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(() => { - if (imageBitmapCache.get(sourceUrl) === promise) { + if (imageBitmapCache.get(sourceUrl) === entry) { imageBitmapCache.delete(sourceUrl); } }); diff --git a/tests/image-pipeline/source-loader.test.ts b/tests/image-pipeline/source-loader.test.ts index cdf0837..2d44d59 100644 --- a/tests/image-pipeline/source-loader.test.ts +++ b/tests/image-pipeline/source-loader.test.ts @@ -167,4 +167,90 @@ describe("loadSourceBitmap", () => { expect(succeedingResponse.blob).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); + } + }); });