Skip to content

Commit 6bcbd15

Browse files
waleedlatif1claude
andauthored
fix(blocks): remap condition/router IDs when duplicating blocks (#3533)
* 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> * fix(blocks): deep-clone subBlocks before mutating condition IDs Shallow copy of subBlocks meant remapConditionIds could mutate the source data (clipboard on repeated paste, or input workflowState on import). Deep-clone subBlocks in both regenerateBlockIds and regenerateWorkflowIds to prevent this. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(blocks): remap condition IDs in regenerateWorkflowStateIds (template use) The template use code path was missing condition/router ID remapping, causing broken condition blocks when creating workflows from templates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 68d207d commit 6bcbd15

File tree

5 files changed

+233
-27
lines changed

5 files changed

+233
-27
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/lib/workflows/persistence/utils.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { and, desc, eq, inArray, sql } from 'drizzle-orm'
1414
import type { Edge } from 'reactflow'
1515
import { v4 as uuidv4 } from 'uuid'
1616
import type { DbOrTx } from '@/lib/db/types'
17+
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
1718
import {
1819
backfillCanonicalModes,
1920
migrateSubblockIds,
@@ -833,7 +834,12 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
833834
Object.entries(state.blocks || {}).forEach(([oldId, block]) => {
834835
const newId = blockIdMapping.get(oldId)!
835836
// Duplicated blocks are always unlocked so users can edit them
836-
const newBlock: BlockState = { ...block, id: newId, locked: false }
837+
const newBlock: BlockState = {
838+
...block,
839+
id: newId,
840+
subBlocks: JSON.parse(JSON.stringify(block.subBlocks)),
841+
locked: false,
842+
}
837843

838844
// Update parentId reference if it exists
839845
if (newBlock.data?.parentId) {
@@ -857,6 +863,21 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
857863
updatedSubBlock.value = blockIdMapping.get(updatedSubBlock.value) ?? updatedSubBlock.value
858864
}
859865

866+
// Remap condition/router IDs embedded in condition-input/router-input subBlocks
867+
if (
868+
(updatedSubBlock.type === 'condition-input' || updatedSubBlock.type === 'router-input') &&
869+
typeof updatedSubBlock.value === 'string'
870+
) {
871+
try {
872+
const parsed = JSON.parse(updatedSubBlock.value)
873+
if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldId, newId)) {
874+
updatedSubBlock.value = JSON.stringify(parsed)
875+
}
876+
} catch {
877+
// Not valid JSON, skip
878+
}
879+
}
880+
860881
updatedSubBlocks[subId] = updatedSubBlock
861882
})
862883
newBlock.subBlocks = updatedSubBlocks
@@ -871,12 +892,17 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
871892
const newId = edgeIdMapping.get(edge.id)!
872893
const newSource = blockIdMapping.get(edge.source) || edge.source
873894
const newTarget = blockIdMapping.get(edge.target) || edge.target
895+
const newSourceHandle =
896+
edge.sourceHandle && blockIdMapping.has(edge.source)
897+
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
898+
: edge.sourceHandle
874899

875900
newEdges.push({
876901
...edge,
877902
id: newId,
878903
source: newSource,
879904
target: newTarget,
905+
sourceHandle: newSourceHandle,
880906
})
881907
})
882908

apps/sim/stores/workflows/utils.ts

Lines changed: 70 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, subBlocks: JSON.parse(JSON.stringify(block.subBlocks)) }
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[],
@@ -497,6 +540,7 @@ export function regenerateBlockIds(
497540
id: newId,
498541
name: newName,
499542
position: newPosition,
543+
subBlocks: JSON.parse(JSON.stringify(block.subBlocks)),
500544
// Temporarily keep data as-is, we'll fix parentId in second pass
501545
data: block.data ? { ...block.data } : block.data,
502546
// Duplicated blocks are always unlocked so users can edit them
@@ -510,6 +554,9 @@ export function regenerateBlockIds(
510554
if (subBlockValues[oldId]) {
511555
newSubBlockValues[newId] = JSON.parse(JSON.stringify(subBlockValues[oldId]))
512556
}
557+
558+
// Remap condition/router IDs in the duplicated block
559+
remapConditionIds(newBlock.subBlocks, newSubBlockValues[newId] || {}, oldId, newId)
513560
})
514561

515562
// Second pass: update parentId references for nested blocks
@@ -542,12 +589,21 @@ export function regenerateBlockIds(
542589
}
543590
})
544591

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-
}))
592+
const newEdges = edges.map((edge) => {
593+
const newSource = blockIdMap.get(edge.source) || edge.source
594+
const newSourceHandle =
595+
edge.sourceHandle && blockIdMap.has(edge.source)
596+
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
597+
: edge.sourceHandle
598+
599+
return {
600+
...edge,
601+
id: uuidv4(),
602+
source: newSource,
603+
target: blockIdMap.get(edge.target) || edge.target,
604+
sourceHandle: newSourceHandle,
605+
}
606+
})
551607

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

0 commit comments

Comments
 (0)