feat(canvas): accelerate local previews and harden edge flows

This commit is contained in:
2026-04-05 17:28:43 +02:00
parent 451ab0b986
commit de37b63b2b
29 changed files with 2751 additions and 358 deletions

View File

@@ -4,6 +4,7 @@ import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
import { withResolvedCompareData } from "../canvas-helpers";
import {
buildGraphSnapshot,
pruneCanvasGraphNodeDataOverrides,
resolveRenderPreviewInputFromGraph,
} from "@/lib/canvas-render-preview";
@@ -100,6 +101,129 @@ describe("withResolvedCompareData", () => {
});
describe("canvas preview graph helpers", () => {
it("treats node data overrides as complete normalized objects when building a graph snapshot", () => {
const graph = buildGraphSnapshot(
[
{
id: "image-1",
type: "image",
data: {
url: "https://cdn.example.com/persisted.png",
previewUrl: "https://cdn.example.com/persisted-preview.png",
label: "Persisted label",
},
},
],
[],
{
nodeDataOverrides: new Map([
[
"image-1",
{
url: "https://cdn.example.com/persisted-source.png",
previewUrl: "https://cdn.example.com/override-preview.png",
},
],
]),
},
);
expect(graph.nodesById.get("image-1")).toMatchObject({
data: {
url: "https://cdn.example.com/persisted-source.png",
previewUrl: "https://cdn.example.com/override-preview.png",
},
});
});
it("prunes stale node data overrides for deleted nodes and persisted catch-up", () => {
const overrides = pruneCanvasGraphNodeDataOverrides(
[
{
id: "image-1",
type: "image",
data: {
url: "https://cdn.example.com/persisted-source.png",
previewUrl: "https://cdn.example.com/persisted-preview.png",
label: "Persisted label",
},
},
],
new Map([
[
"image-1",
{
url: "https://cdn.example.com/persisted-source.png",
previewUrl: "https://cdn.example.com/local-preview.png",
},
],
["deleted-node", { previewUrl: "https://cdn.example.com/stale-preview.png" }],
]),
);
expect(overrides).toEqual(
new Map([
[
"image-1",
{
url: "https://cdn.example.com/persisted-source.png",
previewUrl: "https://cdn.example.com/local-preview.png",
},
],
]),
);
});
it("keeps already-pruned node data overrides stable", () => {
const override = { previewUrl: "https://cdn.example.com/local-preview.png" };
const overrides = new Map([["image-1", override]]);
const nextOverrides = pruneCanvasGraphNodeDataOverrides(
[
{
id: "image-1",
type: "image",
data: {
url: "https://cdn.example.com/persisted-source.png",
previewUrl: "https://cdn.example.com/persisted-preview.png",
},
},
],
overrides,
);
expect(nextOverrides).toBe(overrides);
});
it("keeps full nested overrides until persisted data fully catches up", () => {
const override = {
exposure: 0.8,
adjustments: {
shadows: 12,
highlights: -4,
},
};
const nextOverrides = pruneCanvasGraphNodeDataOverrides(
[
{
id: "curves-1",
type: "curves",
data: {
exposure: 0.2,
adjustments: {
shadows: 0,
highlights: -4,
},
},
},
],
new Map([["curves-1", override]]),
);
expect(nextOverrides).toEqual(new Map([["curves-1", override]]));
});
it("resolves the upstream source and pipeline steps from a graph snapshot", () => {
const graph = buildGraphSnapshot(
[
@@ -139,4 +263,50 @@ describe("canvas preview graph helpers", () => {
},
]);
});
it("prefers local node data overrides during render preview resolution", () => {
const graph = buildGraphSnapshot(
[
{
id: "image-1",
type: "image",
data: { url: "https://cdn.example.com/persisted-source.png" },
},
{
id: "curves-1",
type: "curves",
data: { exposure: 0.2 },
},
{
id: "render-1",
type: "render",
data: {},
},
],
[
{ source: "image-1", target: "curves-1" },
{ source: "curves-1", target: "render-1" },
],
{
nodeDataOverrides: new Map([
["image-1", { url: "https://cdn.example.com/override-source.png" }],
["curves-1", { exposure: 0.8 }],
]),
},
);
const preview = resolveRenderPreviewInputFromGraph({
nodeId: "render-1",
graph,
});
expect(preview.sourceUrl).toBe("https://cdn.example.com/override-source.png");
expect(preview.steps).toEqual([
{
nodeId: "curves-1",
type: "curves",
params: { exposure: 0.8 },
},
]);
});
});

View File

@@ -2,6 +2,8 @@ import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { CanvasGraphProvider } from "@/components/canvas/canvas-graph-context";
type StoreState = {
nodes: Array<{ id: string; type?: string; data?: unknown }>;
edges: Array<{
@@ -39,6 +41,17 @@ vi.mock("../nodes/compare-surface", () => ({
import CompareNode from "../nodes/compare-node";
function renderCompareNode(props: Record<string, unknown>) {
return renderToStaticMarkup(
<CanvasGraphProvider
nodes={storeState.nodes as Array<{ id: string; type: string; data?: unknown }>}
edges={storeState.edges}
>
<CompareNode {...(props as React.ComponentProps<typeof CompareNode>)} />
</CanvasGraphProvider>,
);
}
describe("CompareNode render preview inputs", () => {
beforeEach(() => {
storeState.nodes = [];
@@ -69,8 +82,7 @@ describe("CompareNode render preview inputs", () => {
},
];
renderToStaticMarkup(
React.createElement(CompareNode, {
renderCompareNode({
id: "compare-1",
data: { leftUrl: "https://cdn.example.com/render-output.png" },
selected: false,
@@ -86,8 +98,7 @@ describe("CompareNode render preview inputs", () => {
targetPosition: undefined,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
} as never),
);
});
expect(compareSurfaceSpy).toHaveBeenCalled();
const previewCall = compareSurfaceSpy.mock.calls.find(
@@ -131,8 +142,7 @@ describe("CompareNode render preview inputs", () => {
},
];
renderToStaticMarkup(
React.createElement(CompareNode, {
renderCompareNode({
id: "compare-1",
data: { leftUrl: "https://cdn.example.com/render-output.png" },
selected: false,
@@ -148,8 +158,7 @@ describe("CompareNode render preview inputs", () => {
targetPosition: undefined,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
} as never),
);
});
expect(compareSurfaceSpy).toHaveBeenCalledTimes(1);
expect(compareSurfaceSpy.mock.calls[0]?.[0]).toMatchObject({

View File

@@ -147,6 +147,14 @@ describe("useCanvasConnections", () => {
});
await act(async () => {
latestHandlersRef.current?.onConnectStart?.(
{} as MouseEvent,
{
nodeId: "node-source",
handleId: null,
handleType: "source",
} as never,
);
latestHandlersRef.current?.onConnectEnd(
{ clientX: 400, clientY: 260 } as MouseEvent,
{
@@ -192,6 +200,14 @@ describe("useCanvasConnections", () => {
});
await act(async () => {
latestHandlersRef.current?.onConnectStart?.(
{} as MouseEvent,
{
nodeId: "node-source",
handleId: null,
handleType: "source",
} as never,
);
latestHandlersRef.current?.onConnectEnd(
{ clientX: 123, clientY: 456 } as MouseEvent,
{
@@ -244,6 +260,14 @@ describe("useCanvasConnections", () => {
});
await act(async () => {
latestHandlersRef.current?.onConnectStart?.(
{} as MouseEvent,
{
nodeId: "node-source",
handleId: null,
handleType: "source",
} as never,
);
latestHandlersRef.current?.onConnectEnd(
{ clientX: 300, clientY: 210 } as MouseEvent,
{
@@ -288,6 +312,14 @@ describe("useCanvasConnections", () => {
});
await act(async () => {
latestHandlersRef.current?.onConnectStart?.(
{} as MouseEvent,
{
nodeId: "node-source",
handleId: "target-handle",
handleType: "target",
} as never,
);
latestHandlersRef.current?.onConnectEnd(
{ clientX: 200, clientY: 200 } as MouseEvent,
{
@@ -313,4 +345,50 @@ describe("useCanvasConnections", () => {
targetHandle: "target-handle",
});
});
it("ignores onConnectEnd when no connect drag is active", async () => {
const runCreateEdgeMutation = vi.fn(async () => undefined);
const showConnectionRejectedToast = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
helperResult={{
sourceNodeId: "node-source",
targetNodeId: "node-target",
sourceHandle: undefined,
targetHandle: undefined,
}}
runCreateEdgeMutation={runCreateEdgeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
/>,
);
});
await act(async () => {
latestHandlersRef.current?.onConnectEnd(
{ clientX: 400, clientY: 260 } as MouseEvent,
{
isValid: false,
from: { x: 0, y: 0 },
fromNode: { id: "node-source", type: "image" },
fromHandle: { id: null, type: "source" },
fromPosition: null,
to: { x: 400, y: 260 },
toHandle: null,
toNode: null,
toPosition: null,
pointer: null,
} as never,
);
});
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull();
});
});

View File

@@ -0,0 +1,448 @@
// @vitest-environment jsdom
import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CanvasGraphProvider,
useCanvasGraph,
useCanvasGraphPreviewOverrides,
} from "@/components/canvas/canvas-graph-context";
import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data";
type AdjustmentData = {
exposure: number;
label?: string;
};
type HookHarnessProps = {
nodeId: string;
data: AdjustmentData;
onSave: (value: AdjustmentData) => Promise<void> | void;
};
const latestHookRef: {
current:
| {
applyLocalData: (next: AdjustmentData) => void;
updateLocalData: (updater: (current: AdjustmentData) => AdjustmentData) => void;
localData: AdjustmentData;
}
| null;
} = { current: null };
const latestOverridesRef: {
current: ReadonlyMap<string, unknown>;
} = { current: new Map() };
const canvasGraphSetOverrideRef: {
current: ((nodeId: string, data: unknown) => void) | null;
} = { current: null };
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function HookHarness({ nodeId, data, onSave }: HookHarnessProps) {
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<AdjustmentData>({
nodeId,
data,
normalize: (value) => ({ ...(value as AdjustmentData) }),
saveDelayMs: 1000,
onSave,
debugLabel: "curves",
});
useEffect(() => {
latestHookRef.current = {
applyLocalData,
updateLocalData,
localData,
};
return () => {
latestHookRef.current = null;
};
}, [applyLocalData, localData, updateLocalData]);
return null;
}
function GraphProbe() {
const { previewNodeDataOverrides } = useCanvasGraph();
const { setPreviewNodeDataOverride } = useCanvasGraphPreviewOverrides();
useEffect(() => {
latestOverridesRef.current = previewNodeDataOverrides;
canvasGraphSetOverrideRef.current = setPreviewNodeDataOverride;
return () => {
canvasGraphSetOverrideRef.current = null;
};
}, [previewNodeDataOverrides, setPreviewNodeDataOverride]);
return null;
}
function TestApp({
nodeId,
data,
mounted,
onSave,
}: {
nodeId: string;
data: AdjustmentData;
mounted: boolean;
onSave: (value: AdjustmentData) => Promise<void> | void;
}) {
return (
<CanvasGraphProvider nodes={[{ id: nodeId, type: "curves", data }]} edges={[]}>
{mounted ? <HookHarness nodeId={nodeId} data={data} onSave={onSave} /> : null}
<GraphProbe />
</CanvasGraphProvider>
);
}
describe("useNodeLocalData preview overrides", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
afterEach(async () => {
latestHookRef.current = null;
latestOverridesRef.current = new Map();
canvasGraphSetOverrideRef.current = null;
vi.clearAllMocks();
if (root) {
await act(async () => {
root?.unmount();
});
}
container?.remove();
root = null;
container = null;
});
it("sets a preview override for local edits and clears it when persisted data catches up", async () => {
const onSave = vi.fn(async () => undefined);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
latestHookRef.current?.updateLocalData((current) => ({
...current,
exposure: 0.8,
}));
});
expect(latestOverridesRef.current).toEqual(
new Map([["node-1", { exposure: 0.8, label: "persisted" }]]),
);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.8, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
expect(latestOverridesRef.current).toEqual(new Map());
});
it("does not update the canvas graph provider during render when updating local data", async () => {
const onSave = vi.fn(async () => undefined);
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
latestHookRef.current?.updateLocalData((current) => ({
...current,
exposure: 0.8,
}));
});
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining(
"Cannot update a component (`CanvasGraphProvider`) while rendering a different component",
),
);
});
it("does not write preview overrides from inside the local state updater", async () => {
const onSave = vi.fn(async () => undefined);
const originalSetPreviewNodeDataOverride = canvasGraphSetOverrideRef.current;
let insideUpdater = false;
canvasGraphSetOverrideRef.current = () => {
if (insideUpdater) {
throw new Error("setPreviewNodeDataOverride called during updater");
}
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
await expect(
act(async () => {
latestHookRef.current?.updateLocalData((current) => {
insideUpdater = true;
return {
...current,
exposure: 0.8,
};
});
}),
).resolves.toBeUndefined();
insideUpdater = false;
canvasGraphSetOverrideRef.current = originalSetPreviewNodeDataOverride;
});
it("clears its preview override on unmount", async () => {
const onSave = vi.fn(async () => undefined);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
latestHookRef.current?.applyLocalData({
exposure: 0.8,
label: "persisted",
});
});
expect(latestOverridesRef.current).toEqual(
new Map([["node-1", { exposure: 0.8, label: "persisted" }]]),
);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted={false}
onSave={onSave}
/>,
);
});
expect(latestOverridesRef.current).toEqual(new Map());
});
it("cancels a pending debounced save when the node unmounts", async () => {
vi.useFakeTimers();
const onSave = vi.fn(async () => undefined);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
latestHookRef.current?.applyLocalData({
exposure: 0.8,
label: "persisted",
});
});
expect(onSave).not.toHaveBeenCalled();
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted={false}
onSave={onSave}
/>,
);
});
expect(onSave).not.toHaveBeenCalled();
expect(latestOverridesRef.current).toEqual(new Map());
vi.useRealTimers();
});
it("clears pending preview state after a rejected save so persisted data can recover", async () => {
vi.useFakeTimers();
const onSave = vi.fn(async () => {
throw new Error("save failed");
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
latestHookRef.current?.applyLocalData({
exposure: 0.8,
label: "persisted",
});
});
expect(latestOverridesRef.current).toEqual(
new Map([["node-1", { exposure: 0.8, label: "persisted" }]]),
);
await act(async () => {
vi.advanceTimersByTime(1000);
await Promise.resolve();
});
expect(onSave).toHaveBeenCalledTimes(1);
expect(latestOverridesRef.current).toEqual(new Map());
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.3, label: "server" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
vi.runOnlyPendingTimers();
});
expect(latestHookRef.current?.localData).toEqual({ exposure: 0.3, label: "server" });
vi.useRealTimers();
});
it("accepts a non-identical persisted update after a successful save", async () => {
vi.useFakeTimers();
const onSave = vi.fn(async () => undefined);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
latestHookRef.current?.applyLocalData({
exposure: 0.8,
label: "client",
});
});
await act(async () => {
vi.advanceTimersByTime(1000);
await Promise.resolve();
});
expect(onSave).toHaveBeenCalledTimes(1);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.75, label: "server-normalized" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
vi.runOnlyPendingTimers();
});
expect(latestHookRef.current?.localData).toEqual({
exposure: 0.75,
label: "server-normalized",
});
expect(latestOverridesRef.current).toEqual(new Map());
vi.useRealTimers();
});
});