Skip to content

Commit 1e44d64

Browse files
waleedlatif1claude
andcommitted
fix(blocks): remap condition/router IDs when duplicating blocks
Condition and router blocks embed IDs in the format `{blockId}-{suffix}` inside their subBlock values and edge sourceHandles. When blocks were duplicated, these IDs were not updated to reference the new block ID, causing duplicate handle IDs and broken edge routing. Fixes all four duplication paths: single block duplicate, copy/paste, workflow duplication (server-side), and workflow import. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 68d207d commit 1e44d64

File tree

4 files changed

+205
-26
lines changed

4 files changed

+205
-26
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { EDGE } from '@/executor/constants'
2+
3+
/**
4+
* Remaps condition/router block IDs in a parsed conditions array.
5+
* Condition IDs use the format `{blockId}-{suffix}` and must be updated
6+
* when a block is duplicated to reference the new block ID.
7+
*
8+
* @param conditions - Parsed array of condition block objects with `id` fields
9+
* @param oldBlockId - The original block ID prefix to replace
10+
* @param newBlockId - The new block ID prefix
11+
* @returns Whether any IDs were changed (mutates in place)
12+
*/
13+
export function remapConditionBlockIds(
14+
conditions: Array<{ id: string; [key: string]: unknown }>,
15+
oldBlockId: string,
16+
newBlockId: string
17+
): boolean {
18+
let changed = false
19+
const prefix = `${oldBlockId}-`
20+
for (const condition of conditions) {
21+
if (typeof condition.id === 'string' && condition.id.startsWith(prefix)) {
22+
const suffix = condition.id.slice(oldBlockId.length)
23+
condition.id = `${newBlockId}${suffix}`
24+
changed = true
25+
}
26+
}
27+
return changed
28+
}
29+
30+
/** Handle prefixes that embed block-scoped condition/route IDs */
31+
const HANDLE_PREFIXES = [EDGE.CONDITION_PREFIX, EDGE.ROUTER_PREFIX] as const
32+
33+
/**
34+
* Remaps a condition or router edge sourceHandle from the old block ID to the new one.
35+
* Handle formats:
36+
* - Condition: `condition-{blockId}-{suffix}`
37+
* - Router V2: `router-{blockId}-{suffix}`
38+
*
39+
* @returns The remapped handle string, or the original if no remapping needed
40+
*/
41+
export function remapConditionEdgeHandle(
42+
sourceHandle: string,
43+
oldBlockId: string,
44+
newBlockId: string
45+
): string {
46+
for (const handlePrefix of HANDLE_PREFIXES) {
47+
if (!sourceHandle.startsWith(handlePrefix)) continue
48+
49+
const innerId = sourceHandle.slice(handlePrefix.length)
50+
if (!innerId.startsWith(`${oldBlockId}-`)) continue
51+
52+
const suffix = innerId.slice(oldBlockId.length)
53+
return `${handlePrefix}${newBlockId}${suffix}`
54+
}
55+
56+
return sourceHandle
57+
}

apps/sim/lib/workflows/persistence/duplicate.ts

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from '@sim/db/schema'
99
import { createLogger } from '@sim/logger'
1010
import { and, eq, isNull, min } from 'drizzle-orm'
11+
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
1112
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
1213
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1314
import type { Variable } from '@/stores/panel/variables/types'
@@ -77,6 +78,40 @@ function remapVariableIdsInSubBlocks(
7778
return updated
7879
}
7980

