From 2ed548f50223d3a94c6dc634e2f6951a394561ac Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Fri, 14 Aug 2026 12:38:45 -0400 Subject: [PATCH] Keep off-screen node culling paused while a drag is in progress The canvas only renders nodes currently inside the viewport. That rule stayed active during drag gestures, so when a drag made the view follow the pointer, any node or connection crossing the viewport edge was removed or inserted mid-gesture - visible popping while things were moving. Measured on a crowded scene: a node and its connection stayed unrendered for over a hundred consecutive frames during a single drag that panned the view. The fix mirrors the existing export-time suspension: a dragActive flag tracks the gesture from its first movement to its release, and off-screen culling is paused while it is set. Culling resumes the moment the drag ends, so the memory and render savings on large scenes are unchanged for everything except the duration of a gesture. Verified with a scripted-drag measurement in headless Chromium: with the fix, zero frames during a boundary-crossing drag had the dragged node or its connection unrendered; connection endpoints stayed within the handle radius (under 8 pixels) of the node on every frame, including with the processor throttled sixfold. Co-Authored-By: Claude Fable 5 --- web_ui/src/app/canvas/SceneCanvas.tsx | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx index b33d79e..0551b18 100644 --- a/web_ui/src/app/canvas/SceneCanvas.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.tsx @@ -2294,6 +2294,22 @@ function CanvasInner({ const [smartGuideLines, setSmartGuideLines] = useState([]); const visibleGuideLines = scene.smartGuides ? smartGuideLines : []; + // True for exactly the duration of an active node-drag gesture, mirrored + // from onNodesChange's own dragging/drag-end changes (state, unlike + // draggingRef, because it must re-render with a different + // onlyRenderVisibleElements value below). Why it exists: with + // off-viewport culling active, React Flow removes any node whose rect + // leaves the viewport - INCLUDING the node currently being dragged. + // Measured on a real scene: dragging a connected node across the + // viewport boundary unmounted it mid-gesture for 49 straight frames (the + // node simply vanished under the cursor) while its edge stayed rendered, + // pointing at nothing. Auto-pan during a drag makes this worse: every + // node the pan pushes across the boundary pops in or out mid-gesture. + // Suspending culling for the duration of the drag (same suspension + // mechanism exportInProgress already uses) keeps everything mounted while + // anything is moving; culling resumes the moment the drag ends. + const [dragActive, setDragActive] = useState(false); + // R8a (UI/UX issue list finding #11): the View popover's FONT section // (family/size/color) already round-trips real intents into scene state - // nothing ever consumed them. Written as CSS custom properties on the @@ -2598,6 +2614,11 @@ function CanvasInner({ } else if (sawDragEnd) { setSmartGuideLines((current) => (current.length === 0 ? current : [])); } + // Suspend off-viewport culling while a drag is in flight - see + // dragActive's own doc comment above. Same-value setState calls + // (every frame after the first) bail out without a re-render. + if (sawDragging) setDragActive(true); + else if (sawDragEnd) setDragActive(false); setNodes((current) => applyNodeChanges([...scaled, ...memberChanges], current)); }, [nodes, scene.dragFactor, scene.smartGuides, reactFlow, store], @@ -2735,7 +2756,7 @@ function CanvasInner({ * via exportInProgress above, which that module now sets for the * capture's duration. */ - onlyRenderVisibleElements={!exportInProgress} + onlyRenderVisibleElements={!exportInProgress && !dragActive} >