Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 56 additions & 20 deletions apps/sim/app/w/[id]/workflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -117,7 +122,7 @@ const WorkflowContent = React.memo(() => {
collaborativeAddBlock: addBlock,
collaborativeAddEdge: addEdge,
collaborativeRemoveEdge: removeEdge,
collaborativeUpdateBlockPosition: updateBlockPosition,
collaborativeUpdateBlockPosition,
collaborativeUpdateParentId: updateParentId,
isConnected,
currentWorkflowId,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -1254,6 +1278,7 @@ const WorkflowContent = React.memo(() => {
getNodeHierarchyWrapper,
getNodeAbsolutePositionWrapper,
getNodeDepthWrapper,
collaborativeUpdateBlockPosition,
]
)

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
41 changes: 13 additions & 28 deletions apps/sim/contexts/socket-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<string, number>>(new Map())
const pendingPositionUpdates = useRef<Map<string, any>>(new Map())

Expand All @@ -333,47 +332,33 @@ 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,
payload,
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.set(blockId, intervalId)
positionUpdateTimeouts.current.delete(blockId)
}, 8) // 120fps for smooth movement

// 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)
}
Comment on lines +349 to 362
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider using requestAnimationFrame instead of setTimeout for better performance and synchronization with browser render cycles

} else {
// For all non-position updates, emit immediately
Expand Down Expand Up @@ -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
}
Comment on lines +405 to 409
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Use a config constant for the throttle delay (8ms) to maintain consistency with other timing values

Expand Down