81+
/**
82+
* Remaps condition/router block IDs within subBlocks when a block is duplicated.
83+
* Returns a new object without mutating the input.
84+
*/
85+
function remapConditionIdsInSubBlocks(
86+
subBlocks: Record<string, any>,
87+
oldBlockId: string,
88+
newBlockId: string
89+
): Record<string, any> {
90+
const updated: Record<string, any> = {}
91+
92+
for (const [key, subBlock] of Object.entries(subBlocks)) {
93+
if (
94+
subBlock &&
95+
typeof subBlock === 'object' &&
96+
(subBlock.type === 'condition-input' || subBlock.type === 'router-input') &&
97+
typeof subBlock.value === 'string'
98+
) {
99+
try {
100+
const parsed = JSON.parse(subBlock.value)
101+
if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldBlockId, newBlockId)) {
102+
updated[key] = { ...subBlock, value: JSON.stringify(parsed) }
103+
continue
104+
}
105+
} catch {
106+
// Not valid JSON, skip
107+
}
108+
}
109+
updated[key] = subBlock
110+
}
111+
112+
return updated
113+
}
114+
80115
/**
81116
* Duplicate a workflow with all its blocks, edges, and subflows
82117
* This is a shared helper used by both the workflow duplicate API and folder duplicate API
@@ -259,6 +294,15 @@ export async function duplicateWorkflow(
259294
)
260295
}
261296

297+
// Remap condition/router IDs to use the new block ID
298+
if (updatedSubBlocks && typeof updatedSubBlocks === 'object') {
299+
updatedSubBlocks = remapConditionIdsInSubBlocks(
300+
updatedSubBlocks as Record<string, any>,
301+
block.id,
302+
newBlockId
303+
)
304+
}
305+
262306
return {
263307
...block,
264308
id: newBlockId,
@@ -286,15 +330,24 @@ export async function duplicateWorkflow(
286330
.where(eq(workflowEdges.workflowId, sourceWorkflowId))
287331

288332
if (sourceEdges.length > 0) {
289-
const newEdges = sourceEdges.map((edge) => ({
290-
...edge,
291-
id: crypto.randomUUID(), // Generate new edge ID
292-
workflowId: newWorkflowId,
293-
sourceBlockId: blockIdMapping.get(edge.sourceBlockId) || edge.sourceBlockId,
294-
targetBlockId: blockIdMapping.get(edge.targetBlockId) || edge.targetBlockId,
295-
createdAt: now,
296-
updatedAt: now,
297-
}))
333+
const newEdges = sourceEdges.map((edge) => {
334+
const newSourceBlockId = blockIdMapping.get(edge.sourceBlockId) || edge.sourceBlockId
335+
const newSourceHandle =
336+
edge.sourceHandle && blockIdMapping.has(edge.sourceBlockId)
337+
? remapConditionEdgeHandle(edge.sourceHandle, edge.sourceBlockId, newSourceBlockId)
338+
: edge.sourceHandle
339+
340+
return {
341+
...edge,
342+
id: crypto.randomUUID(),
343+
workflowId: newWorkflowId,
344+
sourceBlockId: newSourceBlockId,
345+
targetBlockId: blockIdMapping.get(edge.targetBlockId) || edge.targetBlockId,
346+
sourceHandle: newSourceHandle,
347+
createdAt: now,
348+
updatedAt: now,
349+
}
350+
})
298351

299352
await tx.insert(workflowEdges).values(newEdges)
300353
logger.info(`[${requestId}] Copied ${sourceEdges.length} edges with updated block references`)

apps/sim/stores/workflows/utils.ts

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Edge } from 'reactflow'
22
import { v4 as uuidv4 } from 'uuid'
33
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
44
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
5+
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
56
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
67
import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility'
78
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
@@ -363,13 +364,15 @@ export function regenerateWorkflowIds(
363364
const nameMap = new Map<string, string>()
364365
const newBlocks: Record<string, BlockState> = {}
365366

366-
// First pass: generate new IDs
367+
// First pass: generate new IDs and remap condition/router IDs in subBlocks
367368
Object.entries(workflowState.blocks).forEach(([oldId, block]) => {
368369
const newId = uuidv4()
369370
blockIdMap.set(oldId, newId)
370371
const oldNormalizedName = normalizeName(block.name)
371372
nameMap.set(oldNormalizedName, oldNormalizedName)
372-
newBlocks[newId] = { ...block, id: newId }
373+
const newBlock = { ...block, id: newId }
374+
remapConditionIds(newBlock.subBlocks, {}, oldId, newId)
375+
newBlocks[newId] = newBlock
373376
})
374377

375378
// Second pass: update parentId references
@@ -385,12 +388,21 @@ export function regenerateWorkflowIds(
385388
}
386389
})
387390

388-
const newEdges = workflowState.edges.map((edge) => ({
389-
...edge,
390-
id: uuidv4(),
391-
source: blockIdMap.get(edge.source) || edge.source,
392-
target: blockIdMap.get(edge.target) || edge.target,
393-
}))
391+
const newEdges = workflowState.edges.map((edge) => {
392+
const newSource = blockIdMap.get(edge.source) || edge.source
393+
const newSourceHandle =
394+
edge.sourceHandle && blockIdMap.has(edge.source)
395+
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
396+
: edge.sourceHandle
397+
398+
return {
399+
...edge,
400+
id: uuidv4(),
401+
source: newSource,
402+
target: blockIdMap.get(edge.target) || edge.target,
403+
sourceHandle: newSourceHandle,
404+
}
405+
})
394406

395407
const newLoops: Record<string, Loop> = {}
396408
if (workflowState.loops) {
@@ -429,6 +441,37 @@ export function regenerateWorkflowIds(
429441
}
430442
}
431443

444+
/**
445+
* Remaps condition/router block IDs within subBlock values when a block is duplicated.
446+
* Mutates both `subBlocks` and `subBlockValues` in place (callers must pass cloned data).
447+
*/
448+
export function remapConditionIds(
449+
subBlocks: Record<string, SubBlockState>,
450+
subBlockValues: Record<string, unknown>,
451+
oldBlockId: string,
452+
newBlockId: string
453+
): void {
454+
for (const [subBlockId, subBlock] of Object.entries(subBlocks)) {
455+
if (subBlock.type !== 'condition-input' && subBlock.type !== 'router-input') continue
456+
457+
const value = subBlockValues[subBlockId] ?? subBlock.value
458+
if (typeof value !== 'string') continue
459+
460+
try {
461+
const parsed = JSON.parse(value)
462+
if (!Array.isArray(parsed)) continue
463+
464+
if (remapConditionBlockIds(parsed, oldBlockId, newBlockId)) {
465+
const newValue = JSON.stringify(parsed)
466+
subBlock.value = newValue
467+
subBlockValues[subBlockId] = newValue
468+
}
469+
} catch {
470+
// Not valid JSON, skip
471+
}
472+
}
473+
}
474+
432475
export function regenerateBlockIds(
433476
blocks: Record<string, BlockState>,
434477
edges: Edge[],
@@ -510,6 +553,9 @@ export function regenerateBlockIds(
510553
if (subBlockValues[oldId]) {
511554
newSubBlockValues[newId] = JSON.parse(JSON.stringify(subBlockValues[oldId]))
512555
}
556+
557+
// Remap condition/router IDs in the duplicated block
558+
remapConditionIds(newBlock.subBlocks, newSubBlockValues[newId] || {}, oldId, newId)
513559
})
514560

