Skip to content

Commit fbef731

Browse files
committed
fix(graph): prevent cyclic dependencies in graph following ReactFlow examples
1 parent 21fa92b commit fbef731

File tree

3 files changed

+90
-1
lines changed

3 files changed

+90
-1
lines changed

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
5656
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
5757
import { getUniqueBlockName } from '@/stores/workflows/utils'
5858
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
59+
import { wouldCreateCycle } from '@/stores/workflows/workflow/utils'
5960

6061
/** Lazy-loaded components for non-critical UI that can load after initial render */
6162
const LazyChat = lazy(() =>
@@ -1638,6 +1639,31 @@ const WorkflowContent = React.memo(() => {
16381639
setIsErrorConnectionDrag(false)
16391640
}, [])
16401641

1642+
/**
1643+
* Validates if a connection is allowed before it's created.
1644+
* Prevents cycles and other invalid connections.
1645+
*/
1646+
const isValidConnection = useCallback(
1647+
(connection: { source: string | null; target: string | null }) => {
1648+
if (!connection.source || !connection.target) {
1649+
return false
1650+
}
1651+
1652+
// Prevent self-connections
1653+
if (connection.source === connection.target) {
1654+
return false
1655+
}
1656+
1657+
// Check if this connection would create a cycle
1658+
if (wouldCreateCycle(edges, connection.source, connection.target)) {
1659+
return false
1660+
}
1661+
1662+
return true
1663+
},
1664+
[edges]
1665+
)
1666+
16411667
/** Handles new edge connections with container boundary validation. */
16421668
const onConnect = useCallback(
16431669
(connection: any) => {
@@ -2249,6 +2275,7 @@ const WorkflowContent = React.memo(() => {
22492275
onConnect={effectivePermissions.canEdit ? onConnect : undefined}
22502276
onConnectStart={effectivePermissions.canEdit ? onConnectStart : undefined}
22512277
onConnectEnd={effectivePermissions.canEdit ? onConnectEnd : undefined}
2278+
isValidConnection={effectivePermissions.canEdit ? isValidConnection : undefined}
22522279
nodeTypes={nodeTypes}
22532280
edgeTypes={edgeTypes}
22542281
onDrop={effectivePermissions.canEdit ? onDrop : undefined}

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)