feat(canvas): accelerate local previews and harden edge flows
This commit is contained in:
@@ -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 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
448
components/canvas/__tests__/use-node-local-data.test.tsx
Normal file
448
components/canvas/__tests__/use-node-local-data.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -122,22 +122,22 @@ export function useCanvasDeleteHandlers({
|
||||
nodes,
|
||||
edges,
|
||||
);
|
||||
const edgePromises = bridgeCreates.map((bridgeCreate) =>
|
||||
runCreateEdgeMutation({
|
||||
canvasId,
|
||||
sourceNodeId: bridgeCreate.sourceNodeId,
|
||||
targetNodeId: bridgeCreate.targetNodeId,
|
||||
sourceHandle: bridgeCreate.sourceHandle,
|
||||
targetHandle: bridgeCreate.targetHandle,
|
||||
}),
|
||||
);
|
||||
|
||||
void Promise.all([
|
||||
runBatchRemoveNodesMutation({
|
||||
void (async () => {
|
||||
await runBatchRemoveNodesMutation({
|
||||
nodeIds: idsToDelete as Id<"nodes">[],
|
||||
}),
|
||||
...edgePromises,
|
||||
])
|
||||
});
|
||||
|
||||
for (const bridgeCreate of bridgeCreates) {
|
||||
await runCreateEdgeMutation({
|
||||
canvasId,
|
||||
sourceNodeId: bridgeCreate.sourceNodeId,
|
||||
targetNodeId: bridgeCreate.targetNodeId,
|
||||
sourceHandle: bridgeCreate.sourceHandle,
|
||||
targetHandle: bridgeCreate.targetHandle,
|
||||
});
|
||||
}
|
||||
})()
|
||||
.then(() => {
|
||||
// Erfolg bedeutet hier nur: Mutation/Queue wurde angenommen.
|
||||
// Den Delete-Lock erst lösen, wenn Convex-Snapshot die Node wirklich nicht mehr enthält.
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
buildGraphSnapshot,
|
||||
type CanvasGraphEdgeLike,
|
||||
type CanvasGraphNodeDataOverrides,
|
||||
type CanvasGraphNodeLike,
|
||||
type CanvasGraphSnapshot,
|
||||
pruneCanvasGraphNodeDataOverrides,
|
||||
} from "@/lib/canvas-render-preview";
|
||||
|
||||
type CanvasGraphContextValue = CanvasGraphSnapshot;
|
||||
type CanvasGraphContextValue = CanvasGraphSnapshot & {
|
||||
previewNodeDataOverrides: CanvasGraphNodeDataOverrides;
|
||||
};
|
||||
|
||||
type CanvasGraphPreviewOverridesContextValue = {
|
||||
setPreviewNodeDataOverride: (nodeId: string, data: unknown) => void;
|
||||
clearPreviewNodeDataOverride: (nodeId: string) => void;
|
||||
clearPreviewNodeDataOverrides: () => void;
|
||||
};
|
||||
|
||||
const CanvasGraphContext = createContext<CanvasGraphContextValue | null>(null);
|
||||
const CanvasGraphPreviewOverridesContext =
|
||||
createContext<CanvasGraphPreviewOverridesContextValue | null>(null);
|
||||
|
||||
export function CanvasGraphProvider({
|
||||
nodes,
|
||||
@@ -22,9 +42,88 @@ export function CanvasGraphProvider({
|
||||
edges: readonly CanvasGraphEdgeLike[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const value = useMemo(() => buildGraphSnapshot(nodes, edges), [edges, nodes]);
|
||||
const [previewNodeDataOverrides, setPreviewNodeDataOverrides] =
|
||||
useState<CanvasGraphNodeDataOverrides>(() => new Map());
|
||||
|
||||
return <CanvasGraphContext.Provider value={value}>{children}</CanvasGraphContext.Provider>;
|
||||
const setPreviewNodeDataOverride = useCallback((nodeId: string, data: unknown) => {
|
||||
setPreviewNodeDataOverrides((previous) => {
|
||||
if (previous.has(nodeId) && Object.is(previous.get(nodeId), data)) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const next = new Map(previous);
|
||||
next.set(nodeId, data);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearPreviewNodeDataOverride = useCallback((nodeId: string) => {
|
||||
setPreviewNodeDataOverrides((previous) => {
|
||||
if (!previous.has(nodeId)) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const next = new Map(previous);
|
||||
next.delete(nodeId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearPreviewNodeDataOverrides = useCallback(() => {
|
||||
setPreviewNodeDataOverrides((previous) => {
|
||||
if (previous.size === 0) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return new Map();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const prunedPreviewNodeDataOverrides = useMemo(
|
||||
() => pruneCanvasGraphNodeDataOverrides(nodes, previewNodeDataOverrides),
|
||||
[nodes, previewNodeDataOverrides],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (prunedPreviewNodeDataOverrides !== previewNodeDataOverrides) {
|
||||
setPreviewNodeDataOverrides(prunedPreviewNodeDataOverrides);
|
||||
}
|
||||
}, [previewNodeDataOverrides, prunedPreviewNodeDataOverrides]);
|
||||
|
||||
const graph = useMemo(
|
||||
() =>
|
||||
buildGraphSnapshot(nodes, edges, {
|
||||
nodeDataOverrides: prunedPreviewNodeDataOverrides,
|
||||
}),
|
||||
[edges, nodes, prunedPreviewNodeDataOverrides],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
...graph,
|
||||
previewNodeDataOverrides: prunedPreviewNodeDataOverrides,
|
||||
}),
|
||||
[graph, prunedPreviewNodeDataOverrides],
|
||||
);
|
||||
|
||||
const previewOverridesValue = useMemo(
|
||||
() => ({
|
||||
setPreviewNodeDataOverride,
|
||||
clearPreviewNodeDataOverride,
|
||||
clearPreviewNodeDataOverrides,
|
||||
}),
|
||||
[
|
||||
clearPreviewNodeDataOverride,
|
||||
clearPreviewNodeDataOverrides,
|
||||
setPreviewNodeDataOverride,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<CanvasGraphPreviewOverridesContext.Provider value={previewOverridesValue}>
|
||||
<CanvasGraphContext.Provider value={value}>{children}</CanvasGraphContext.Provider>
|
||||
</CanvasGraphPreviewOverridesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCanvasGraph(): CanvasGraphContextValue {
|
||||
@@ -35,3 +134,12 @@ export function useCanvasGraph(): CanvasGraphContextValue {
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useCanvasGraphPreviewOverrides(): CanvasGraphPreviewOverridesContextValue {
|
||||
const context = useContext(CanvasGraphPreviewOverridesContext);
|
||||
if (!context) {
|
||||
throw new Error("useCanvasGraphPreviewOverrides must be used within CanvasGraphProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -308,6 +308,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
closeConnectionDropMenu,
|
||||
handleConnectionDropPick,
|
||||
onConnect,
|
||||
onConnectStart,
|
||||
onConnectEnd,
|
||||
onReconnectStart,
|
||||
onReconnect,
|
||||
@@ -520,6 +521,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
onNodeDrag={onNodeDrag}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onConnect={onConnect}
|
||||
onConnectStart={onConnectStart}
|
||||
onConnectEnd={onConnectEnd}
|
||||
onReconnect={onReconnect}
|
||||
onReconnectStart={onReconnectStart}
|
||||
|
||||
@@ -1,46 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
|
||||
|
||||
import { usePipelinePreview } from "@/hooks/use-pipeline-preview";
|
||||
import {
|
||||
collectPipelineFromGraph,
|
||||
getSourceImageFromGraph,
|
||||
shouldFastPathPreviewPipeline,
|
||||
} from "@/lib/canvas-render-preview";
|
||||
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
|
||||
import { buildHistogramPlot } from "@/lib/image-pipeline/histogram-plot";
|
||||
|
||||
const PREVIEW_PIPELINE_TYPES = new Set([
|
||||
"curves",
|
||||
"color-adjust",
|
||||
"light-adjust",
|
||||
"detail-adjust",
|
||||
]);
|
||||
|
||||
type PreviewLatencyTrace = {
|
||||
sequence: number;
|
||||
changedAtMs: number;
|
||||
nodeType: string;
|
||||
origin: string;
|
||||
};
|
||||
|
||||
function readPreviewLatencyTrace(): PreviewLatencyTrace | null {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const debugGlobals = globalThis as typeof globalThis & {
|
||||
__LEMONSPACE_DEBUG_PREVIEW_LATENCY__?: boolean;
|
||||
__LEMONSPACE_LAST_PREVIEW_TRACE__?: PreviewLatencyTrace;
|
||||
};
|
||||
|
||||
if (debugGlobals.__LEMONSPACE_DEBUG_PREVIEW_LATENCY__ !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return debugGlobals.__LEMONSPACE_LAST_PREVIEW_TRACE__ ?? null;
|
||||
}
|
||||
const PREVIEW_PIPELINE_TYPES = new Set(["curves", "color-adjust", "light-adjust", "detail-adjust"]);
|
||||
|
||||
export default function AdjustmentPreview({
|
||||
nodeId,
|
||||
@@ -54,7 +26,6 @@ export default function AdjustmentPreview({
|
||||
currentParams: unknown;
|
||||
}) {
|
||||
const graph = useCanvasGraph();
|
||||
const lastLoggedTraceSequenceRef = useRef<number | null>(null);
|
||||
|
||||
const sourceUrl = useMemo(
|
||||
() =>
|
||||
@@ -93,36 +64,21 @@ export default function AdjustmentPreview({
|
||||
});
|
||||
}, [currentParams, currentType, graph, nodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
const trace = readPreviewLatencyTrace();
|
||||
if (!trace) {
|
||||
return;
|
||||
}
|
||||
const usesFastPreviewDebounce = shouldFastPathPreviewPipeline(
|
||||
steps,
|
||||
graph.previewNodeDataOverrides,
|
||||
);
|
||||
const shouldDeferHistogram = graph.previewNodeDataOverrides.has(nodeId);
|
||||
|
||||
if (lastLoggedTraceSequenceRef.current === trace.sequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastLoggedTraceSequenceRef.current = trace.sequence;
|
||||
|
||||
console.info("[Preview latency] downstream-graph-visible", {
|
||||
nodeId,
|
||||
nodeType: currentType,
|
||||
sourceNodeType: trace.nodeType,
|
||||
sourceOrigin: trace.origin,
|
||||
sinceChangeMs: performance.now() - trace.changedAtMs,
|
||||
pipelineDepth: steps.length,
|
||||
stepTypes: steps.map((step) => step.type),
|
||||
hasSource: Boolean(sourceUrl),
|
||||
});
|
||||
}, [currentType, nodeId, sourceUrl, steps]);
|
||||
const previewDebounceMs = usesFastPreviewDebounce ? 16 : undefined;
|
||||
|
||||
const { canvasRef, histogram, isRendering, hasSource, previewAspectRatio, error } =
|
||||
usePipelinePreview({
|
||||
sourceUrl,
|
||||
steps,
|
||||
nodeWidth,
|
||||
includeHistogram: true,
|
||||
includeHistogram: !shouldDeferHistogram,
|
||||
debounceMs: previewDebounceMs,
|
||||
// Die Vorschau muss in-Node gut lesbar bleiben, aber nicht in voller
|
||||
// Display-Auflösung rechnen.
|
||||
previewScale: 0.5,
|
||||
|
||||
@@ -59,6 +59,7 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
|
||||
[],
|
||||
);
|
||||
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<ColorAdjustData>({
|
||||
nodeId: id,
|
||||
data,
|
||||
normalize: normalizeData,
|
||||
saveDelayMs: 16,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
|
||||
import { usePipelinePreview } from "@/hooks/use-pipeline-preview";
|
||||
import type { RenderPreviewInput } from "@/lib/canvas-render-preview";
|
||||
import {
|
||||
shouldFastPathPreviewPipeline,
|
||||
type RenderPreviewInput,
|
||||
} from "@/lib/canvas-render-preview";
|
||||
|
||||
const EMPTY_STEPS: RenderPreviewInput["steps"] = [];
|
||||
|
||||
@@ -22,16 +26,24 @@ export default function CompareSurface({
|
||||
clipWidthPercent,
|
||||
preferPreview,
|
||||
}: CompareSurfaceProps) {
|
||||
const graph = useCanvasGraph();
|
||||
const usePreview = Boolean(previewInput && (preferPreview || !finalUrl));
|
||||
const previewSourceUrl = usePreview ? previewInput?.sourceUrl ?? null : null;
|
||||
const previewSteps = usePreview ? previewInput?.steps ?? EMPTY_STEPS : EMPTY_STEPS;
|
||||
const visibleFinalUrl = usePreview ? undefined : finalUrl;
|
||||
const previewDebounceMs = shouldFastPathPreviewPipeline(
|
||||
previewSteps,
|
||||
graph.previewNodeDataOverrides,
|
||||
)
|
||||
? 16
|
||||
: undefined;
|
||||
|
||||
const { canvasRef, isRendering, error } = usePipelinePreview({
|
||||
sourceUrl: previewSourceUrl,
|
||||
steps: previewSteps,
|
||||
nodeWidth,
|
||||
includeHistogram: false,
|
||||
debounceMs: previewDebounceMs,
|
||||
// Compare-Nodes zeigen nur eine kompakte Live-Ansicht; kleinere Kacheln
|
||||
// halten lange Workflows spürbar reaktionsfreudiger.
|
||||
previewScale: 0.5,
|
||||
|
||||
@@ -59,6 +59,7 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
|
||||
[],
|
||||
);
|
||||
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<CurvesData>({
|
||||
nodeId: id,
|
||||
data,
|
||||
normalize: normalizeData,
|
||||
saveDelayMs: 16,
|
||||
|
||||
@@ -59,6 +59,7 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
|
||||
[],
|
||||
);
|
||||
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<DetailAdjustData>({
|
||||
nodeId: id,
|
||||
data,
|
||||
normalize: normalizeData,
|
||||
saveDelayMs: 16,
|
||||
|
||||
@@ -59,6 +59,7 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
|
||||
[],
|
||||
);
|
||||
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<LightAdjustData>({
|
||||
nodeId: id,
|
||||
data,
|
||||
normalize: normalizeData,
|
||||
saveDelayMs: 16,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
|
||||
import {
|
||||
findSourceNodeFromGraph,
|
||||
resolveRenderPreviewInputFromGraph,
|
||||
shouldFastPathPreviewPipeline,
|
||||
} from "@/lib/canvas-render-preview";
|
||||
import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
|
||||
import { parseAspectRatioString } from "@/lib/image-formats";
|
||||
@@ -495,6 +496,12 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
||||
);
|
||||
|
||||
const steps = renderPreviewInput.steps;
|
||||
const previewDebounceMs = shouldFastPathPreviewPipeline(
|
||||
steps,
|
||||
graph.previewNodeDataOverrides,
|
||||
)
|
||||
? 16
|
||||
: undefined;
|
||||
|
||||
const renderFingerprint = useMemo(
|
||||
() => ({
|
||||
@@ -560,6 +567,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
||||
sourceUrl,
|
||||
steps,
|
||||
nodeWidth: previewNodeWidth,
|
||||
debounceMs: previewDebounceMs,
|
||||
// Inline-Preview: bewusst kompakt halten, damit Änderungen schneller
|
||||
// sichtbar werden, besonders in langen Graphen.
|
||||
previewScale: 0.5,
|
||||
@@ -577,6 +585,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
||||
steps,
|
||||
nodeWidth: fullscreenPreviewWidth,
|
||||
includeHistogram: false,
|
||||
debounceMs: previewDebounceMs,
|
||||
previewScale: 0.85,
|
||||
maxPreviewWidth: 1920,
|
||||
maxDevicePixelRatio: 1.5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useCanvasGraphPreviewOverrides } from "@/components/canvas/canvas-graph-context";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
|
||||
function hashNodeData(value: unknown): string {
|
||||
@@ -21,69 +22,67 @@ function logNodeDataDebug(event: string, payload: Record<string, unknown>): void
|
||||
console.info("[Canvas node debug]", event, payload);
|
||||
}
|
||||
|
||||
type PreviewLatencyTrace = {
|
||||
sequence: number;
|
||||
changedAtMs: number;
|
||||
nodeType: string;
|
||||
origin: "applyLocalData" | "updateLocalData";
|
||||
};
|
||||
|
||||
function writePreviewLatencyTrace(trace: Omit<PreviewLatencyTrace, "sequence">): void {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return;
|
||||
}
|
||||
|
||||
const debugGlobals = globalThis as typeof globalThis & {
|
||||
__LEMONSPACE_DEBUG_PREVIEW_LATENCY__?: boolean;
|
||||
__LEMONSPACE_LAST_PREVIEW_TRACE__?: PreviewLatencyTrace;
|
||||
};
|
||||
|
||||
if (debugGlobals.__LEMONSPACE_DEBUG_PREVIEW_LATENCY__ !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTrace: PreviewLatencyTrace = {
|
||||
...trace,
|
||||
sequence: (debugGlobals.__LEMONSPACE_LAST_PREVIEW_TRACE__?.sequence ?? 0) + 1,
|
||||
};
|
||||
|
||||
debugGlobals.__LEMONSPACE_LAST_PREVIEW_TRACE__ = nextTrace;
|
||||
|
||||
console.info("[Preview latency] node-local-change", nextTrace);
|
||||
}
|
||||
|
||||
export function useNodeLocalData<T>({
|
||||
nodeId,
|
||||
data,
|
||||
normalize,
|
||||
saveDelayMs,
|
||||
onSave,
|
||||
debugLabel,
|
||||
}: {
|
||||
nodeId: string;
|
||||
data: unknown;
|
||||
normalize: (value: unknown) => T;
|
||||
saveDelayMs: number;
|
||||
onSave: (value: T) => Promise<void> | void;
|
||||
debugLabel: string;
|
||||
}) {
|
||||
const { setPreviewNodeDataOverride, clearPreviewNodeDataOverride } =
|
||||
useCanvasGraphPreviewOverrides();
|
||||
const [localData, setLocalDataState] = useState<T>(() => normalize(data));
|
||||
const localDataRef = useRef(localData);
|
||||
const persistedDataRef = useRef(localData);
|
||||
const hasPendingLocalChangesRef = useRef(false);
|
||||
const localChangeVersionRef = useRef(0);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
localDataRef.current = localData;
|
||||
}, [localData]);
|
||||
|
||||
const queueSave = useDebouncedCallback(() => {
|
||||
void onSave(localDataRef.current);
|
||||
const savedValue = localDataRef.current;
|
||||
const savedVersion = localChangeVersionRef.current;
|
||||
|
||||
Promise.resolve(onSave(savedValue))
|
||||
.then(() => {
|
||||
if (!isMountedRef.current || savedVersion !== localChangeVersionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasPendingLocalChangesRef.current = false;
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isMountedRef.current || savedVersion !== localChangeVersionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasPendingLocalChangesRef.current = false;
|
||||
localDataRef.current = persistedDataRef.current;
|
||||
setLocalDataState(persistedDataRef.current);
|
||||
clearPreviewNodeDataOverride(nodeId);
|
||||
});
|
||||
}, saveDelayMs);
|
||||
|
||||
useEffect(() => {
|
||||
const incomingData = normalize(data);
|
||||
persistedDataRef.current = incomingData;
|
||||
const incomingHash = hashNodeData(incomingData);
|
||||
const localHash = hashNodeData(localDataRef.current);
|
||||
|
||||
if (incomingHash === localHash) {
|
||||
hasPendingLocalChangesRef.current = false;
|
||||
clearPreviewNodeDataOverride(nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -99,44 +98,46 @@ export function useNodeLocalData<T>({
|
||||
const timer = window.setTimeout(() => {
|
||||
localDataRef.current = incomingData;
|
||||
setLocalDataState(incomingData);
|
||||
clearPreviewNodeDataOverride(nodeId);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [data, debugLabel, normalize]);
|
||||
}, [clearPreviewNodeDataOverride, data, debugLabel, nodeId, normalize]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
queueSave.cancel();
|
||||
clearPreviewNodeDataOverride(nodeId);
|
||||
};
|
||||
}, [clearPreviewNodeDataOverride, nodeId, queueSave]);
|
||||
|
||||
const applyLocalData = useCallback(
|
||||
(next: T) => {
|
||||
localChangeVersionRef.current += 1;
|
||||
hasPendingLocalChangesRef.current = true;
|
||||
writePreviewLatencyTrace({
|
||||
changedAtMs: performance.now(),
|
||||
nodeType: debugLabel,
|
||||
origin: "applyLocalData",
|
||||
});
|
||||
localDataRef.current = next;
|
||||
setLocalDataState(next);
|
||||
setPreviewNodeDataOverride(nodeId, next);
|
||||
queueSave();
|
||||
},
|
||||
[debugLabel, queueSave],
|
||||
[debugLabel, nodeId, queueSave, setPreviewNodeDataOverride],
|
||||
);
|
||||
|
||||
const updateLocalData = useCallback(
|
||||
(updater: (current: T) => T) => {
|
||||
const next = updater(localDataRef.current);
|
||||
|
||||
localChangeVersionRef.current += 1;
|
||||
hasPendingLocalChangesRef.current = true;
|
||||
setLocalDataState((current) => {
|
||||
const next = updater(current);
|
||||
writePreviewLatencyTrace({
|
||||
changedAtMs: performance.now(),
|
||||
nodeType: debugLabel,
|
||||
origin: "updateLocalData",
|
||||
});
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
return next;
|
||||
});
|
||||
localDataRef.current = next;
|
||||
setLocalDataState(next);
|
||||
setPreviewNodeDataOverride(nodeId, next);
|
||||
queueSave();
|
||||
},
|
||||
[debugLabel, queueSave],
|
||||
[debugLabel, nodeId, queueSave, setPreviewNodeDataOverride],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||
import type { Connection, Edge as RFEdge, Node as RFNode, OnConnectEnd } from "@xyflow/react";
|
||||
import type { Connection, Edge as RFEdge, Node as RFNode, OnConnectEnd, OnConnectStart } from "@xyflow/react";
|
||||
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import {
|
||||
@@ -100,14 +100,20 @@ export function useCanvasConnections({
|
||||
const [connectionDropMenu, setConnectionDropMenu] =
|
||||
useState<ConnectionDropMenuState | null>(null);
|
||||
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
|
||||
const isConnectDragActiveRef = useRef(false);
|
||||
const closeConnectionDropMenu = useCallback(() => setConnectionDropMenu(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
connectionDropMenuRef.current = connectionDropMenu;
|
||||
}, [connectionDropMenu]);
|
||||
|
||||
const onConnectStart = useCallback<OnConnectStart>(() => {
|
||||
isConnectDragActiveRef.current = true;
|
||||
}, []);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
isConnectDragActiveRef.current = false;
|
||||
const validationError = validateCanvasConnection(connection, nodes, edges);
|
||||
if (validationError) {
|
||||
showConnectionRejectedToast(validationError);
|
||||
@@ -129,6 +135,11 @@ export function useCanvasConnections({
|
||||
|
||||
const onConnectEnd = useCallback<OnConnectEnd>(
|
||||
(event, connectionState) => {
|
||||
if (!isConnectDragActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isConnectDragActiveRef.current = false;
|
||||
if (isReconnectDragActiveRef.current) return;
|
||||
if (connectionState.isValid === true) return;
|
||||
const fromNode = connectionState.fromNode;
|
||||
@@ -153,8 +164,8 @@ export function useCanvasConnections({
|
||||
{
|
||||
source: droppedConnection.sourceNodeId,
|
||||
target: droppedConnection.targetNodeId,
|
||||
sourceHandle: droppedConnection.sourceHandle,
|
||||
targetHandle: droppedConnection.targetHandle,
|
||||
sourceHandle: droppedConnection.sourceHandle ?? null,
|
||||
targetHandle: droppedConnection.targetHandle ?? null,
|
||||
},
|
||||
nodesRef.current,
|
||||
edgesRef.current,
|
||||
@@ -333,14 +344,15 @@ export function useCanvasConnections({
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
connectionDropMenu,
|
||||
closeConnectionDropMenu,
|
||||
handleConnectionDropPick,
|
||||
onConnect,
|
||||
onConnectEnd,
|
||||
onReconnectStart,
|
||||
onReconnect,
|
||||
onReconnectEnd,
|
||||
};
|
||||
return {
|
||||
connectionDropMenu,
|
||||
closeConnectionDropMenu,
|
||||
handleConnectionDropPick,
|
||||
onConnect,
|
||||
onConnectStart,
|
||||
onConnectEnd,
|
||||
onReconnectStart,
|
||||
onReconnect,
|
||||
onReconnectEnd,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user