From 0a5bf5a8217475adff3872ceaf8f7e2b20154b1e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 24 Jun 2025 20:53:07 -0700 Subject: [PATCH] fix(sockets): smoother throttling + fix re-render bug (#541) * fix(sockets movement): smoother throttling strategy and re-render bug fix * works --------- Co-authored-by: Vikhyath Mondreti --- apps/sim/app/w/[id]/workflow.tsx | 76 ++++++++++++++++++++-------- apps/sim/contexts/socket-context.tsx | 41 +++++---------- 2 files changed, 69 insertions(+), 48 deletions(-) diff --git a/apps/sim/app/w/[id]/workflow.tsx b/apps/sim/app/w/[id]/workflow.tsx index a2e605a83..68a38d78d 100644 --- a/apps/sim/app/w/[id]/workflow.tsx +++ b/apps/sim/app/w/[id]/workflow.tsx @@ -99,7 +99,12 @@ const WorkflowContent = React.memo(() => { const { workflows, activeWorkflowId, isLoading, setActiveWorkflow, createWorkflow } = useWorkflowRegistry() - const { blocks, edges, updateNodeDimensions } = useWorkflowStore() + const { + blocks, + edges, + updateNodeDimensions, + updateBlockPosition: storeUpdateBlockPosition, + } = useWorkflowStore() // Use collaborative operations for real-time sync const currentWorkflow = useMemo(() => workflows[workflowId], [workflows, workflowId]) const workspaceId = currentWorkflow?.workspaceId @@ -117,7 +122,7 @@ const WorkflowContent = React.memo(() => { collaborativeAddBlock: addBlock, collaborativeAddEdge: addEdge, collaborativeRemoveEdge: removeEdge, - collaborativeUpdateBlockPosition: updateBlockPosition, + collaborativeUpdateBlockPosition, collaborativeUpdateParentId: updateParentId, isConnected, currentWorkflowId, @@ -186,12 +191,12 @@ const WorkflowContent = React.memo(() => { nodeId, newParentId, getNodes, - updateBlockPosition, + collaborativeUpdateBlockPosition, updateParentId, () => resizeLoopNodes(getNodes, updateNodeDimensions, blocks) ) }, - [getNodes, updateBlockPosition, updateParentId, updateNodeDimensions, blocks] + [getNodes, collaborativeUpdateBlockPosition, updateParentId, updateNodeDimensions, blocks] ) // Function to resize all loop nodes with improved hierarchy handling @@ -256,13 +261,20 @@ const WorkflowContent = React.memo(() => { [detectedOrientation] ) - applyAutoLayoutSmooth(blocks, edges, updateBlockPosition, fitView, resizeLoopNodesWrapper, { - ...orientationConfig, - alignByLayer: true, - animationDuration: 500, // Smooth 500ms animation - isSidebarCollapsed, - handleOrientation: detectedOrientation, // Explicitly set the detected orientation - }) + applyAutoLayoutSmooth( + blocks, + edges, + collaborativeUpdateBlockPosition, + fitView, + resizeLoopNodesWrapper, + { + ...orientationConfig, + alignByLayer: true, + animationDuration: 500, // Smooth 500ms animation + isSidebarCollapsed, + handleOrientation: detectedOrientation, // Explicitly set the detected orientation + } + ) const orientationMessage = detectedOrientation === 'vertical' @@ -273,7 +285,14 @@ const WorkflowContent = React.memo(() => { orientation: detectedOrientation, blockCount: Object.keys(blocks).length, }) - }, [blocks, edges, updateBlockPosition, fitView, isSidebarCollapsed, resizeLoopNodesWrapper]) + }, [ + blocks, + edges, + collaborativeUpdateBlockPosition, + fitView, + isSidebarCollapsed, + resizeLoopNodesWrapper, + ]) const debouncedAutoLayout = useCallback(() => { const debounceTimer = setTimeout(() => { @@ -841,7 +860,7 @@ const WorkflowContent = React.memo(() => { return } - // Always call setActiveWorkflow when workflow ID changes to ensure proper state + // Get current active workflow state const { activeWorkflowId } = useWorkflowRegistry.getState() if (activeWorkflowId !== currentId) { @@ -955,18 +974,20 @@ const WorkflowContent = React.memo(() => { return nodeArray }, [blocks, activeBlockIds, pendingBlocks, isDebugModeEnabled, nestedSubflowErrors]) - // Update nodes + // Update nodes - use store version to avoid collaborative feedback loops const onNodesChange = useCallback( (changes: any) => { changes.forEach((change: any) => { if (change.type === 'position' && change.position) { const node = nodes.find((n) => n.id === change.id) if (!node) return - updateBlockPosition(change.id, change.position) + // Use store version to avoid collaborative feedback loop + // React Flow position changes can be triggered by collaborative updates + storeUpdateBlockPosition(change.id, change.position) } }) }, - [nodes, updateBlockPosition] + [nodes, storeUpdateBlockPosition] ) // Effect to resize loops when nodes change (add/remove/position change) @@ -1001,11 +1022,11 @@ const WorkflowContent = React.memo(() => { const absolutePosition = getNodeAbsolutePositionWrapper(id) // Update the node to remove parent reference and use absolute position - updateBlockPosition(id, absolutePosition) + collaborativeUpdateBlockPosition(id, absolutePosition) updateParentId(id, '', 'parent') } }) - }, [blocks, updateBlockPosition, updateParentId, getNodeAbsolutePositionWrapper]) + }, [blocks, collaborativeUpdateBlockPosition, updateParentId, getNodeAbsolutePositionWrapper]) // Validate nested subflows whenever blocks change useEffect(() => { @@ -1108,6 +1129,9 @@ const WorkflowContent = React.memo(() => { // Store currently dragged node ID setDraggedNodeId(node.id) + // Emit collaborative position update during drag for smooth real-time movement + collaborativeUpdateBlockPosition(node.id, node.position) + // Get the current parent ID of the node being dragged const currentParentId = blocks[node.id]?.data?.parentId || null @@ -1254,6 +1278,7 @@ const WorkflowContent = React.memo(() => { getNodeHierarchyWrapper, getNodeAbsolutePositionWrapper, getNodeDepthWrapper, + collaborativeUpdateBlockPosition, ] ) @@ -1276,7 +1301,11 @@ const WorkflowContent = React.memo(() => { }) document.body.style.cursor = '' - // Don't process if the node hasn't actually changed parent or is being moved within same parent + // Emit collaborative position update for the final position + // This ensures other users see the smooth final position + collaborativeUpdateBlockPosition(node.id, node.position) + + // Don't process parent changes if the node hasn't actually changed parent or is being moved within same parent if (potentialParentId === dragStartParentId) return // Check if this is a starter block - starter blocks should never be in containers @@ -1319,7 +1348,14 @@ const WorkflowContent = React.memo(() => { setDraggedNodeId(null) setPotentialParentId(null) }, - [getNodes, dragStartParentId, potentialParentId, updateNodeParent, getNodeHierarchyWrapper] + [ + getNodes, + dragStartParentId, + potentialParentId, + updateNodeParent, + getNodeHierarchyWrapper, + collaborativeUpdateBlockPosition, + ] ) // Update onPaneClick to only handle edge selection diff --git a/apps/sim/contexts/socket-context.tsx b/apps/sim/contexts/socket-context.tsx index ebbb6b47e..2453e25f8 100644 --- a/apps/sim/contexts/socket-context.tsx +++ b/apps/sim/contexts/socket-context.tsx @@ -323,8 +323,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { } }, [socket, currentWorkflowId]) - // Position update throttling at 60fps (16ms) - const THROTTLE_DELAY = 16 // 60fps standard + // Light throttling for position updates to ensure smooth collaborative movement const positionUpdateTimeouts = useRef>(new Map()) const pendingPositionUpdates = useRef>(new Map()) @@ -333,13 +332,13 @@ export function SocketProvider({ children, user }: SocketProviderProps) { (operation: string, target: string, payload: any) => { if (!socket || !currentWorkflowId) return - // Check if this is a position update that should be throttled + // Apply light throttling only to position updates for smooth collaborative experience const isPositionUpdate = operation === 'update-position' && target === 'block' if (isPositionUpdate && payload.id) { const blockId = payload.id - // Store the latest position update for this block + // Store the latest position update pendingPositionUpdates.current.set(blockId, { operation, target, @@ -347,33 +346,19 @@ export function SocketProvider({ children, user }: SocketProviderProps) { timestamp: Date.now(), }) - // Check if we have an active interval for this block - const existingTimeout = positionUpdateTimeouts.current.get(blockId) - - if (!existingTimeout) { - // No active interval - start emitting at regular intervals - const intervalId = window.setInterval(() => { + // Check if we already have a pending timeout for this block + if (!positionUpdateTimeouts.current.has(blockId)) { + // Schedule emission with light throttling (120fps = ~8ms) + const timeoutId = window.setTimeout(() => { const latestUpdate = pendingPositionUpdates.current.get(blockId) if (latestUpdate) { socket.emit('workflow-operation', latestUpdate) pendingPositionUpdates.current.delete(blockId) - } else { - // No more updates pending - stop the interval - clearInterval(intervalId) - positionUpdateTimeouts.current.delete(blockId) } - }, THROTTLE_DELAY) + positionUpdateTimeouts.current.delete(blockId) + }, 8) // 120fps for smooth movement - positionUpdateTimeouts.current.set(blockId, intervalId) - - // Set a cleanup timeout to stop the interval if no updates come in - setTimeout(() => { - if (positionUpdateTimeouts.current.get(blockId) === intervalId) { - clearInterval(intervalId) - positionUpdateTimeouts.current.delete(blockId) - pendingPositionUpdates.current.delete(blockId) - } - }, 50) // Stop interval after 50ms of no updates + positionUpdateTimeouts.current.set(blockId, timeoutId) } } else { // For all non-position updates, emit immediately @@ -411,14 +396,14 @@ export function SocketProvider({ children, user }: SocketProviderProps) { [socket, currentWorkflowId] ) - // Throttled cursor updates (lower priority than position updates) + // Minimal cursor throttling (reduced from 30fps to 120fps) const lastCursorEmit = useRef(0) const emitCursorUpdate = useCallback( (cursor: { x: number; y: number }) => { if (socket && currentWorkflowId) { const now = performance.now() - // Throttle cursor updates to 30fps to reduce noise - if (now - lastCursorEmit.current >= 33) { + // Very light throttling at 120fps (8ms) to prevent excessive spam + if (now - lastCursorEmit.current >= 8) { socket.emit('cursor-update', { cursor }) lastCursorEmit.current = now }