fix(canvas): use resolved ids for drag edge splits

This commit is contained in:
2026-04-03 22:48:56 +02:00
parent 81edfa6da7
commit 47cb167bd3
2 changed files with 178 additions and 13 deletions

View File

@@ -32,6 +32,7 @@ type HarnessProps = {
runSplitEdgeAtExistingNodeMutation: ReturnType<typeof vi.fn>; runSplitEdgeAtExistingNodeMutation: ReturnType<typeof vi.fn>;
onInvalidConnection: ReturnType<typeof vi.fn<(reason: CanvasConnectionValidationReason) => void>>; onInvalidConnection: ReturnType<typeof vi.fn<(reason: CanvasConnectionValidationReason) => void>>;
syncPendingMoveForClientRequest: ReturnType<typeof vi.fn>; syncPendingMoveForClientRequest: ReturnType<typeof vi.fn>;
resolvedRealIdEntries?: Array<[string, Id<"nodes">]>;
}; };
const latestHandlersRef: { const latestHandlersRef: {
@@ -48,7 +49,9 @@ function HookHarness(props: HarnessProps) {
const pendingLocalPositionUntilConvexMatchesRef = useRef(new Map()); const pendingLocalPositionUntilConvexMatchesRef = useRef(new Map());
const preferLocalPositionNodeIdsRef = useRef(new Set<string>()); const preferLocalPositionNodeIdsRef = useRef(new Set<string>());
const pendingMoveAfterCreateRef = useRef(new Map()); const pendingMoveAfterCreateRef = useRef(new Map());
const resolvedRealIdByClientRequestRef = useRef(new Map()); const resolvedRealIdByClientRequestRef = useRef(
new Map(props.resolvedRealIdEntries ?? []),
);
const pendingEdgeSplitByClientRequestRef = useRef(new Map()); const pendingEdgeSplitByClientRequestRef = useRef(new Map());
const handlers = useCanvasNodeInteractions({ const handlers = useCanvasNodeInteractions({
@@ -159,4 +162,133 @@ describe("useCanvasNodeInteractions", () => {
positionY: 180, positionY: 180,
}); });
}); });
it("does not split an edge that already touches a resolved optimistic node", async () => {
const runMoveNodeMutation = vi.fn(async () => undefined);
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
const runResizeNodeMutation = vi.fn(async () => undefined);
const runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined);
const onInvalidConnection = vi.fn<(reason: CanvasConnectionValidationReason) => void>();
const syncPendingMoveForClientRequest = vi.fn(async () => undefined);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const draggedNode: RFNode = {
id: "optimistic_req-1",
type: "image",
position: { x: 320, y: 180 },
data: {},
};
await act(async () => {
root?.render(
<HookHarness
nodes={[
draggedNode,
{ id: "node-real", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-text", type: "text", position: { x: 400, y: 120 }, data: {} },
]}
edges={[
{
id: "edge-image-curves",
source: "node-real",
target: "node-text",
},
]}
runMoveNodeMutation={runMoveNodeMutation}
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
runResizeNodeMutation={runResizeNodeMutation}
runSplitEdgeAtExistingNodeMutation={runSplitEdgeAtExistingNodeMutation}
onInvalidConnection={onInvalidConnection}
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
resolvedRealIdEntries={[["req-1", "node-real" as Id<"nodes">]]}
/>,
);
});
await act(async () => {
latestHandlersRef.current?.onNodeDrag({} as React.MouseEvent, draggedNode);
latestHandlersRef.current?.onNodeDragStop(
{} as React.MouseEvent,
draggedNode,
[draggedNode],
);
});
expect(runSplitEdgeAtExistingNodeMutation).not.toHaveBeenCalled();
expect(onInvalidConnection).not.toHaveBeenCalled();
expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith("req-1");
expect(runMoveNodeMutation).not.toHaveBeenCalled();
});
it("still splits a valid edge with the resolved optimistic node id", async () => {
const runMoveNodeMutation = vi.fn(async () => undefined);
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
const runResizeNodeMutation = vi.fn(async () => undefined);
const runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined);
const onInvalidConnection = vi.fn<(reason: CanvasConnectionValidationReason) => void>();
const syncPendingMoveForClientRequest = vi.fn(async () => undefined);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const draggedNode: RFNode = {
id: "optimistic_req-2",
type: "video",
position: { x: 320, y: 180 },
data: {},
};
await act(async () => {
root?.render(
<HookHarness
nodes={[
draggedNode,
{ id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-text", type: "text", position: { x: 400, y: 120 }, data: {} },
{ id: "node-real-middle", type: "video", position: { x: 320, y: 180 }, data: {} },
]}
edges={[
{
id: "edge-image-curves",
source: "node-image",
target: "node-text",
},
]}
runMoveNodeMutation={runMoveNodeMutation}
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
runResizeNodeMutation={runResizeNodeMutation}
runSplitEdgeAtExistingNodeMutation={runSplitEdgeAtExistingNodeMutation}
onInvalidConnection={onInvalidConnection}
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
resolvedRealIdEntries={[["req-2", "node-real-middle" as Id<"nodes">]]}
/>,
);
});
await act(async () => {
latestHandlersRef.current?.onNodeDrag({} as React.MouseEvent, draggedNode);
latestHandlersRef.current?.onNodeDragStop(
{} as React.MouseEvent,
draggedNode,
[draggedNode],
);
});
expect(runSplitEdgeAtExistingNodeMutation).toHaveBeenCalledWith({
canvasId: "canvas-1",
splitEdgeId: "edge-image-curves",
middleNodeId: "node-real-middle",
splitSourceHandle: undefined,
splitTargetHandle: undefined,
newNodeSourceHandle: undefined,
newNodeTargetHandle: undefined,
positionX: 320,
positionY: 180,
});
expect(onInvalidConnection).not.toHaveBeenCalled();
});
}); });