515561
// Second pass: update parentId references for nested blocks
@@ -542,12 +588,21 @@ export function regenerateBlockIds(
542588
}
543589
})
544590

545-
const newEdges = edges.map((edge) => ({
546-
...edge,
547-
id: uuidv4(),
548-
source: blockIdMap.get(edge.source) || edge.source,
549-
target: blockIdMap.get(edge.target) || edge.target,
550-
}))
591+
const newEdges = edges.map((edge) => {
592+
const newSource = blockIdMap.get(edge.source) || edge.source
593+
const newSourceHandle =
594+
edge.sourceHandle && blockIdMap.has(edge.source)
595+
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
596+
: edge.sourceHandle
597+
598+
return {
599+
...edge,
600+
id: uuidv4(),
601+
source: newSource,
602+
target: blockIdMap.get(edge.target) || edge.target,
603+
sourceHandle: newSourceHandle,
604+
}
605+
})
551606

552607
const newLoops: Record<string, Loop> = {}
553608
Object.entries(loops).forEach(([oldLoopId, loop]) => {

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
filterValidEdges,
1313
getUniqueBlockName,
1414
mergeSubblockState,
15+
remapConditionIds,
1516
} from '@/stores/workflows/utils'
1617
import type {
1718
Position,
@@ -611,6 +612,21 @@ export const useWorkflowStore = create<WorkflowStore>()(
611612
{}
612613
)
613614

615+
// Remap condition/router IDs in the duplicated subBlocks
616+
const clonedSubBlockValues = activeWorkflowId
617+
? JSON.parse(
618+
JSON.stringify(
619+
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}
620+
)
621+
)
622+
: {}
623+
remapConditionIds(
624+
newSubBlocks as Record<string, SubBlockState>,
625+
clonedSubBlockValues,
626+
id,
627+
newId
628+
)
629+
614630
const newState = {
615631
blocks: {
616632
...get().blocks,
@@ -630,14 +646,12 @@ export const useWorkflowStore = create<WorkflowStore>()(
630646
}
631647

632648
if (activeWorkflowId) {
633-
const subBlockValues =
634-
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}
635649
useSubBlockStore.setState((state) => ({
636650
workflowValues: {
637651
...state.workflowValues,
638652
[activeWorkflowId]: {
639653
...state.workflowValues[activeWorkflowId],
640-
[newId]: JSON.parse(JSON.stringify(subBlockValues)),
654+
[newId]: clonedSubBlockValues,
641655
},
642656
},
643657
}))

0 commit comments

Comments
 (0)