From c41dde871fc829710007294b1a2b950e1fc276ca Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 11:26:45 +0200 Subject: [PATCH] fix(image-pipeline): share source bitmap cache for abortable loads --- lib/image-pipeline/source-loader.ts | 98 ++++++++++----- tests/image-pipeline/source-loader.test.ts | 140 +++++++++++++++++++++ 2 files changed, 206 insertions(+), 32 deletions(-) create mode 100644 tests/image-pipeline/source-loader.test.ts diff --git a/lib/image-pipeline/source-loader.ts b/lib/image-pipeline/source-loader.ts index 94702c5..6eb3dbb 100644 --- a/lib/image-pipeline/source-loader.ts +++ b/lib/image-pipeline/source-loader.ts @@ -10,34 +10,10 @@ function throwIfAborted(signal: AbortSignal | undefined): void { } } -export async function loadSourceBitmap( - sourceUrl: string, - options: LoadSourceBitmapOptions = {}, -): Promise { - if (!sourceUrl || sourceUrl.trim().length === 0) { - throw new Error("Render sourceUrl is required."); - } - - if (typeof createImageBitmap !== "function") { - throw new Error("ImageBitmap is not available in this environment."); - } - - throwIfAborted(options.signal); - - if (options.signal) { - const response = await fetch(sourceUrl, { signal: options.signal }); - if (!response.ok) { - throw new Error(`Render source failed: ${response.status}`); - } - - const blob = await response.blob(); - throwIfAborted(options.signal); - return await createImageBitmap(blob); - } - +function getOrCreateSourceBitmapPromise(sourceUrl: string): Promise { const cached = imageBitmapCache.get(sourceUrl); if (cached) { - return await cached; + return cached; } const promise = (async () => { @@ -52,10 +28,68 @@ export async function loadSourceBitmap( imageBitmapCache.set(sourceUrl, promise); - try { - return await promise; - } catch (error) { - imageBitmapCache.delete(sourceUrl); - throw error; - } + void promise.catch(() => { + if (imageBitmapCache.get(sourceUrl) === promise) { + imageBitmapCache.delete(sourceUrl); + } + }); + + return promise; +} + +async function awaitWithLocalAbort( + promise: Promise, + signal: AbortSignal | undefined, +): Promise { + throwIfAborted(signal); + + if (!signal) { + return await promise; + } + + return await new Promise((resolve, reject) => { + const abortError = () => new DOMException("The operation was aborted.", "AbortError"); + + const cleanup = () => { + signal.removeEventListener("abort", onAbort); + }; + + const onAbort = () => { + cleanup(); + reject(abortError()); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + + promise.then( + (value) => { + cleanup(); + if (signal.aborted) { + reject(abortError()); + return; + } + resolve(value); + }, + (error) => { + cleanup(); + reject(error); + }, + ); + }); +} + +export async function loadSourceBitmap( + sourceUrl: string, + options: LoadSourceBitmapOptions = {}, +): Promise { + if (!sourceUrl || sourceUrl.trim().length === 0) { + throw new Error("Render sourceUrl is required."); + } + + if (typeof createImageBitmap !== "function") { + throw new Error("ImageBitmap is not available in this environment."); + } + + const promise = getOrCreateSourceBitmapPromise(sourceUrl); + return await awaitWithLocalAbort(promise, options.signal); } diff --git a/tests/image-pipeline/source-loader.test.ts b/tests/image-pipeline/source-loader.test.ts new file mode 100644 index 0000000..835629f --- /dev/null +++ b/tests/image-pipeline/source-loader.test.ts @@ -0,0 +1,140 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (error?: unknown) => void; +}; + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error?: unknown) => void; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + + return { promise, resolve, reject }; +} + +async function importSubject() { + vi.resetModules(); + return await import("@/lib/image-pipeline/source-loader"); +} + +describe("loadSourceBitmap", () => { + const bitmap = { width: 64, height: 64 } as ImageBitmap; + const blob = new Blob(["source"]); + + beforeEach(() => { + vi.restoreAllMocks(); + vi.stubGlobal("createImageBitmap", vi.fn().mockResolvedValue(bitmap)); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("reuses one fetch/decode pipeline for concurrent abortable callers", async () => { + const response = { + ok: true, + status: 200, + blob: vi.fn().mockResolvedValue(blob), + }; + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); + + const { loadSourceBitmap } = await importSubject(); + + const first = loadSourceBitmap("https://cdn.example.com/source.png", { + signal: new AbortController().signal, + }); + const second = loadSourceBitmap("https://cdn.example.com/source.png", { + signal: new AbortController().signal, + }); + + await expect(first).resolves.toBe(bitmap); + await expect(second).resolves.toBe(bitmap); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(response.blob).toHaveBeenCalledTimes(1); + expect(createImageBitmap).toHaveBeenCalledTimes(1); + }); + + it("lets a later consumer succeed after an earlier caller aborts", async () => { + const responseDeferred = createDeferred<{ + ok: boolean; + status: number; + blob: () => Promise; + }>(); + const blobDeferred = createDeferred(); + + vi.stubGlobal("fetch", vi.fn().mockImplementation(() => responseDeferred.promise)); + + const { loadSourceBitmap } = await importSubject(); + const controller = new AbortController(); + + const abortedPromise = loadSourceBitmap("https://cdn.example.com/source.png", { + signal: controller.signal, + }); + + controller.abort(); + + const laterPromise = loadSourceBitmap("https://cdn.example.com/source.png"); + + responseDeferred.resolve({ + ok: true, + status: 200, + blob: vi.fn().mockImplementation(() => blobDeferred.promise), + }); + blobDeferred.resolve(blob); + + await expect(abortedPromise).rejects.toMatchObject({ name: "AbortError" }); + await expect(laterPromise).resolves.toBe(bitmap); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(createImageBitmap).toHaveBeenCalledTimes(1); + }); + + it("clears a failed fetch from cache so the next attempt retries", async () => { + const failingResponse = { + ok: false, + status: 503, + blob: vi.fn(), + }; + const succeedingResponse = { + ok: true, + status: 200, + blob: vi.fn().mockResolvedValue(blob), + }; + + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(failingResponse) + .mockResolvedValueOnce(succeedingResponse), + ); + + const { loadSourceBitmap } = await importSubject(); + + await expect( + loadSourceBitmap("https://cdn.example.com/source.png", { + signal: new AbortController().signal, + }), + ).rejects.toThrow("Render source failed: 503"); + + await expect( + loadSourceBitmap("https://cdn.example.com/source.png", { + signal: new AbortController().signal, + }), + ).resolves.toBe(bitmap); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(succeedingResponse.blob).toHaveBeenCalledTimes(1); + expect(createImageBitmap).toHaveBeenCalledTimes(1); + }); +});