fix(image-pipeline): dedupe in-flight preview requests

This commit is contained in:
Matthias
2026-04-04 12:03:04 +02:00
parent d73db3a612
commit 9a6192752e
3 changed files with 393 additions and 2 deletions

View File

@@ -40,6 +40,10 @@ type WorkerMessage =
| {
kind: "preview" | "full";
requestId: number;
payload?: {
previewWidth?: number;
includeHistogram?: boolean;
};
}
| {
kind: "cancel";
@@ -65,10 +69,39 @@ class FakeWorker {
}
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((innerResolve, innerReject) => {
resolve = innerResolve;
reject = innerReject;
});
return {
promise,
resolve,
reject,
};
}
describe("worker-client fallbacks", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
vi.stubGlobal(
"ImageData",
class ImageData {
data: Uint8ClampedArray;
width: number;
height: number;
constructor(data: Uint8ClampedArray, width: number, height: number) {
this.data = data;
this.width = width;
this.height = height;
}
},
);
previewRendererMocks.renderPreview.mockReset();
bridgeMocks.renderFull.mockReset();
previewRendererMocks.renderPreview.mockResolvedValue({
@@ -185,4 +218,191 @@ describe("worker-client fallbacks", () => {
expect(previewResult.width).toBe(16);
expect(fullResult.format).toBe("png");
});
it("shares one worker preview execution across identical requests", async () => {
const workerMessages: WorkerMessage[] = [];
FakeWorker.behavior = (worker, message) => {
workerMessages.push(message);
if (message.kind !== "preview") {
return;
}
queueMicrotask(() => {
worker.onmessage?.({
data: {
kind: "preview-result",
requestId: message.requestId,
payload: {
width: 8,
height: 4,
histogram: emptyHistogram(),
pixels: new Uint8ClampedArray(8 * 4 * 4).buffer,
},
},
} as MessageEvent);
});
};
vi.stubGlobal("Worker", FakeWorker as unknown as typeof Worker);
const { renderPreviewWithWorkerFallback } = await import("@/lib/image-pipeline/worker-client");
const request = {
sourceUrl: "https://cdn.example.com/source.png",
steps: [],
previewWidth: 128,
includeHistogram: true,
} as const;
const [first, second] = await Promise.all([
renderPreviewWithWorkerFallback(request),
renderPreviewWithWorkerFallback(request),
]);
expect(workerMessages.filter((message) => message.kind === "preview")).toHaveLength(1);
expect(previewRendererMocks.renderPreview).not.toHaveBeenCalled();
expect(first.width).toBe(8);
expect(second.width).toBe(8);
});
it("creates separate preview executions when width or histogram settings differ", async () => {
const workerMessages: WorkerMessage[] = [];
FakeWorker.behavior = (worker, message) => {
workerMessages.push(message);
if (message.kind !== "preview") {
return;
}
queueMicrotask(() => {
worker.onmessage?.({
data: {
kind: "preview-result",
requestId: message.requestId,
payload: {
width: message.payload?.previewWidth ?? 1,
height: 4,
histogram: emptyHistogram(),
pixels: new Uint8ClampedArray((message.payload?.previewWidth ?? 1) * 4 * 4).buffer,
},
},
} as MessageEvent);
});
};
vi.stubGlobal("Worker", FakeWorker as unknown as typeof Worker);
const { renderPreviewWithWorkerFallback } = await import("@/lib/image-pipeline/worker-client");
await Promise.all([
renderPreviewWithWorkerFallback({
sourceUrl: "https://cdn.example.com/source.png",
steps: [],
previewWidth: 128,
includeHistogram: false,
}),
renderPreviewWithWorkerFallback({
sourceUrl: "https://cdn.example.com/source.png",
steps: [],
previewWidth: 256,
includeHistogram: false,
}),
renderPreviewWithWorkerFallback({
sourceUrl: "https://cdn.example.com/source.png",
steps: [],
previewWidth: 128,
includeHistogram: true,
}),
]);
expect(workerMessages.filter((message) => message.kind === "preview")).toHaveLength(3);
});
it("removes aborted subscribers without canceling surviving identical preview consumers", async () => {
const workerMessages: WorkerMessage[] = [];
const previewStarted = createDeferred<void>();
FakeWorker.behavior = (worker, message) => {
workerMessages.push(message);
if (message.kind !== "preview") {
return;
}
previewStarted.resolve();
queueMicrotask(() => {
worker.onmessage?.({
data: {
kind: "preview-result",
requestId: message.requestId,
payload: {
width: 8,
height: 4,
histogram: emptyHistogram(),
pixels: new Uint8ClampedArray(8 * 4 * 4).buffer,
},
},
} as MessageEvent);
});
};
vi.stubGlobal("Worker", FakeWorker as unknown as typeof Worker);
const { renderPreviewWithWorkerFallback } = await import("@/lib/image-pipeline/worker-client");
const firstController = new AbortController();
const secondController = new AbortController();
const request = {
sourceUrl: "https://cdn.example.com/source.png",
steps: [],
previewWidth: 128,
includeHistogram: true,
} as const;
const firstPromise = renderPreviewWithWorkerFallback({
...request,
signal: firstController.signal,
});
const secondPromise = renderPreviewWithWorkerFallback({
...request,
signal: secondController.signal,
});
await previewStarted.promise;
firstController.abort();
await expect(firstPromise).rejects.toMatchObject({ name: "AbortError" });
await expect(secondPromise).resolves.toMatchObject({ width: 8, height: 4 });
expect(workerMessages.filter((message) => message.kind === "preview")).toHaveLength(1);
expect(workerMessages.filter((message) => message.kind === "cancel")).toHaveLength(0);
});
it("shares one fallback preview execution across identical requests", async () => {
vi.stubGlobal("Worker", undefined);
const deferred = createDeferred<Awaited<ReturnType<typeof previewRendererMocks.renderPreview>>>();
previewRendererMocks.renderPreview.mockReturnValueOnce(deferred.promise);
const { renderPreviewWithWorkerFallback } = await import("@/lib/image-pipeline/worker-client");
const firstPromise = renderPreviewWithWorkerFallback({
sourceUrl: "https://cdn.example.com/source.png",
steps: [],
previewWidth: 128,
includeHistogram: false,
});
const secondPromise = renderPreviewWithWorkerFallback({
sourceUrl: "https://cdn.example.com/source.png",
steps: [],
previewWidth: 128,
includeHistogram: false,
});
expect(previewRendererMocks.renderPreview).toHaveBeenCalledTimes(1);
deferred.resolve({
width: 16,
height: 16,
imageData: { data: new Uint8ClampedArray(16 * 16 * 4) },
histogram: emptyHistogram(),
});
const [first, second] = await Promise.all([firstPromise, secondPromise]);
expect(first.width).toBe(16);
expect(second.width).toBe(16);
});
});