From 5ce919f333b257222bb168e9d943503996db06fc Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Fri, 14 Aug 2026 15:43:41 -0400 Subject: [PATCH] Keep connection paths correct on every frame of a drag Screenshots of the live symptom show the line drawn at the position the node occupied earlier in the gesture, with a gap of hundreds of pixels on a fast drag, closing as soon as movement stops. The distance is far more than one frame's worth of movement, so the geometry is not merely late: something is actively drawing the connection from records that trail the gesture. Writing the path once per pointer event, as the previous change did, is not enough on its own. React Flow re-renders the same edges from its own node records immediately afterwards, and if those records are behind the gesture, that render overwrites the correct path with a stale one. The net result on screen is unchanged, which matches the report that the previous change made no difference. The correct geometry is now the last write before every paint for the whole gesture, not just at the moment each pointer event is handled. An animation-frame loop, alive only while a drag is in progress, redraws the affected connections from the latest corrected positions. Whatever React renders in between can no longer be what the user sees. The loop is torn down when the gesture ends and if the canvas unmounts mid-gesture. Co-Authored-By: Claude Opus 5 --- web_ui/src/app/canvas/SceneCanvas.tsx | 47 +++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx index 31fae9c..bdb822e 100644 --- a/web_ui/src/app/canvas/SceneCanvas.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.tsx @@ -2283,6 +2283,20 @@ function CanvasInner({ // The connections this gesture must keep in step, resolved once when it // starts - see drag/edgeSync.ts for why they are written synchronously. const edgeSyncPlanRef = useRef([]); + // The latest corrected position of every node this gesture is moving, and + // the animation-frame loop that keeps their connections drawn from it. + // + // Writing the paths once per pointer event is not sufficient on its own: + // React Flow re-renders the same edges from its own node records a moment + // later, and if those records are even one frame behind the gesture, that + // render overwrites the correct path with a stale one - which is exactly + // the reported symptom, a line drawn where the node used to be, with the + // gap growing the faster the node moves and closing when it stops. A + // frame loop makes the correct geometry the LAST write before every + // paint, so whatever React renders in between cannot be what the user + // ends up seeing. The loop exists only for the duration of a gesture. + const dragPositionsRef = useRef>(new Map()); + const edgeSyncFrameRef = useRef(null); // ADR-011 stage 11.1: ONE ToFlowNodesCache for this canvas's whole // lifetime, threaded into every toFlowNodes call below - this is what // actually makes the per-node dispatcher/whole-flow-node memoization in @@ -2452,6 +2466,14 @@ function CanvasInner({ return () => document.removeEventListener("keydown", handleKeyboardContextMenu); }, []); + // A canvas unmounted mid-gesture must not leave its frame loop running. + useEffect( + () => () => { + if (edgeSyncFrameRef.current !== null) cancelAnimationFrame(edgeSyncFrameRef.current); + }, + [], + ); + // ADR-011 stage 11.3 (P4): toFlowEdges rebuilds the WHOLE edges array (an // O(E) map over every edge) - hoveredEdgeId is only EVER read inside that // rebuild when scene.fadeConnectionsEnabled is on (see toFlowEdges' own @@ -2648,11 +2670,27 @@ function CanvasInner({ // shape is already in the DOM for the frame being painted. See // drag/edgeSync.ts for the full reasoning. if (edgeSyncPlanRef.current.length > 0) { - const movedPositions = new Map(); + const movedPositions = dragPositionsRef.current; for (const c of [...corrected, ...memberChanges]) { if (c.type === "position" && c.position) movedPositions.set(c.id, c.position); } - syncEdgePaths(edgeSyncPlanRef.current, movedPositions, (id) => storeApi.getState().nodeLookup.get(id)); + const getInternal = (id: string) => storeApi.getState().nodeLookup.get(id); + // Immediately, for this event's own frame... + syncEdgePaths(edgeSyncPlanRef.current, movedPositions, getInternal); + // ...and again before every subsequent paint until the gesture ends, + // so a later render from stale records cannot leave a stale path on + // screen. See dragPositionsRef's comment above. + if (edgeSyncFrameRef.current === null) { + const tick = () => { + if (edgeSyncPlanRef.current.length === 0) { + edgeSyncFrameRef.current = null; + return; + } + syncEdgePaths(edgeSyncPlanRef.current, dragPositionsRef.current, getInternal); + edgeSyncFrameRef.current = requestAnimationFrame(tick); + }; + edgeSyncFrameRef.current = requestAnimationFrame(tick); + } } // Group members ride in the SAME batch as the node that carries them, // so React Flow commits the group and its members together. @@ -2738,6 +2776,11 @@ function CanvasInner({ pendingGuidesRef.current = []; // Gesture over: React Flow owns the edges again until the next one. edgeSyncPlanRef.current = []; + dragPositionsRef.current = new Map(); + if (edgeSyncFrameRef.current !== null) { + cancelAnimationFrame(edgeSyncFrameRef.current); + edgeSyncFrameRef.current = null; + } setSmartGuideLines((current) => (current.length === 0 ? current : [])); } // Suspend off-viewport culling while a drag is in flight - see