diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 22fe3c8ce..53d1395fc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -307,9 +307,8 @@ const WorkflowContent = React.memo(() => { const isAutoConnectEnabled = useAutoConnect() const autoConnectRef = useRef(isAutoConnectEnabled) - useEffect(() => { - autoConnectRef.current = isAutoConnectEnabled - }, [isAutoConnectEnabled]) + // Keep ref in sync with latest value for use in callbacks (no effect needed) + autoConnectRef.current = isAutoConnectEnabled // Panel open states for context menu const isVariablesOpen = useVariablesStore((state) => state.isOpen) @@ -448,11 +447,14 @@ const WorkflowContent = React.memo(() => { ) /** Re-applies diff markers when blocks change after socket rehydration. */ - const blocksRef = useRef(blocks) + const diffBlocksRef = useRef(blocks) useEffect(() => { - if (!isWorkflowReady) return - if (hasActiveDiff && isDiffReady && blocks !== blocksRef.current) { - blocksRef.current = blocks + // Track if blocks actually changed (vs other deps triggering this effect) + const blocksChanged = blocks !== diffBlocksRef.current + diffBlocksRef.current = blocks + + if (!isWorkflowReady || !blocksChanged) return + if (hasActiveDiff && isDiffReady) { setTimeout(() => reapplyDiffMarkers(), 0) } }, [blocks, hasActiveDiff, isDiffReady, reapplyDiffMarkers, isWorkflowReady]) @@ -2160,6 +2162,8 @@ const WorkflowContent = React.memo(() => { // Local state for nodes - allows smooth drag without store updates on every frame const [displayNodes, setDisplayNodes] = useState([]) + // Sync derivedNodes to displayNodes while preserving selection state + // This effect handles both normal sync and pending selection from paste/duplicate useEffect(() => { // Check for pending selection (from paste/duplicate), otherwise preserve existing selection if (pendingSelection && pendingSelection.length > 0) { @@ -2186,7 +2190,7 @@ const WorkflowContent = React.memo(() => { selected: selectedIds.has(node.id), })) }) - }, [derivedNodes, blocks, pendingSelection, clearPendingSelection]) + }, [derivedNodes, blocks, pendingSelection, clearPendingSelection, syncPanelWithSelection]) // Phase 2: When displayNodes updates, check if pending zoom blocks are ready // (Phase 1 is located earlier in the file where pendingZoomBlockIdsRef is defined) @@ -2380,40 +2384,6 @@ const WorkflowContent = React.memo(() => { resizeLoopNodesWrapper() }, [derivedNodes, resizeLoopNodesWrapper, isWorkflowReady]) - /** Cleans up orphaned nodes with invalid parent references after deletion. */ - useEffect(() => { - if (!isWorkflowReady) return - - // Create a mapping of node IDs to check for missing parent references - const nodeIds = new Set(Object.keys(blocks)) - - // Check for nodes with invalid parent references and collect updates - const orphanedUpdates: Array<{ - id: string - position: { x: number; y: number } - parentId: string - }> = [] - Object.entries(blocks).forEach(([id, block]) => { - const parentId = block.data?.parentId - - // If block has a parent reference but parent no longer exists - if (parentId && !nodeIds.has(parentId)) { - logger.warn('Found orphaned node with invalid parent reference', { - nodeId: id, - missingParentId: parentId, - }) - - const absolutePosition = getNodeAbsolutePosition(id) - orphanedUpdates.push({ id, position: absolutePosition, parentId: '' }) - } - }) - - // Batch update all orphaned nodes at once - if (orphanedUpdates.length > 0) { - batchUpdateBlocksWithParent(orphanedUpdates) - } - }, [blocks, batchUpdateBlocksWithParent, getNodeAbsolutePosition, isWorkflowReady]) - /** Handles edge removal changes. */ const onEdgesChange = useCallback( (changes: any) => { diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index 00eeac9b8..36445b226 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -448,6 +448,34 @@ export const useWorkflowStore = create()( delete newBlocks[blockId] }) + // Clean up orphaned nodes - blocks whose parent was removed but weren't descendants + // This can happen in edge cases (e.g., data inconsistency, external modifications) + const remainingBlockIds = new Set(Object.keys(newBlocks)) + Object.entries(newBlocks).forEach(([blockId, block]) => { + const parentId = block.data?.parentId + if (parentId && !remainingBlockIds.has(parentId)) { + // Parent was removed - convert to absolute position and clear parentId + // Calculate absolute position by traversing up the (now-deleted) parent chain + let absoluteX = block.position.x + let absoluteY = block.position.y + + // Try to get parent's position from original blocks before deletion + let currentParentId: string | undefined = parentId + while (currentParentId && currentBlocks[currentParentId]) { + const parent = currentBlocks[currentParentId] + absoluteX += parent.position.x + absoluteY += parent.position.y + currentParentId = parent.data?.parentId + } + + newBlocks[blockId] = { + ...block, + position: { x: absoluteX, y: absoluteY }, + data: { ...block.data, parentId: undefined }, + } + } + }) + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId if (activeWorkflowId) { const subBlockStore = useSubBlockStore.getState()