fix(image-pipeline): coalesce preview churn to latest state

This commit is contained in:
Matthias
2026-04-04 12:11:39 +02:00
parent 9a6192752e
commit d02e6924f3
2 changed files with 196 additions and 8 deletions

View File

@@ -20,6 +20,8 @@ type UsePipelinePreviewOptions = {
maxDevicePixelRatio?: number;
};
const PREVIEW_RENDER_DEBOUNCE_MS = 48;
function computePreviewWidth(
nodeWidth: number,
previewScale: number,
@@ -159,7 +161,7 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
if (runIdRef.current !== currentRun) return;
setIsRendering(false);
});
}, 16);
}, PREVIEW_RENDER_DEBOUNCE_MS);
return () => {
window.clearTimeout(timer);

View File

@@ -11,6 +11,8 @@ const workerClientMocks = vi.hoisted(() => ({
renderPreviewWithWorkerFallback: vi.fn(),
}));
const PREVIEW_SETTLE_MS = 80;
vi.mock("@/lib/image-pipeline/worker-client", () => ({
isPipelineAbortError: () => false,
renderPreviewWithWorkerFallback: workerClientMocks.renderPreviewWithWorkerFallback,
@@ -18,6 +20,38 @@ vi.mock("@/lib/image-pipeline/worker-client", () => ({
import { usePipelinePreview } from "@/hooks/use-pipeline-preview";
function createPreviewResult(width: number, height: number) {
return {
width,
height,
imageData: { data: new Uint8ClampedArray(width * height * 4) },
histogram: emptyHistogram(),
};
}
function createDeferredPreviewResult() {
let resolve: ((value: ReturnType<typeof createPreviewResult>) => void) | null = null;
return {
promise: new Promise<ReturnType<typeof createPreviewResult>>((innerResolve) => {
resolve = innerResolve;
}),
resolve(value: ReturnType<typeof createPreviewResult>) {
resolve?.(value);
},
};
}
function createLightAdjustSteps(brightness: number): PipelineStep[] {
return [
{
nodeId: "light-1",
type: "light-adjust",
params: { brightness },
},
];
}
const previewHarnessState = {
latestHistogram: emptyHistogram(),
latestError: null as string | null,
@@ -53,6 +87,8 @@ function PreviewHarness({
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("usePipelinePreview", () => {
let putImageDataSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
previewHarnessState.latestHistogram = emptyHistogram();
@@ -66,8 +102,10 @@ describe("usePipelinePreview", () => {
histogram: emptyHistogram(),
});
putImageDataSpy = vi.fn();
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
putImageData: vi.fn(),
putImageData: putImageDataSpy,
} as unknown as CanvasRenderingContext2D);
container = document.createElement("div");
@@ -107,7 +145,7 @@ describe("usePipelinePreview", () => {
});
await act(async () => {
vi.advanceTimersByTime(16);
vi.advanceTimersByTime(PREVIEW_SETTLE_MS);
await Promise.resolve();
});
@@ -129,7 +167,7 @@ describe("usePipelinePreview", () => {
});
await act(async () => {
vi.advanceTimersByTime(16);
vi.advanceTimersByTime(PREVIEW_SETTLE_MS);
await Promise.resolve();
});
@@ -160,7 +198,7 @@ describe("usePipelinePreview", () => {
});
await act(async () => {
vi.advanceTimersByTime(16);
vi.advanceTimersByTime(PREVIEW_SETTLE_MS);
await Promise.resolve();
});
@@ -199,7 +237,7 @@ describe("usePipelinePreview", () => {
});
await act(async () => {
vi.advanceTimersByTime(16);
vi.advanceTimersByTime(PREVIEW_SETTLE_MS);
await Promise.resolve();
});
@@ -223,7 +261,7 @@ describe("usePipelinePreview", () => {
});
await act(async () => {
vi.advanceTimersByTime(16);
vi.advanceTimersByTime(PREVIEW_SETTLE_MS);
await Promise.resolve();
});
@@ -243,7 +281,7 @@ describe("usePipelinePreview", () => {
});
await act(async () => {
vi.advanceTimersByTime(16);
vi.advanceTimersByTime(PREVIEW_SETTLE_MS);
await Promise.resolve();
});
@@ -261,6 +299,154 @@ describe("usePipelinePreview", () => {
}),
);
});
it("only commits the latest visible preview after rapid sequential invalidations", async () => {
await act(async () => {
root?.render(
createElement(PreviewHarness, {
sourceUrl: "https://cdn.example.com/source.png",
steps: createLightAdjustSteps(10),
includeHistogram: false,
}),
);
});
await act(async () => {
root?.render(
createElement(PreviewHarness, {
sourceUrl: "https://cdn.example.com/source.png",
steps: createLightAdjustSteps(20),
includeHistogram: false,
}),
);
root?.render(
createElement(PreviewHarness, {
sourceUrl: "https://cdn.example.com/source.png",
steps: createLightAdjustSteps(30),
includeHistogram: false,
}),
);
});
await act(async () => {
vi.advanceTimersByTime(80);
await Promise.resolve();
});
expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledTimes(1);
expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledWith(
expect.objectContaining({
steps: createLightAdjustSteps(30),
}),
);
expect(putImageDataSpy).toHaveBeenCalledTimes(1);
});
it("does not let stale finished renders overwrite a newer preview", async () => {
const firstRender = createDeferredPreviewResult();
const secondRender = createDeferredPreviewResult();
workerClientMocks.renderPreviewWithWorkerFallback.mockReset();
workerClientMocks.renderPreviewWithWorkerFallback
.mockReturnValueOnce(firstRender.promise)
.mockReturnValueOnce(secondRender.promise);
await act(async () => {
root?.render(
createElement(PreviewHarness, {
sourceUrl: "https://cdn.example.com/source.png",
steps: createLightAdjustSteps(10),
includeHistogram: false,
}),
);
});
await act(async () => {
vi.advanceTimersByTime(80);
await Promise.resolve();
});
await act(async () => {
root?.render(
createElement(PreviewHarness, {
sourceUrl: "https://cdn.example.com/source.png",
steps: createLightAdjustSteps(20),
includeHistogram: false,
}),
);
});
await act(async () => {
vi.advanceTimersByTime(80);
await Promise.resolve();
});
const latestResult = createPreviewResult(240, 120);
await act(async () => {
secondRender.resolve(latestResult);
await Promise.resolve();
});
await act(async () => {
firstRender.resolve(createPreviewResult(120, 80));
await Promise.resolve();
});
const canvas = container?.querySelector("canvas");
expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledTimes(2);
expect(putImageDataSpy).toHaveBeenCalledTimes(1);
expect(putImageDataSpy).toHaveBeenCalledWith(latestResult.imageData, 0, 0);
expect(canvas?.width).toBe(240);
expect(canvas?.height).toBe(120);
});
it("coalesces slider churn so transient values do not fan out one render per value", async () => {
workerClientMocks.renderPreviewWithWorkerFallback.mockImplementation(
() => new Promise(() => undefined),
);
await act(async () => {
root?.render(
createElement(PreviewHarness, {
sourceUrl: "https://cdn.example.com/source.png",
steps: createLightAdjustSteps(10),
includeHistogram: false,
}),
);
});
for (const brightness of [20, 30, 40, 50]) {
await act(async () => {
vi.advanceTimersByTime(20);
await Promise.resolve();
});
expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledTimes(0);
await act(async () => {
root?.render(
createElement(PreviewHarness, {
sourceUrl: "https://cdn.example.com/source.png",
steps: createLightAdjustSteps(brightness),
includeHistogram: false,
}),
);
});
}
await act(async () => {
vi.advanceTimersByTime(80);
await Promise.resolve();
});
expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledTimes(1);
expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledWith(
expect.objectContaining({
steps: createLightAdjustSteps(50),
}),
);
});
});
describe("preview histogram call sites", () => {