View File

@@ -190,6 +190,27 @@ export function useCanvasNodeInteractions(args: {
setHighlightedIntersectionEdge(null); setHighlightedIntersectionEdge(null);
}, [setHighlightedIntersectionEdge]); }, [setHighlightedIntersectionEdge]);
const getEffectiveSplitMiddleNode = useCallback(
(node: RFNode): RFNode => {
const clientRequestId = clientRequestIdFromOptimisticNodeId(node.id);
if (!clientRequestId) {
return node;
}
const resolvedRealId =
resolvedRealIdByClientRequestRef.current.get(clientRequestId);
if (!resolvedRealId) {
return node;
}
return {
...node,
id: resolvedRealId,
};
},
[resolvedRealIdByClientRequestRef],
);
const onNodesChange = useCallback( const onNodesChange = useCallback(
(changes: NodeChange[]) => { (changes: NodeChange[]) => {
for (const change of changes) { for (const change of changes) {
@@ -285,7 +306,12 @@ export function useCanvasNodeInteractions(args: {
return; return;
} }
if (intersectedEdge.source === node.id || intersectedEdge.target === node.id) { const effectiveMiddleNode = getEffectiveSplitMiddleNode(node);
if (
intersectedEdge.source === effectiveMiddleNode.id ||
intersectedEdge.target === effectiveMiddleNode.id
) {
clearHighlightedIntersectionEdge(); clearHighlightedIntersectionEdge();
return; return;
} }
@@ -299,7 +325,12 @@ export function useCanvasNodeInteractions(args: {
overlappedEdgeRef.current = intersectedEdge.id; overlappedEdgeRef.current = intersectedEdge.id;
setHighlightedIntersectionEdge(intersectedEdge.id); setHighlightedIntersectionEdge(intersectedEdge.id);
}, },
[clearHighlightedIntersectionEdge, edges, setHighlightedIntersectionEdge], [
clearHighlightedIntersectionEdge,
edges,
getEffectiveSplitMiddleNode,
setHighlightedIntersectionEdge,
],
); );
const onNodeDragStop = useCallback( const onNodeDragStop = useCallback(
@@ -315,6 +346,7 @@ export function useCanvasNodeInteractions(args: {
} }
try { try {
const effectivePrimaryNode = getEffectiveSplitMiddleNode(primaryNode);
const intersectedEdge = intersectedEdgeId const intersectedEdge = intersectedEdgeId
? edges.find( ? edges.find(
(edge) => (edge) =>
@@ -328,8 +360,8 @@ export function useCanvasNodeInteractions(args: {
const splitEligible = const splitEligible =
intersectedEdge !== undefined && intersectedEdge !== undefined &&
splitHandles !== undefined && splitHandles !== undefined &&
intersectedEdge.source !== primaryNode.id && intersectedEdge.source !== effectivePrimaryNode.id &&
intersectedEdge.target !== primaryNode.id && intersectedEdge.target !== effectivePrimaryNode.id &&
hasHandleKey(splitHandles, "source") && hasHandleKey(splitHandles, "source") &&
hasHandleKey(splitHandles, "target"); hasHandleKey(splitHandles, "target");
@@ -339,7 +371,7 @@ export function useCanvasNodeInteractions(args: {
nodes, nodes,
edges, edges,
splitEdge: intersectedEdge, splitEdge: intersectedEdge,
middleNode: primaryNode, middleNode: effectivePrimaryNode,
}) })
: null; : null;
@@ -383,7 +415,7 @@ export function useCanvasNodeInteractions(args: {
const multiClientRequestId = clientRequestIdFromOptimisticNodeId( const multiClientRequestId = clientRequestIdFromOptimisticNodeId(
primaryNode.id, primaryNode.id,
); );
let middleId = primaryNode.id as Id<"nodes">; let middleId = effectivePrimaryNode.id as Id<"nodes">;
if (multiClientRequestId) { if (multiClientRequestId) {
const resolvedId = const resolvedId =
resolvedRealIdByClientRequestRef.current.get(multiClientRequestId); resolvedRealIdByClientRequestRef.current.get(multiClientRequestId);
@@ -518,6 +550,7 @@ export function useCanvasNodeInteractions(args: {
onInvalidConnection, onInvalidConnection,
pendingEdgeSplitByClientRequestRef, pendingEdgeSplitByClientRequestRef,
pendingMoveAfterCreateRef, pendingMoveAfterCreateRef,
getEffectiveSplitMiddleNode,
resolvedRealIdByClientRequestRef, resolvedRealIdByClientRequestRef,
runBatchMoveNodesMutation, runBatchMoveNodesMutation,
runMoveNodeMutation, runMoveNodeMutation,