fix(image-pipeline): share source bitmap cache for abortable loads

This commit is contained in:
Matthias
2026-04-04 11:26:45 +02:00
parent 8660126fd6
commit c41dde871f
2 changed files with 206 additions and 32 deletions

View File

@@ -10,34 +10,10 @@ function throwIfAborted(signal: AbortSignal | undefined): void {
}
}
export async function loadSourceBitmap(
sourceUrl: string,
options: LoadSourceBitmapOptions = {},
): Promise<ImageBitmap> {
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<ImageBitmap> {
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) {
void promise.catch(() => {
if (imageBitmapCache.get(sourceUrl) === promise) {
imageBitmapCache.delete(sourceUrl);
throw error;
}
});
return promise;
}
async function awaitWithLocalAbort<T>(
promise: Promise<T>,
signal: AbortSignal | undefined,
): Promise<T> {
throwIfAborted(signal);
if (!signal) {
return await promise;
}
return await new Promise<T>((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<ImageBitmap> {
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);
}

View File

@@ -0,0 +1,140 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error?: unknown) => void;
};
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error?: unknown) => void;
const promise = new Promise<T>((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<Blob>;
}>();
const blobDeferred = createDeferred<Blob>();
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);
});
});