Skip to content

Commit 5516fa3

Browse files
authored
fix(graph): prevent cyclic dependencies in graph following ReactFlow examples (#2439)
* fix(graph): prevent cyclic dependencies in graph following ReactFlow examples * ack PR comment
1 parent 21fa92b commit 5516fa3

File tree

4 files changed

+90
-11
lines changed

4 files changed

+90
-11
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ import { useSelectorDisplayName } from '@/hooks/use-selector-display-name'
4040
import { useVariablesStore } from '@/stores/panel/variables/store'
4141
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
4242
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
43+
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
44+
import { wouldCreateCycle } from '@/stores/workflows/workflow/utils'
4345

4446
const logger = createLogger('WorkflowBlock')
4547

@@ -844,7 +846,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
844846
data-handleid='target'
845847
isConnectableStart={false}
846848
isConnectableEnd={true}
847-
isValidConnection={(connection) => connection.source !== id}
849+
isValidConnection={(connection) => {
850+
if (connection.source === id) return false
851+
const edges = useWorkflowStore.getState().edges
852+
return !wouldCreateCycle(edges, connection.source!, connection.target!)
853+
}}
848854
/>
849855
)}
850856

@@ -1045,7 +1051,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
10451051
data-handleid={`condition-${cond.id}`}
10461052
isConnectableStart={true}
10471053
isConnectableEnd={false}
1048-
isValidConnection={(connection) => connection.target !== id}
1054+
isValidConnection={(connection) => {
1055+
if (connection.target === id) return false
1056+
const edges = useWorkflowStore.getState().edges
1057+
return !wouldCreateCycle(edges, connection.source!, connection.target!)
1058+
}}
10491059
/>
10501060
)
10511061
})}
@@ -1064,7 +1074,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
10641074
data-handleid='error'
10651075
isConnectableStart={true}
10661076
isConnectableEnd={false}
1067-
isValidConnection={(connection) => connection.target !== id}
1077+
isValidConnection={(connection) => {
1078+
if (connection.target === id) return false
1079+
const edges = useWorkflowStore.getState().edges
1080+
return !wouldCreateCycle(edges, connection.source!, connection.target!)
1081+
}}
10681082
/>
10691083
</>
10701084
)}
@@ -1081,7 +1095,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
10811095
data-handleid='source'
10821096
isConnectableStart={true}
10831097
isConnectableEnd={false}
1084-
isValidConnection={(connection) => connection.target !== id}
1098+
isValidConnection={(connection) => {
1099+
if (connection.target === id) return false
1100+
const edges = useWorkflowStore.getState().edges
1101+
return !wouldCreateCycle(edges, connection.source!, connection.target!)
1102+
}}
10851103
/>
10861104

10871105
{shouldShowDefaultHandles && (
@@ -1100,7 +1118,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
11001118
data-handleid='error'
11011119
isConnectableStart={true}
11021120
isConnectableEnd={false}
1103-
isValidConnection={(connection) => connection.target !== id}
1121+
isValidConnection={(connection) => {
1122+
if (connection.target === id) return false
1123+
const edges = useWorkflowStore.getState().edges
1124+
return !wouldCreateCycle(edges, connection.source!, connection.target!)
1125+
}}
11041126
/>
11051127
)}
11061128
</>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1642,11 +1642,6 @@ const WorkflowContent = React.memo(() => {
16421642
const onConnect = useCallback(
16431643
(connection: any) => {
16441644
if (connection.source && connection.target) {
1645-
// Prevent self-connections
1646-
if (connection.source === connection.target) {
1647-
return
1648-
}
1649-
16501645
// Check if connecting nodes across container boundaries
16511646
const sourceNode = getNodes().find((n) => n.id === connection.source)
16521647
const targetNode = getNodes().find((n) => n.id === connection.target)

apps/sim/stores/workflows/workflow/store.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ import type {
2020
WorkflowState,
2121
WorkflowStore,
2222
} from '@/stores/workflows/workflow/types'
23-
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
23+
import {
24+
generateLoopBlocks,
25+
generateParallelBlocks,
26+
wouldCreateCycle,
27+
} from '@/stores/workflows/workflow/utils'
2428

2529
const logger = createLogger('WorkflowStore')
2630

@@ -428,6 +432,15 @@ export const useWorkflowStore = create<WorkflowStore>()(
428432
return
429433
}
430434

435+
// Prevent self-connections and cycles
436+
if (wouldCreateCycle(get().edges, edge.source, edge.target)) {
437+
logger.warn('Prevented edge that would create a cycle', {
438+
source: edge.source,
439+
target: edge.target,
440+
})
441+
return
442+
}
443+
431444
// Check for duplicate connections
432445
const isDuplicate = get().edges.some(
433446
(existingEdge) =>

apps/sim/stores/workflows/workflow/utils.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,56 @@
1+
import type { Edge } from 'reactflow'
12
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
23

34
const DEFAULT_LOOP_ITERATIONS = 5
45

6+
/**
7+
* Check if adding an edge would create a cycle in the graph.
8+
* Uses depth-first search to detect if the source node is reachable from the target node.
9+
*
10+
* @param edges - Current edges in the graph
11+
* @param sourceId - Source node ID of the proposed edge
12+
* @param targetId - Target node ID of the proposed edge
13+
* @returns true if adding this edge would create a cycle
14+
*/
15+
export function wouldCreateCycle(edges: Edge[], sourceId: string, targetId: string): boolean {
16+
if (sourceId === targetId) {
17+
return true
18+
}
19+
20+
const adjacencyList = new Map<string, string[]>()
21+
for (const edge of edges) {
22+
if (!adjacencyList.has(edge.source)) {
23+
adjacencyList.set(edge.source, [])
24+
}
25+
adjacencyList.get(edge.source)!.push(edge.target)
26+
}
27+
28+
const visited = new Set<string>()
29+
30+
function canReachSource(currentNode: string): boolean {
31+
if (currentNode === sourceId) {
32+
return true
33+
}
34+
35+
if (visited.has(currentNode)) {
36+
return false
37+
}
38+
39+
visited.add(currentNode)
40+
41+
const neighbors = adjacencyList.get(currentNode) || []
42+
for (const neighbor of neighbors) {
43+
if (canReachSource(neighbor)) {
44+
return true
45+
}
46+
}
47+
48+
return false
49+
}
50+
51+
return canReachSource(targetId)
52+
}
53+
554
/**
655
* Convert UI loop block to executor Loop format
756
*

0 commit comments

Comments
 (0)