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

@@ -3,7 +3,7 @@ import {
renderPreview,
type PreviewRenderResult,
} from "@/lib/image-pipeline/preview-renderer";
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
import { hashPipeline, type PipelineStep } from "@/lib/image-pipeline/contracts";
import type { HistogramData } from "@/lib/image-pipeline/histogram";
import type { RenderFullOptions, RenderFullResult } from "@/lib/image-pipeline/render-types";
@@ -76,6 +76,14 @@ let workerInstance: Worker | null = null;
let workerInitError: Error | null = null;
let requestIdCounter = 0;
const pendingRequests = new Map<number, PendingRequest>();
const inFlightPreviewRequests = new Map<string, SharedPreviewRequest>();
type SharedPreviewRequest = {
promise: Promise<PreviewRenderResult>;
abortController: AbortController;
consumers: Set<symbol>;
settled: boolean;
};
function nextRequestId(): number {
requestIdCounter += 1;
@@ -264,7 +272,20 @@ function runWorkerRequest<TResponse extends PreviewRenderResult | RenderFullResu
});
}
export async function renderPreviewWithWorkerFallback(options: {
function getPreviewRequestKey(options: {
sourceUrl: string;
steps: readonly PipelineStep[];
previewWidth: number;
includeHistogram?: boolean;
}): string {
return [
hashPipeline(options.sourceUrl, options.steps),
options.previewWidth,
options.includeHistogram === true ? 1 : 0,
].join(":");
}
async function runPreviewRequest(options: {
sourceUrl: string;
steps: readonly PipelineStep[];
previewWidth: number;
@@ -295,6 +316,105 @@ export async function renderPreviewWithWorkerFallback(options: {
}
}
function getOrCreateSharedPreviewRequest(options: {
sourceUrl: string;
steps: readonly PipelineStep[];
previewWidth: number;
includeHistogram?: boolean;
}): SharedPreviewRequest {
const key = getPreviewRequestKey(options);
const existing = inFlightPreviewRequests.get(key);
if (existing) {
return existing;
}
const abortController = new AbortController();
const sharedRequest: SharedPreviewRequest = {
abortController,
consumers: new Set(),
settled: false,
promise: Promise.resolve(undefined as never),
};
sharedRequest.promise = runPreviewRequest({
...options,
signal: abortController.signal,
}).finally(() => {
sharedRequest.settled = true;
inFlightPreviewRequests.delete(key);
});
inFlightPreviewRequests.set(key, sharedRequest);
return sharedRequest;
}
export async function renderPreviewWithWorkerFallback(options: {
sourceUrl: string;
steps: readonly PipelineStep[];
previewWidth: number;
includeHistogram?: boolean;
signal?: AbortSignal;
}): Promise<PreviewRenderResult> {
if (options.signal?.aborted) {
throw makeAbortError();
}
const sharedRequest = getOrCreateSharedPreviewRequest({
sourceUrl: options.sourceUrl,
steps: options.steps,
previewWidth: options.previewWidth,
includeHistogram: options.includeHistogram,
});
return await new Promise<PreviewRenderResult>((resolve, reject) => {
const consumerId = Symbol("preview-consumer");
let settled = false;
const settleOnce = (callback: () => void): void => {
if (settled) {
return;
}
settled = true;
sharedRequest.consumers.delete(consumerId);
if (options.signal) {
options.signal.removeEventListener("abort", abortHandler);
}
if (!sharedRequest.settled && sharedRequest.consumers.size === 0) {
sharedRequest.abortController.abort();
}
callback();
};
const abortHandler = () => {
settleOnce(() => {
reject(makeAbortError());
});
};
sharedRequest.consumers.add(consumerId);
if (options.signal) {
options.signal.addEventListener("abort", abortHandler, { once: true });
}
sharedRequest.promise.then(
(result) => {
settleOnce(() => {
resolve(result);
});
},
(error: unknown) => {
settleOnce(() => {
reject(error);
});
},
);
});
}
export async function renderFullWithWorkerFallback(
options: RenderFullOptions,
): Promise<RenderFullResult> {