From 928fd819047097eafedb11ac2b1fab5c78d2bb12 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Fri, 3 Apr 2026 20:42:47 +0200 Subject: [PATCH] refactor(canvas): extract flow reconciliation helpers --- ...canvas-flow-reconciliation-helpers.test.ts | 117 +++++++ .../canvas-flow-reconciliation-helpers.ts | 318 ++++++++++++++++++ components/canvas/canvas-helpers.ts | 34 +- components/canvas/canvas.tsx | 265 +++------------ 4 files changed, 484 insertions(+), 250 deletions(-) create mode 100644 components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts create mode 100644 components/canvas/canvas-flow-reconciliation-helpers.ts diff --git a/components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts b/components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts new file mode 100644 index 0000000..5d86e6c --- /dev/null +++ b/components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts @@ -0,0 +1,117 @@ +import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; +import { describe, expect, it } from "vitest"; + +import type { Id } from "@/convex/_generated/dataModel"; +import { + reconcileCanvasFlowEdges, + reconcileCanvasFlowNodes, +} from "@/components/canvas/canvas-flow-reconciliation-helpers"; + +const asNodeId = (id: string): Id<"nodes"> => id as Id<"nodes">; + +describe("canvas flow reconciliation helpers", () => { + it("carries an optimistic edge while a pending connection create awaits convex edge sync", () => { + const previousEdges: RFEdge[] = [ + { + id: "optimistic_edge_req-1", + source: "node-source", + target: "optimistic_req-1", + }, + ]; + + const result = reconcileCanvasFlowEdges({ + previousEdges, + convexEdges: [], + convexNodes: [ + { _id: asNodeId("node-source"), type: "image" }, + { _id: asNodeId("node-real"), type: "prompt" }, + ], + previousConvexNodeIdsSnapshot: new Set(["node-source"]), + pendingRemovedEdgeIds: new Set(), + pendingConnectionCreateIds: new Set(["req-1"]), + resolvedRealIdByClientRequest: new Map(), + localNodeIds: new Set(["node-source"]), + isAnyNodeDragging: false, + colorMode: "light", + }); + + expect(result.edges).toEqual([ + { + id: "optimistic_edge_req-1", + source: "node-source", + target: "node-real", + }, + ]); + expect(result.inferredRealIdByClientRequest).toEqual( + new Map([["req-1", "node-real"]]), + ); + }); + + it("remaps optimistic endpoints to resolved real node ids", () => { + const previousEdges: RFEdge[] = [ + { + id: "optimistic_edge_req-2", + source: "optimistic_req-2", + target: "node-target", + }, + ]; + + const result = reconcileCanvasFlowEdges({ + previousEdges, + convexEdges: [], + convexNodes: [ + { _id: asNodeId("node-real"), type: "image" }, + { _id: asNodeId("node-target"), type: "prompt" }, + ], + previousConvexNodeIdsSnapshot: new Set(["node-real", "node-target"]), + pendingRemovedEdgeIds: new Set(), + pendingConnectionCreateIds: new Set(), + resolvedRealIdByClientRequest: new Map([["req-2", asNodeId("node-real")]]), + localNodeIds: new Set(["node-target"]), + isAnyNodeDragging: false, + colorMode: "light", + }); + + expect(result.edges).toEqual([ + { + id: "optimistic_edge_req-2", + source: "node-real", + target: "node-target", + }, + ]); + }); + + it("cleans up matched local position pins once convex catches up", () => { + const previousNodes: RFNode[] = [ + { + id: "node-1", + type: "image", + position: { x: 120, y: 80 }, + data: {}, + }, + ]; + const incomingNodes: RFNode[] = [ + { + id: "node-1", + type: "image", + position: { x: 120, y: 80 }, + data: {}, + }, + ]; + + const result = reconcileCanvasFlowNodes({ + previousNodes, + incomingNodes, + convexNodes: [{ _id: asNodeId("node-1"), type: "image" }], + deletingNodeIds: new Set(), + resolvedRealIdByClientRequest: new Map(), + pendingConnectionCreateIds: new Set(), + preferLocalPositionNodeIds: new Set(), + pendingLocalPositionPins: new Map([["node-1", { x: 120, y: 80 }]]), + pendingMovePins: new Map(), + }); + + expect(result.nodes).toEqual(incomingNodes); + expect(result.nextPendingLocalPositionPins.size).toBe(0); + }); +}); diff --git a/components/canvas/canvas-flow-reconciliation-helpers.ts b/components/canvas/canvas-flow-reconciliation-helpers.ts new file mode 100644 index 0000000..6ce6ecf --- /dev/null +++ b/components/canvas/canvas-flow-reconciliation-helpers.ts @@ -0,0 +1,318 @@ +import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; + +import type { Doc, Id } from "@/convex/_generated/dataModel"; +import { convexEdgeToRF, convexEdgeToRFWithSourceGlow } from "@/lib/canvas-utils"; + +import { + applyPinnedNodePositionsReadOnly, + clientRequestIdFromOptimisticEdgeId, + clientRequestIdFromOptimisticNodeId, + isOptimisticEdgeId, + isOptimisticNodeId, + mergeNodesPreservingLocalState, + OPTIMISTIC_NODE_PREFIX, + positionsMatchPin, + rfEdgeConnectionSignature, +} from "./canvas-helpers"; + +type FlowConvexNodeRecord = Pick, "_id" | "type">; +type FlowConvexEdgeRecord = Pick< + Doc<"edges">, + "_id" | "sourceNodeId" | "targetNodeId" | "sourceHandle" | "targetHandle" +>; + +export function inferPendingConnectionNodeHandoff(args: { + previousNodes: RFNode[]; + incomingConvexNodes: FlowConvexNodeRecord[]; + pendingConnectionCreateIds: ReadonlySet; + resolvedRealIdByClientRequest: ReadonlyMap>; +}): Map> { + const nextResolvedRealIdByClientRequest = new Map(args.resolvedRealIdByClientRequest); + const unresolvedClientRequestIds: string[] = []; + + for (const clientRequestId of args.pendingConnectionCreateIds) { + if (nextResolvedRealIdByClientRequest.has(clientRequestId)) continue; + + const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`; + const optimisticNodePresent = args.previousNodes.some( + (node) => node.id === optimisticNodeId, + ); + if (optimisticNodePresent) { + unresolvedClientRequestIds.push(clientRequestId); + } + } + + if (unresolvedClientRequestIds.length !== 1) { + return nextResolvedRealIdByClientRequest; + } + + const previousIds = new Set(args.previousNodes.map((node) => node.id)); + const newlyAppearedIncomingRealNodeIds = args.incomingConvexNodes + .map((node) => node._id as string) + .filter((id) => !isOptimisticNodeId(id)) + .filter((id) => !previousIds.has(id)); + + if (newlyAppearedIncomingRealNodeIds.length !== 1) { + return nextResolvedRealIdByClientRequest; + } + + nextResolvedRealIdByClientRequest.set( + unresolvedClientRequestIds[0]!, + newlyAppearedIncomingRealNodeIds[0] as Id<"nodes">, + ); + return nextResolvedRealIdByClientRequest; +} + +export function reconcileCanvasFlowEdges(args: { + previousEdges: RFEdge[]; + convexEdges: FlowConvexEdgeRecord[]; + convexNodes?: FlowConvexNodeRecord[]; + previousConvexNodeIdsSnapshot: ReadonlySet; + pendingRemovedEdgeIds: ReadonlySet; + pendingConnectionCreateIds: ReadonlySet; + resolvedRealIdByClientRequest: ReadonlyMap>; + localNodeIds: ReadonlySet; + isAnyNodeDragging: boolean; + colorMode: "light" | "dark"; +}): { + edges: RFEdge[]; + nextConvexNodeIdsSnapshot: Set; + inferredRealIdByClientRequest: Map>; + settledPendingConnectionCreateIds: string[]; +} { + const currentConvexIdList = args.convexNodes?.map((node) => node._id as string) ?? []; + const currentConvexIdSet = new Set(currentConvexIdList); + const newlyAppearedIds = currentConvexIdList.filter( + (id) => !args.previousConvexNodeIdsSnapshot.has(id), + ); + + const tempEdges = args.previousEdges.filter((edge) => edge.className === "temp"); + const sourceTypeByNodeId = args.convexNodes + ? new Map(args.convexNodes.map((node) => [node._id as string, node.type as string])) + : undefined; + const mapped = args.convexEdges + .filter((edge) => !args.pendingRemovedEdgeIds.has(edge._id as string)) + .map((edge) => + sourceTypeByNodeId + ? convexEdgeToRFWithSourceGlow( + edge as Doc<"edges">, + sourceTypeByNodeId.get(edge.sourceNodeId), + args.colorMode, + ) + : convexEdgeToRF(edge as Doc<"edges">), + ); + + const mappedSignatures = new Set(mapped.map(rfEdgeConnectionSignature)); + const convexNodeIds = args.convexNodes + ? new Set(args.convexNodes.map((node) => node._id as string)) + : null; + const inferredRealIdByClientRequest = new Map(args.resolvedRealIdByClientRequest); + + const resolveEndpoint = (nodeId: string): string => { + if (!isOptimisticNodeId(nodeId)) return nodeId; + + const clientRequestId = clientRequestIdFromOptimisticNodeId(nodeId); + if (!clientRequestId) return nodeId; + + if (args.isAnyNodeDragging && args.localNodeIds.has(nodeId)) { + return nodeId; + } + + const realId = inferredRealIdByClientRequest.get(clientRequestId); + return realId !== undefined ? (realId as string) : nodeId; + }; + + const resolveEndpointWithInference = (nodeId: string, edge: RFEdge): string => { + const baseNodeId = resolveEndpoint(nodeId); + if (!isOptimisticNodeId(baseNodeId)) return baseNodeId; + if (args.isAnyNodeDragging) return baseNodeId; + + const nodeClientRequestId = clientRequestIdFromOptimisticNodeId(baseNodeId); + if (nodeClientRequestId === null) return baseNodeId; + + const edgeClientRequestId = clientRequestIdFromOptimisticEdgeId(edge.id); + if (edgeClientRequestId === null || edgeClientRequestId !== nodeClientRequestId) { + return baseNodeId; + } + + if (!args.pendingConnectionCreateIds.has(nodeClientRequestId)) { + return baseNodeId; + } + + if (newlyAppearedIds.length !== 1) { + return baseNodeId; + } + + const inferredRealId = newlyAppearedIds[0] as Id<"nodes">; + inferredRealIdByClientRequest.set(nodeClientRequestId, inferredRealId); + return inferredRealId; + }; + + const endpointUsable = (nodeId: string): boolean => { + if (args.isAnyNodeDragging && args.localNodeIds.has(nodeId)) { + return true; + } + + const resolvedNodeId = resolveEndpoint(nodeId); + return Boolean(convexNodeIds?.has(resolvedNodeId) || convexNodeIds?.has(nodeId)); + }; + + const optimisticEndpointHasPendingCreate = (nodeId: string): boolean => { + if (!isOptimisticNodeId(nodeId)) return false; + const clientRequestId = clientRequestIdFromOptimisticNodeId(nodeId); + return clientRequestId !== null && args.pendingConnectionCreateIds.has(clientRequestId); + }; + + const shouldCarryOptimisticEdge = (original: RFEdge, remapped: RFEdge): boolean => { + if (mappedSignatures.has(rfEdgeConnectionSignature(remapped))) { + return false; + } + + const sourceOk = endpointUsable(remapped.source); + const targetOk = endpointUsable(remapped.target); + if (sourceOk && targetOk) return true; + + if (!args.pendingConnectionCreateIds.size) { + return false; + } + + if (sourceOk && optimisticEndpointHasPendingCreate(original.target)) { + return true; + } + + if (targetOk && optimisticEndpointHasPendingCreate(original.source)) { + return true; + } + + return false; + }; + + const carriedOptimistic: RFEdge[] = []; + for (const edge of args.previousEdges) { + if (edge.className === "temp") continue; + if (!isOptimisticEdgeId(edge.id)) continue; + + const remappedEdge: RFEdge = { + ...edge, + source: resolveEndpointWithInference(edge.source, edge), + target: resolveEndpointWithInference(edge.target, edge), + }; + + if (!shouldCarryOptimisticEdge(edge, remappedEdge)) continue; + carriedOptimistic.push(remappedEdge); + } + + const settledPendingConnectionCreateIds: string[] = []; + for (const clientRequestId of args.pendingConnectionCreateIds) { + const realId = inferredRealIdByClientRequest.get(clientRequestId); + if (realId === undefined) continue; + + const nodePresent = args.convexNodes?.some((node) => node._id === realId) ?? false; + const edgeTouchesNewNode = args.convexEdges.some( + (edge) => edge.sourceNodeId === realId || edge.targetNodeId === realId, + ); + if (nodePresent && edgeTouchesNewNode) { + settledPendingConnectionCreateIds.push(clientRequestId); + } + } + + return { + edges: [...mapped, ...carriedOptimistic, ...tempEdges], + nextConvexNodeIdsSnapshot: args.convexNodes + ? currentConvexIdSet + : new Set(args.previousConvexNodeIdsSnapshot), + inferredRealIdByClientRequest, + settledPendingConnectionCreateIds, + }; +} + +function applyLocalPositionPins(args: { + nodes: RFNode[]; + pendingLocalPositionPins: ReadonlyMap; +}): { + nodes: RFNode[]; + nextPendingLocalPositionPins: Map; +} { + const nextPendingLocalPositionPins = new Map(args.pendingLocalPositionPins); + const nodes = args.nodes.map((node) => { + const pin = nextPendingLocalPositionPins.get(node.id); + if (!pin) return node; + + if (positionsMatchPin(node.position, pin)) { + nextPendingLocalPositionPins.delete(node.id); + return node; + } + + return { + ...node, + position: { x: pin.x, y: pin.y }, + }; + }); + + return { + nodes, + nextPendingLocalPositionPins, + }; +} + +export function reconcileCanvasFlowNodes(args: { + previousNodes: RFNode[]; + incomingNodes: RFNode[]; + convexNodes: FlowConvexNodeRecord[]; + deletingNodeIds: ReadonlySet; + resolvedRealIdByClientRequest: ReadonlyMap>; + pendingConnectionCreateIds: ReadonlySet; + preferLocalPositionNodeIds: ReadonlySet; + pendingLocalPositionPins: ReadonlyMap; + pendingMovePins: ReadonlyMap; +}): { + nodes: RFNode[]; + inferredRealIdByClientRequest: Map>; + nextPendingLocalPositionPins: Map; + clearedPreferLocalPositionNodeIds: string[]; +} { + const inferredRealIdByClientRequest = inferPendingConnectionNodeHandoff({ + previousNodes: args.previousNodes, + incomingConvexNodes: args.convexNodes, + pendingConnectionCreateIds: args.pendingConnectionCreateIds, + resolvedRealIdByClientRequest: args.resolvedRealIdByClientRequest, + }); + + const filteredIncomingNodes = args.deletingNodeIds.size + ? args.incomingNodes.filter((node) => !args.deletingNodeIds.has(node.id)) + : args.incomingNodes; + const mergedNodes = mergeNodesPreservingLocalState( + args.previousNodes, + filteredIncomingNodes, + inferredRealIdByClientRequest, + args.preferLocalPositionNodeIds, + ); + const pinnedNodes = applyLocalPositionPins({ + nodes: mergedNodes, + pendingLocalPositionPins: args.pendingLocalPositionPins, + }); + const nodes = applyPinnedNodePositionsReadOnly( + pinnedNodes.nodes, + args.pendingMovePins, + ); + + const incomingById = new Map(filteredIncomingNodes.map((node) => [node.id, node])); + const clearedPreferLocalPositionNodeIds: string[] = []; + for (const node of nodes) { + if (!args.preferLocalPositionNodeIds.has(node.id)) continue; + + const incomingNode = incomingById.get(node.id); + if (!incomingNode) continue; + + if (positionsMatchPin(node.position, incomingNode.position)) { + clearedPreferLocalPositionNodeIds.push(node.id); + } + } + + return { + nodes, + inferredRealIdByClientRequest, + nextPendingLocalPositionPins: pinnedNodes.nextPendingLocalPositionPins, + clearedPreferLocalPositionNodeIds, + }; +} diff --git a/components/canvas/canvas-helpers.ts b/components/canvas/canvas-helpers.ts index 0c40b45..a3fe45d 100644 --- a/components/canvas/canvas-helpers.ts +++ b/components/canvas/canvas-helpers.ts @@ -1,7 +1,7 @@ import type { DefaultEdgeOptions, Edge as RFEdge, Node as RFNode } from "@xyflow/react"; import { readCanvasOps } from "@/lib/canvas-local-persistence"; -import type { Doc, Id } from "@/convex/_generated/dataModel"; +import type { Id } from "@/convex/_generated/dataModel"; import type { CanvasNodeDeleteBlockReason } from "@/lib/toast"; import { getSourceImage } from "@/lib/image-pipeline/contracts"; @@ -385,38 +385,6 @@ export function applyPinnedNodePositionsReadOnly( }); } -export function inferPendingConnectionNodeHandoff( - previousNodes: RFNode[], - incomingConvexNodes: Doc<"nodes">[], - pendingConnectionCreates: ReadonlySet, - resolvedRealIdByClientRequest: Map>, -): void { - const unresolvedClientRequestIds: string[] = []; - for (const clientRequestId of pendingConnectionCreates) { - if (resolvedRealIdByClientRequest.has(clientRequestId)) continue; - const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`; - const optimisticNodePresent = previousNodes.some( - (node) => node.id === optimisticNodeId, - ); - if (optimisticNodePresent) { - unresolvedClientRequestIds.push(clientRequestId); - } - } - if (unresolvedClientRequestIds.length !== 1) return; - - const previousIds = new Set(previousNodes.map((node) => node.id)); - const newlyAppearedIncomingRealNodeIds = incomingConvexNodes - .map((node) => node._id as string) - .filter((id) => !isOptimisticNodeId(id)) - .filter((id) => !previousIds.has(id)); - - if (newlyAppearedIncomingRealNodeIds.length !== 1) return; - - const inferredClientRequestId = unresolvedClientRequestIds[0]!; - const inferredRealId = newlyAppearedIncomingRealNodeIds[0] as Id<"nodes">; - resolvedRealIdByClientRequest.set(inferredClientRequestId, inferredRealId); -} - function isMoveNodeOpPayload( payload: unknown, ): payload is { nodeId: Id<"nodes">; positionX: number; positionY: number } { diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 19f5fc5..120da05 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -76,8 +76,6 @@ import { nodeTypes } from "./node-types"; import { convexNodeDocWithMergedStorageUrl, convexNodeToRF, - convexEdgeToRF, - convexEdgeToRFWithSourceGlow, NODE_DEFAULTS, NODE_HANDLE_MAP, } from "@/lib/canvas-utils"; @@ -99,8 +97,6 @@ import { import CustomConnectionLine from "@/components/canvas/custom-connection-line"; import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates"; import { - applyPinnedNodePositions, - applyPinnedNodePositionsReadOnly, CANVAS_MIN_ZOOM, clientRequestIdFromOptimisticEdgeId, clientRequestIdFromOptimisticNodeId, @@ -115,19 +111,19 @@ import { getPendingRemovedEdgeIdsFromLocalOps, getPendingMovePinsFromLocalOps, hasHandleKey, - inferPendingConnectionNodeHandoff, isEditableKeyboardTarget, isOptimisticEdgeId, isOptimisticNodeId, - mergeNodesPreservingLocalState, normalizeHandle, OPTIMISTIC_EDGE_PREFIX, OPTIMISTIC_NODE_PREFIX, - positionsMatchPin, type PendingEdgeSplit, - rfEdgeConnectionSignature, withResolvedCompareData, } from "./canvas-helpers"; +import { + reconcileCanvasFlowEdges, + reconcileCanvasFlowNodes, +} from "./canvas-flow-reconciliation-helpers"; import { adjustNodeDimensionChanges } from "./canvas-node-change-helpers"; import { useGenerationFailureWarnings } from "./canvas-generation-failures"; import { useCanvasDeleteHandlers } from "./canvas-delete-handlers"; @@ -1942,189 +1938,38 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { useLayoutEffect(() => { if (!convexEdges) return; setEdges((prev) => { - const prevConvexSnap = convexNodeIdsSnapshotForEdgeCarryRef.current; - const currentConvexIdList: string[] = - convexNodes !== undefined - ? convexNodes.map((n: Doc<"nodes">) => n._id as string) - : []; - const currentConvexIdSet = new Set(currentConvexIdList); - const newlyAppearedIds: string[] = []; - for (const id of currentConvexIdList) { - if (!prevConvexSnap.has(id)) newlyAppearedIds.push(id); + const reconciliation = reconcileCanvasFlowEdges({ + previousEdges: prev, + convexEdges, + convexNodes, + previousConvexNodeIdsSnapshot: convexNodeIdsSnapshotForEdgeCarryRef.current, + pendingRemovedEdgeIds: getPendingRemovedEdgeIdsFromLocalOps(canvasId as string), + pendingConnectionCreateIds: pendingConnectionCreatesRef.current, + resolvedRealIdByClientRequest: resolvedRealIdByClientRequestRef.current, + localNodeIds: new Set(nodesRef.current.map((node) => node.id)), + isAnyNodeDragging: + isDragging.current || + nodesRef.current.some((node) => + Boolean((node as { dragging?: boolean }).dragging), + ), + colorMode: resolvedTheme === "dark" ? "dark" : "light", + }); + + resolvedRealIdByClientRequestRef.current = + reconciliation.inferredRealIdByClientRequest; + convexNodeIdsSnapshotForEdgeCarryRef.current = + reconciliation.nextConvexNodeIdsSnapshot; + for (const clientRequestId of reconciliation.settledPendingConnectionCreateIds) { + pendingConnectionCreatesRef.current.delete(clientRequestId); } - const tempEdges = prev.filter((e) => e.className === "temp"); - const pendingRemovedEdgeIds = getPendingRemovedEdgeIdsFromLocalOps( - canvasId as string, - ); - const sourceTypeByNodeId = - convexNodes !== undefined - ? new Map( - convexNodes.map((n: Doc<"nodes">) => [n._id as string, n.type as string]), - ) - : undefined; - const glowMode = resolvedTheme === "dark" ? "dark" : "light"; - const mapped = convexEdges - .filter((edge: Doc<"edges">) => !pendingRemovedEdgeIds.has(edge._id as string)) - .map((edge: Doc<"edges">) => - sourceTypeByNodeId - ? convexEdgeToRFWithSourceGlow( - edge, - sourceTypeByNodeId.get(edge.sourceNodeId), - glowMode, - ) - : convexEdgeToRF(edge), - ); - - const mappedSignatures = new Set(mapped.map(rfEdgeConnectionSignature)); - const convexNodeIds = - convexNodes !== undefined - ? new Set(convexNodes.map((n: Doc<"nodes">) => n._id as string)) - : null; - const realIdByClientRequest = resolvedRealIdByClientRequestRef.current; - const isAnyNodeDragging = - isDragging.current || - nodesRef.current.some((n) => - Boolean((n as { dragging?: boolean }).dragging), - ); - - const localHasOptimisticNode = (nodeId: string): boolean => { - if (!isOptimisticNodeId(nodeId)) return false; - return nodesRef.current.some((n) => n.id === nodeId); - }; - - const resolveEndpoint = (nodeId: string): string => { - if (!isOptimisticNodeId(nodeId)) return nodeId; - const cr = clientRequestIdFromOptimisticNodeId(nodeId); - if (!cr) return nodeId; - if (isAnyNodeDragging && localHasOptimisticNode(nodeId)) { - return nodeId; - } - const real = realIdByClientRequest.get(cr); - return real !== undefined ? (real as string) : nodeId; - }; - - /** Wenn Mutation-.then noch nicht lief: echte ID aus Delta (eine neue Node) + gleiche clientRequestId wie Kante. */ - const resolveEndpointWithInference = ( - nodeId: string, - edge: RFEdge, - ): string => { - const base = resolveEndpoint(nodeId); - if (!isOptimisticNodeId(base)) return base; - if (isAnyNodeDragging) return base; - const nodeCr = clientRequestIdFromOptimisticNodeId(base); - if (nodeCr === null) return base; - const edgeCr = clientRequestIdFromOptimisticEdgeId(edge.id); - if (edgeCr === null || edgeCr !== nodeCr) return base; - if (!pendingConnectionCreatesRef.current.has(nodeCr)) return base; - if (newlyAppearedIds.length !== 1) return base; - const inferred = newlyAppearedIds[0]; - resolvedRealIdByClientRequestRef.current.set( - nodeCr, - inferred as Id<"nodes">, - ); - return inferred; - }; - - const endpointUsable = (nodeId: string): boolean => { - if (isAnyNodeDragging && localHasOptimisticNode(nodeId)) return true; - const resolved = resolveEndpoint(nodeId); - if (convexNodeIds?.has(resolved)) return true; - if (convexNodeIds?.has(nodeId)) return true; - return false; - }; - - const optimisticEndpointHasPendingCreate = (nodeId: string): boolean => { - if (!isOptimisticNodeId(nodeId)) return false; - const cr = clientRequestIdFromOptimisticNodeId(nodeId); - return ( - cr !== null && pendingConnectionCreatesRef.current.has(cr) - ); - }; - - const shouldCarryOptimisticEdge = ( - original: RFEdge, - remapped: RFEdge, - ): boolean => { - if (mappedSignatures.has(rfEdgeConnectionSignature(remapped))) { - return false; - } - - const sourceOk = endpointUsable(remapped.source); - const targetOk = endpointUsable(remapped.target); - if (sourceOk && targetOk) return true; - - if (!pendingConnectionCreatesRef.current.size) { - return false; - } - - if ( - sourceOk && - optimisticEndpointHasPendingCreate(original.target) - ) { - return true; - } - - if ( - targetOk && - optimisticEndpointHasPendingCreate(original.source) - ) { - return true; - } - - return false; - }; - - const carriedOptimistic: RFEdge[] = []; - for (const e of prev) { - if (e.className === "temp") continue; - if (!isOptimisticEdgeId(e.id)) continue; - - const remapped: RFEdge = { - ...e, - source: resolveEndpointWithInference(e.source, e), - target: resolveEndpointWithInference(e.target, e), - }; - - if (!shouldCarryOptimisticEdge(e, remapped)) continue; - - carriedOptimistic.push(remapped); - } - - if (convexNodes !== undefined) { - convexNodeIdsSnapshotForEdgeCarryRef.current = currentConvexIdSet; - } - - /** Erst löschen, wenn Convex die neue Kante geliefert hat — sonst kurzes Fenster: pending=0, Kanten-Query noch alt, Carry schlägt fehl. */ - for (const cr of [...pendingConnectionCreatesRef.current]) { - const realId = resolvedRealIdByClientRequestRef.current.get(cr); - if (realId === undefined) continue; - const nodePresent = - convexNodes !== undefined && - convexNodes.some((n: Doc<"nodes">) => n._id === realId); - const edgeTouchesNewNode = convexEdges.some( - (e: Doc<"edges">) => - e.sourceNodeId === realId || e.targetNodeId === realId, - ); - if (nodePresent && edgeTouchesNewNode) { - pendingConnectionCreatesRef.current.delete(cr); - } - } - - return [...mapped, ...carriedOptimistic, ...tempEdges]; + return reconciliation.edges; }); }, [canvasId, convexEdges, convexNodes, resolvedTheme, edgeSyncNonce]); useLayoutEffect(() => { if (!convexNodes || isResizing.current) return; setNodes((previousNodes) => { - inferPendingConnectionNodeHandoff( - previousNodes, - convexNodes, - pendingConnectionCreatesRef.current, - resolvedRealIdByClientRequestRef.current, - ); - /** RF setzt `node.dragging` + Position oft bevor `onNodeDragStart` `isDraggingRef` setzt — ohne diese Zeile zieht useLayoutEffect Convex-Stand darüber („Kleben“). */ const anyRfNodeDragging = previousNodes.some((n) => Boolean((n as { dragging?: boolean }).dragging), @@ -2150,41 +1995,27 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { enriched.map(convexNodeToRF), edges, ); - // Nodes, die gerade optimistisch gelöscht werden, nicht wiederherstellen - const filteredIncoming = deletingNodeIds.current.size > 0 - ? incomingNodes.filter((node) => !deletingNodeIds.current.has(node.id)) - : incomingNodes; - const merged = applyPinnedNodePositions( - mergeNodesPreservingLocalState( - previousNodes, - filteredIncoming, - resolvedRealIdByClientRequestRef.current, - preferLocalPositionNodeIdsRef.current, - ), - pendingLocalPositionUntilConvexMatchesRef.current, - ); - const mergedWithOpPins = applyPinnedNodePositionsReadOnly( - merged, - getPendingMovePinsFromLocalOps(canvasId as string), - ); - /** Nicht am Drag-Ende leeren (moveNode läuft oft async): solange Convex alt ist, Eintrag behalten und erst bei übereinstimmendem Snapshot entfernen. */ - const incomingById = new Map( - filteredIncoming.map((n) => [n.id, n]), - ); - for (const n of mergedWithOpPins) { - if (!preferLocalPositionNodeIdsRef.current.has(n.id)) continue; - const inc = incomingById.get(n.id); - if (!inc) continue; - if ( - positionsMatchPin(n.position, { - x: inc.position.x, - y: inc.position.y, - }) - ) { - preferLocalPositionNodeIdsRef.current.delete(n.id); - } + const reconciliation = reconcileCanvasFlowNodes({ + previousNodes, + incomingNodes, + convexNodes, + deletingNodeIds: deletingNodeIds.current, + resolvedRealIdByClientRequest: resolvedRealIdByClientRequestRef.current, + pendingConnectionCreateIds: pendingConnectionCreatesRef.current, + preferLocalPositionNodeIds: preferLocalPositionNodeIdsRef.current, + pendingLocalPositionPins: pendingLocalPositionUntilConvexMatchesRef.current, + pendingMovePins: getPendingMovePinsFromLocalOps(canvasId as string), + }); + + resolvedRealIdByClientRequestRef.current = + reconciliation.inferredRealIdByClientRequest; + pendingLocalPositionUntilConvexMatchesRef.current = + reconciliation.nextPendingLocalPositionPins; + for (const nodeId of reconciliation.clearedPreferLocalPositionNodeIds) { + preferLocalPositionNodeIdsRef.current.delete(nodeId); } - return mergedWithOpPins; + + return reconciliation.nodes; }); }, [canvasId, convexNodes, edges, storageUrlsById]);