From 4fa517066f6125e70e7978ef018daf123ac5d5aa Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 11:40:32 +0200 Subject: [PATCH] fix(image-pipeline): close cleared in-flight source bitmaps --- lib/image-pipeline/source-loader.ts | 8 ++++ tests/image-pipeline/source-loader.test.ts | 45 ++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/lib/image-pipeline/source-loader.ts b/lib/image-pipeline/source-loader.ts index cfa6c06..a6665e3 100644 --- a/lib/image-pipeline/source-loader.ts +++ b/lib/image-pipeline/source-loader.ts @@ -3,6 +3,7 @@ export const SOURCE_BITMAP_CACHE_MAX_ENTRIES = 32; type CacheEntry = { promise: Promise; bitmap?: ImageBitmap; + released?: boolean; }; const imageBitmapCache = new Map(); @@ -29,6 +30,7 @@ function deleteCacheEntry(sourceUrl: string): void { return; } + entry.released = true; imageBitmapCache.delete(sourceUrl); closeBitmap(entry.bitmap); } @@ -77,6 +79,12 @@ function getOrCreateSourceBitmapPromise(sourceUrl: string): Promise const blob = await response.blob(); const bitmap = await createImageBitmap(blob); + + if (entry.released || imageBitmapCache.get(sourceUrl) !== entry) { + closeBitmap(bitmap); + return bitmap; + } + entry.bitmap = bitmap; evictIfNeeded(sourceUrl); return bitmap; diff --git a/tests/image-pipeline/source-loader.test.ts b/tests/image-pipeline/source-loader.test.ts index 2d44d59..d45409c 100644 --- a/tests/image-pipeline/source-loader.test.ts +++ b/tests/image-pipeline/source-loader.test.ts @@ -253,4 +253,49 @@ describe("loadSourceBitmap", () => { expect(bitmap.close).toHaveBeenCalledTimes(1); } }); + + it("closes a decoded bitmap that resolves after its cache entry was cleared in flight", async () => { + const subject = (await importSubject()) as typeof import("@/lib/image-pipeline/source-loader") & { + clearSourceBitmapCache?: () => void; + }; + const { clearSourceBitmapCache, loadSourceBitmap } = subject; + + expect(clearSourceBitmapCache).toBeTypeOf("function"); + + const firstBitmap = { close: vi.fn() } as unknown as ImageBitmap; + const secondBitmap = { close: vi.fn() } as unknown as ImageBitmap; + const decodeDeferred = createDeferred(); + + vi.stubGlobal( + "createImageBitmap", + vi + .fn() + .mockImplementationOnce(async () => await decodeDeferred.promise) + .mockResolvedValueOnce(secondBitmap), + ); + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(async (input: string | URL | Request) => ({ + ok: true, + status: 200, + blob: vi.fn().mockResolvedValue(new Blob([String(input)])), + })), + ); + + const sourceUrl = "https://cdn.example.com/source-in-flight.png"; + const pendingLoad = loadSourceBitmap(sourceUrl); + + await vi.waitFor(() => { + expect(createImageBitmap).toHaveBeenCalledTimes(1); + }); + + clearSourceBitmapCache!(); + decodeDeferred.resolve(firstBitmap); + + await expect(pendingLoad).resolves.toBe(firstBitmap); + expect(firstBitmap.close).toHaveBeenCalledTimes(1); + + await expect(loadSourceBitmap(sourceUrl)).resolves.toBe(secondBitmap); + expect(fetch).toHaveBeenCalledTimes(2); + }); });