From d1c14c93e593b4d6199b91f4f4589a72b51ae3d0 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Fri, 3 Apr 2026 21:36:02 +0200 Subject: [PATCH] refactor(canvas): extract flow reconciliation hook Move Convex-to-local node and edge reconciliation into a dedicated hook so canvas.tsx has a cleaner sync boundary during modularization. Add hook-level tests for optimistic edge carry and drag-lock behavior to preserve the existing UX. --- .../use-canvas-flow-reconciliation.test.tsx | 279 ++++++++++++++++++ .../canvas-flow-reconciliation-helpers.ts | 24 +- components/canvas/canvas.tsx | 132 ++------- .../canvas/use-canvas-flow-reconciliation.ts | 166 +++++++++++ vitest.config.ts | 1 + 5 files changed, 500 insertions(+), 102 deletions(-) create mode 100644 components/canvas/__tests__/use-canvas-flow-reconciliation.test.tsx create mode 100644 components/canvas/use-canvas-flow-reconciliation.ts diff --git a/components/canvas/__tests__/use-canvas-flow-reconciliation.test.tsx b/components/canvas/__tests__/use-canvas-flow-reconciliation.test.tsx new file mode 100644 index 0000000..d095c43 --- /dev/null +++ b/components/canvas/__tests__/use-canvas-flow-reconciliation.test.tsx @@ -0,0 +1,279 @@ +// @vitest-environment jsdom + +import { act, useEffect, useRef, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { Doc, Id } from "@/convex/_generated/dataModel"; +import { useCanvasFlowReconciliation } from "@/components/canvas/use-canvas-flow-reconciliation"; + +vi.mock("@/components/canvas/canvas-helpers", async () => { + const actual = await vi.importActual( + "@/components/canvas/canvas-helpers", + ); + + return { + ...actual, + getPendingMovePinsFromLocalOps: vi.fn(() => new Map()), + getPendingRemovedEdgeIdsFromLocalOps: vi.fn(() => new Set()), + }; +}); + +const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">; +const asNodeId = (id: string): Id<"nodes"> => id as Id<"nodes">; +type HarnessProps = { + canvasId: Id<"canvases">; + initialNodes: RFNode[]; + initialEdges: RFEdge[]; + convexNodes?: Doc<"nodes">[]; + convexEdges?: Doc<"edges">[]; + storageUrlsById: Record; + themeMode: "light" | "dark"; + edgeSyncNonce: number; + isDragging: boolean; + isResizing: boolean; + resolvedRealIdByClientRequest: Map>; + pendingConnectionCreateIds: Set; + previousConvexNodeIdsSnapshot: Set; + pendingLocalPositionPins?: Map; + preferLocalPositionNodeIds?: Set; +}; + +const latestStateRef: { + current: { + nodes: RFNode[]; + edges: RFEdge[]; + resolvedRealIdByClientRequest: Map>; + pendingConnectionCreateIds: Set; + previousConvexNodeIdsSnapshot: Set; + } | null; +} = { current: null }; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function HookHarness(props: HarnessProps) { + const [nodes, setNodes] = useState(props.initialNodes); + const [edges, setEdges] = useState(props.initialEdges); + const nodesRef = useRef(nodes); + const deletingNodeIds = useRef(new Set()); + const convexNodeIdsSnapshotForEdgeCarryRef = useRef( + props.previousConvexNodeIdsSnapshot, + ); + const resolvedRealIdByClientRequestRef = useRef( + props.resolvedRealIdByClientRequest, + ); + const pendingConnectionCreatesRef = useRef(props.pendingConnectionCreateIds); + const pendingLocalPositionUntilConvexMatchesRef = useRef( + props.pendingLocalPositionPins ?? new Map(), + ); + const preferLocalPositionNodeIdsRef = useRef( + props.preferLocalPositionNodeIds ?? new Set(), + ); + const isDraggingRef = useRef(props.isDragging); + const isResizingRef = useRef(props.isResizing); + + useEffect(() => { + nodesRef.current = nodes; + }, [nodes]); + + useEffect(() => { + isDraggingRef.current = props.isDragging; + isResizingRef.current = props.isResizing; + }, [props.isDragging, props.isResizing]); + + useCanvasFlowReconciliation({ + canvasId: props.canvasId, + convexNodes: props.convexNodes, + convexEdges: props.convexEdges, + storageUrlsById: props.storageUrlsById, + themeMode: props.themeMode, + edges, + edgeSyncNonce: props.edgeSyncNonce, + setNodes, + setEdges, + refs: { + nodesRef, + deletingNodeIds, + convexNodeIdsSnapshotForEdgeCarryRef, + resolvedRealIdByClientRequestRef, + pendingConnectionCreatesRef, + pendingLocalPositionUntilConvexMatchesRef, + preferLocalPositionNodeIdsRef, + isDragging: isDraggingRef, + isResizing: isResizingRef, + }, + }); + + useEffect(() => { + latestStateRef.current = { + nodes, + edges, + resolvedRealIdByClientRequest: resolvedRealIdByClientRequestRef.current, + pendingConnectionCreateIds: pendingConnectionCreatesRef.current, + previousConvexNodeIdsSnapshot: convexNodeIdsSnapshotForEdgeCarryRef.current, + }; + }, [edges, nodes]); + + return null; +} + +describe("useCanvasFlowReconciliation", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + afterEach(async () => { + latestStateRef.current = null; + if (root) { + await act(async () => { + root?.unmount(); + }); + } + container?.remove(); + root = null; + container = null; + }); + + it("carries an optimistic connection edge until convex publishes the real edge", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + { + _id: asNodeId("node-real"), + _creationTime: 1, + canvasId: asCanvasId("canvas-1"), + type: "prompt", + positionX: 120, + positionY: 80, + width: 288, + height: 220, + data: {}, + } as Doc<"nodes">, + ]} + convexEdges={[]} + storageUrlsById={{}} + themeMode="light" + edgeSyncNonce={0} + isDragging={false} + isResizing={false} + resolvedRealIdByClientRequest={new Map()} + pendingConnectionCreateIds={new Set(["req-1"])} + previousConvexNodeIdsSnapshot={new Set(["node-source"])} + />, + ); + }); + + expect(latestStateRef.current?.edges).toEqual([ + { + id: "optimistic_edge_req-1", + source: "node-source", + target: "node-real", + }, + ]); + expect(latestStateRef.current?.resolvedRealIdByClientRequest).toEqual( + new Map([["req-1", asNodeId("node-real")]]), + ); + expect(latestStateRef.current?.pendingConnectionCreateIds).toEqual( + new Set(["req-1"]), + ); + expect(latestStateRef.current?.previousConvexNodeIdsSnapshot).toEqual( + new Set(["node-source", "node-real"]), + ); + }); + + it("preserves local dragging nodes instead of swapping in convex nodes mid-drag", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ]} + convexEdges={[] as Doc<"edges">[]} + storageUrlsById={{}} + themeMode="light" + edgeSyncNonce={0} + isDragging={false} + isResizing={false} + resolvedRealIdByClientRequest={new Map([ + ["req-drag", asNodeId("node-real")], + ])} + pendingConnectionCreateIds={new Set()} + previousConvexNodeIdsSnapshot={new Set(["node-real"])} + />, + ); + }); + + expect(latestStateRef.current?.nodes).toEqual([ + { + id: "optimistic_req-drag", + type: "image", + position: { x: 320, y: 180 }, + data: { label: "local" }, + dragging: true, + }, + ]); + }); +}); diff --git a/components/canvas/canvas-flow-reconciliation-helpers.ts b/components/canvas/canvas-flow-reconciliation-helpers.ts index 6ce6ecf..737e4ea 100644 --- a/components/canvas/canvas-flow-reconciliation-helpers.ts +++ b/components/canvas/canvas-flow-reconciliation-helpers.ts @@ -1,7 +1,12 @@ 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 { + convexEdgeToRF, + convexEdgeToRFWithSourceGlow, + convexNodeDocWithMergedStorageUrl, + convexNodeToRF, +} from "@/lib/canvas-utils"; import { applyPinnedNodePositionsReadOnly, @@ -13,6 +18,7 @@ import { OPTIMISTIC_NODE_PREFIX, positionsMatchPin, rfEdgeConnectionSignature, + withResolvedCompareData, } from "./canvas-helpers"; type FlowConvexNodeRecord = Pick, "_id" | "type">; @@ -21,6 +27,22 @@ type FlowConvexEdgeRecord = Pick< "_id" | "sourceNodeId" | "targetNodeId" | "sourceHandle" | "targetHandle" >; +export function buildIncomingCanvasFlowNodes(args: { + convexNodes: Doc<"nodes">[]; + storageUrlsById: Record | undefined; + previousNodes: RFNode[]; + edges: RFEdge[]; +}): RFNode[] { + const previousDataById = new Map( + args.previousNodes.map((node) => [node.id, node.data as Record]), + ); + const enrichedNodes = args.convexNodes.map((node) => + convexNodeDocWithMergedStorageUrl(node, args.storageUrlsById, previousDataById), + ); + + return withResolvedCompareData(enrichedNodes.map(convexNodeToRF), args.edges); +} + export function inferPendingConnectionNodeHandoff(args: { previousNodes: RFNode[]; incomingConvexNodes: FlowConvexNodeRecord[]; diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 8e9c151..05fa4bc 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, @@ -39,7 +38,7 @@ import { import { showCanvasConnectionRejectedToast } from "@/lib/toast-messages"; import { useMutation } from "convex/react"; import { api } from "@/convex/_generated/api"; -import type { Doc, Id } from "@/convex/_generated/dataModel"; +import type { Id } from "@/convex/_generated/dataModel"; import { isAdjustmentPresetNodeType, isCanvasNodeType, @@ -48,8 +47,6 @@ import { import { nodeTypes } from "./node-types"; import { - convexNodeDocWithMergedStorageUrl, - convexNodeToRF, NODE_DEFAULTS, NODE_HANDLE_MAP, } from "@/lib/canvas-utils"; @@ -80,8 +77,6 @@ import { getMiniMapNodeStrokeColor, getNodeCenterClientPosition, getIntersectedEdgeId, - getPendingRemovedEdgeIdsFromLocalOps, - getPendingMovePinsFromLocalOps, hasHandleKey, isEditableKeyboardTarget, isOptimisticEdgeId, @@ -89,10 +84,6 @@ import { normalizeHandle, 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"; @@ -101,6 +92,7 @@ import { useCanvasReconnectHandlers } from "./canvas-reconnect"; import { useCanvasScissors } from "./canvas-scissors"; import { CanvasSyncProvider } from "./canvas-sync-context"; import { useCanvasData } from "./use-canvas-data"; +import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation"; import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence"; import { useCanvasSyncEngine } from "./use-canvas-sync-engine"; @@ -346,95 +338,28 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }, }); - // ─── Future hook seam: flow reconciliation ──────────────────── - /** - * 1) Kanten: Carry/Inferenz setzt ggf. `resolvedRealIdByClientRequestRef` (auch bevor Mutation-.then läuft). - * 2) Nodes: gleicher Commit, vor Paint — echte Node-IDs passen zu Kanten-Endpunkten (verhindert „reißende“ Kanten). - * Während Drag (`isDraggingRef` oder `node.dragging`): nur optimistic→real-Handoff. - */ - useLayoutEffect(() => { - if (!convexEdges) return; - setEdges((prev) => { - 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); - } - - return reconciliation.edges; - }); - }, [canvasId, convexEdges, convexNodes, resolvedTheme, edgeSyncNonce]); - - useLayoutEffect(() => { - if (!convexNodes || isResizing.current) return; - setNodes((previousNodes) => { - /** 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), - ); - if (isDragging.current || anyRfNodeDragging) { - // Kritisch für UX: Kein optimistic->real-ID-Handoff während aktivem Drag. - // Sonst kann React Flow den Drag verlieren ("Node klebt"), sobald der - // Server-Create zurückkommt und die ID im laufenden Pointer-Stream wechselt. - return previousNodes; - } - - const prevDataById = new Map( - previousNodes.map((node) => [node.id, node.data as Record]), - ); - const enriched = convexNodes.map((node: Doc<"nodes">) => - convexNodeDocWithMergedStorageUrl( - node, - storageUrlsById, - prevDataById, - ), - ); - const incomingNodes = withResolvedCompareData( - enriched.map(convexNodeToRF), - edges, - ); - 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 reconciliation.nodes; - }); - }, [canvasId, convexNodes, edges, storageUrlsById]); + useCanvasFlowReconciliation({ + canvasId, + convexNodes, + convexEdges, + storageUrlsById, + themeMode: resolvedTheme === "dark" ? "dark" : "light", + edges, + edgeSyncNonce, + setNodes, + setEdges, + refs: { + nodesRef, + deletingNodeIds, + convexNodeIdsSnapshotForEdgeCarryRef, + resolvedRealIdByClientRequestRef, + pendingConnectionCreatesRef, + pendingLocalPositionUntilConvexMatchesRef, + preferLocalPositionNodeIdsRef, + isDragging, + isResizing, + }, + }); useEffect(() => { if (isDragging.current) return; @@ -497,7 +422,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { return nextNodes; }); }, - [runResizeNodeMutation], + [pendingLocalPositionUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, runResizeNodeMutation], ); const onEdgesChange = useCallback((changes: EdgeChange[]) => { @@ -618,7 +543,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { pendingLocalPositionUntilConvexMatchesRef.current.delete(n.id); } }, - [setHighlightedIntersectionEdge], + [pendingLocalPositionUntilConvexMatchesRef, setHighlightedIntersectionEdge], ); const onNodeDragStop = useCallback( @@ -806,6 +731,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { edges, runBatchMoveNodesMutation, runMoveNodeMutation, + pendingEdgeSplitByClientRequestRef, + pendingMoveAfterCreateRef, + resolvedRealIdByClientRequestRef, setHighlightedIntersectionEdge, runSplitEdgeAtExistingNodeMutation, syncPendingMoveForClientRequest, @@ -975,6 +903,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }, [ canvasId, + pendingConnectionCreatesRef, + resolvedRealIdByClientRequestRef, runCreateNodeWithEdgeFromSourceOnlineOnly, runCreateNodeWithEdgeToTargetOnlineOnly, showConnectionRejectedToast, diff --git a/components/canvas/use-canvas-flow-reconciliation.ts b/components/canvas/use-canvas-flow-reconciliation.ts new file mode 100644 index 0000000..a830795 --- /dev/null +++ b/components/canvas/use-canvas-flow-reconciliation.ts @@ -0,0 +1,166 @@ +import { useLayoutEffect, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; +import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; + +import type { Doc, Id } from "@/convex/_generated/dataModel"; + +import { + getPendingMovePinsFromLocalOps, + getPendingRemovedEdgeIdsFromLocalOps, +} from "./canvas-helpers"; +import { + buildIncomingCanvasFlowNodes, + reconcileCanvasFlowEdges, + reconcileCanvasFlowNodes, +} from "./canvas-flow-reconciliation-helpers"; + +type PositionPin = { x: number; y: number }; + +type CanvasFlowReconciliationRefs = { + nodesRef: MutableRefObject; + deletingNodeIds: MutableRefObject>; + convexNodeIdsSnapshotForEdgeCarryRef: MutableRefObject>; + resolvedRealIdByClientRequestRef: MutableRefObject>>; + pendingConnectionCreatesRef: MutableRefObject>; + pendingLocalPositionUntilConvexMatchesRef: MutableRefObject< + Map + >; + preferLocalPositionNodeIdsRef: MutableRefObject>; + isDragging: MutableRefObject; + isResizing: MutableRefObject; +}; + +export function useCanvasFlowReconciliation(args: { + canvasId: Id<"canvases">; + convexNodes: Doc<"nodes">[] | undefined; + convexEdges: Doc<"edges">[] | undefined; + storageUrlsById: Record | undefined; + themeMode: "light" | "dark"; + edges: RFEdge[]; + edgeSyncNonce: number; + setNodes: Dispatch>; + setEdges: Dispatch>; + refs: CanvasFlowReconciliationRefs; +}) { + const { + canvasId, + convexEdges, + convexNodes, + storageUrlsById, + themeMode, + edges, + edgeSyncNonce, + setNodes, + setEdges, + } = args; + const { + nodesRef, + deletingNodeIds, + convexNodeIdsSnapshotForEdgeCarryRef, + resolvedRealIdByClientRequestRef, + pendingConnectionCreatesRef, + pendingLocalPositionUntilConvexMatchesRef, + preferLocalPositionNodeIdsRef, + isDragging, + isResizing, + } = args.refs; + + useLayoutEffect(() => { + if (!convexEdges) return; + + setEdges((previousEdges) => { + const reconciliation = reconcileCanvasFlowEdges({ + previousEdges, + 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: themeMode, + }); + + resolvedRealIdByClientRequestRef.current = + reconciliation.inferredRealIdByClientRequest; + convexNodeIdsSnapshotForEdgeCarryRef.current = + reconciliation.nextConvexNodeIdsSnapshot; + for (const clientRequestId of reconciliation.settledPendingConnectionCreateIds) { + pendingConnectionCreatesRef.current.delete(clientRequestId); + } + + return reconciliation.edges; + }); + }, [ + canvasId, + convexEdges, + convexNodes, + edgeSyncNonce, + setEdges, + themeMode, + convexNodeIdsSnapshotForEdgeCarryRef, + isDragging, + nodesRef, + pendingConnectionCreatesRef, + resolvedRealIdByClientRequestRef, + ]); + + useLayoutEffect(() => { + if (!convexNodes || isResizing.current) return; + + setNodes((previousNodes) => { + const anyRfNodeDragging = previousNodes.some((node) => + Boolean((node as { dragging?: boolean }).dragging), + ); + if (isDragging.current || anyRfNodeDragging) { + return previousNodes; + } + + const incomingNodes = buildIncomingCanvasFlowNodes({ + convexNodes, + storageUrlsById, + previousNodes, + edges, + }); + + 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 reconciliation.nodes; + }); + }, [ + canvasId, + convexNodes, + edges, + setNodes, + storageUrlsById, + deletingNodeIds, + isDragging, + isResizing, + pendingConnectionCreatesRef, + pendingLocalPositionUntilConvexMatchesRef, + preferLocalPositionNodeIdsRef, + resolvedRealIdByClientRequestRef, + ]); +} diff --git a/vitest.config.ts b/vitest.config.ts index 70f9b7d..4336a3d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ include: [ "tests/**/*.test.ts", "components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts", + "components/canvas/__tests__/use-canvas-flow-reconciliation.test.tsx", "components/canvas/__tests__/use-canvas-sync-engine.test.ts", "components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx", ],