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.
This commit is contained in:
2026-04-03 21:36:02 +02:00
parent 5223d3d8d7
commit d1c14c93e5
5 changed files with 500 additions and 102 deletions

View File

@@ -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<typeof import("@/components/canvas/canvas-helpers")>(
"@/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<string, string>;
themeMode: "light" | "dark";
edgeSyncNonce: number;
isDragging: boolean;
isResizing: boolean;
resolvedRealIdByClientRequest: Map<string, Id<"nodes">>;
pendingConnectionCreateIds: Set<string>;
previousConvexNodeIdsSnapshot: Set<string>;
pendingLocalPositionPins?: Map<string, { x: number; y: number }>;
preferLocalPositionNodeIds?: Set<string>;
};
const latestStateRef: {
current: {
nodes: RFNode[];
edges: RFEdge[];
resolvedRealIdByClientRequest: Map<string, Id<"nodes">>;
pendingConnectionCreateIds: Set<string>;
previousConvexNodeIdsSnapshot: Set<string>;
} | 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<RFNode[]>(props.initialNodes);
const [edges, setEdges] = useState<RFEdge[]>(props.initialEdges);
const nodesRef = useRef(nodes);
const deletingNodeIds = useRef(new Set<string>());
const convexNodeIdsSnapshotForEdgeCarryRef = useRef(
props.previousConvexNodeIdsSnapshot,
);
const resolvedRealIdByClientRequestRef = useRef(
props.resolvedRealIdByClientRequest,
);
const pendingConnectionCreatesRef = useRef(props.pendingConnectionCreateIds);
const pendingLocalPositionUntilConvexMatchesRef = useRef(
props.pendingLocalPositionPins ?? new Map<string, { x: number; y: number }>(),
);
const preferLocalPositionNodeIdsRef = useRef(
props.preferLocalPositionNodeIds ?? new Set<string>(),
);
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(
<HookHarness
canvasId={asCanvasId("canvas-1")}
initialNodes={[
{
id: "node-source",
type: "image",
position: { x: 0, y: 0 },
data: {},
},
{
id: "optimistic_req-1",
type: "prompt",
position: { x: 120, y: 80 },
data: {},
},
]}
initialEdges={[
{
id: "optimistic_edge_req-1",
source: "node-source",
target: "optimistic_req-1",
},
]}
convexNodes={[
{
_id: asNodeId("node-source"),
_creationTime: 0,
canvasId: asCanvasId("canvas-1"),
type: "image",
positionX: 0,
positionY: 0,
width: 280,
height: 200,
data: {},
} as Doc<"nodes">,
{
_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(
<HookHarness
canvasId={asCanvasId("canvas-1")}
initialNodes={[
{
id: "optimistic_req-drag",
type: "image",
position: { x: 320, y: 180 },
data: { label: "local" },
dragging: true,
},
]}
initialEdges={[]}
convexNodes={[
{
_id: asNodeId("node-real"),
_creationTime: 1,
canvasId: asCanvasId("canvas-1"),
type: "image",
positionX: 20,
positionY: 40,
width: 280,
height: 200,
data: { label: "server" },
} as Doc<"nodes">,
]}
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,
},
]);
});
});

View File

@@ -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<Doc<"nodes">, "_id" | "type">;
@@ -21,6 +27,22 @@ type FlowConvexEdgeRecord = Pick<
"_id" | "sourceNodeId" | "targetNodeId" | "sourceHandle" | "targetHandle"
>;
export function buildIncomingCanvasFlowNodes(args: {
convexNodes: Doc<"nodes">[];
storageUrlsById: Record<string, string | undefined> | undefined;
previousNodes: RFNode[];
edges: RFEdge[];
}): RFNode[] {
const previousDataById = new Map(
args.previousNodes.map((node) => [node.id, node.data as Record<string, unknown>]),
);
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[];

View File

@@ -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<string, unknown>]),
);
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,

View File

@@ -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<RFNode[]>;
deletingNodeIds: MutableRefObject<Set<string>>;
convexNodeIdsSnapshotForEdgeCarryRef: MutableRefObject<Set<string>>;
resolvedRealIdByClientRequestRef: MutableRefObject<Map<string, Id<"nodes">>>;
pendingConnectionCreatesRef: MutableRefObject<Set<string>>;
pendingLocalPositionUntilConvexMatchesRef: MutableRefObject<
Map<string, PositionPin>
>;
preferLocalPositionNodeIdsRef: MutableRefObject<Set<string>>;
isDragging: MutableRefObject<boolean>;
isResizing: MutableRefObject<boolean>;
};
export function useCanvasFlowReconciliation(args: {
canvasId: Id<"canvases">;
convexNodes: Doc<"nodes">[] | undefined;
convexEdges: Doc<"edges">[] | undefined;
storageUrlsById: Record<string, string | undefined> | undefined;
themeMode: "light" | "dark";
edges: RFEdge[];
edgeSyncNonce: number;
setNodes: Dispatch<SetStateAction<RFNode[]>>;
setEdges: Dispatch<SetStateAction<RFEdge[]>>;
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,
]);
}

View File

@@ -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",
],