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>
This commit is contained in:
Waleed
2026-03-12 01:19:38 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 68d207df94
commit 6bcbd15ee6
5 changed files with 233 additions and 27 deletions
+57
View File
@@ -0,0 +1,57 @@
import { EDGE } from '@/executor/constants'
/**
* Remaps condition/router block IDs in a parsed conditions array.
* Condition IDs use the format `{blockId}-{suffix}` and must be updated
* when a block is duplicated to reference the new block ID.
*
* @param conditions - Parsed array of condition block objects with `id` fields
* @param oldBlockId - The original block ID prefix to replace
* @param newBlockId - The new block ID prefix
* @returns Whether any IDs were changed (mutates in place)
*/
export function remapConditionBlockIds(
conditions: Array<{ id: string; [key: string]: unknown }>,
oldBlockId: string,
newBlockId: string
): boolean {
let changed = false
const prefix = `${oldBlockId}-`
for (const condition of conditions) {
if (typeof condition.id === 'string' && condition.id.startsWith(prefix)) {
const suffix = condition.id.slice(oldBlockId.length)
condition.id = `${newBlockId}${suffix}`
changed = true
}
}
return changed
}
/** Handle prefixes that embed block-scoped condition/route IDs */
const HANDLE_PREFIXES = [EDGE.CONDITION_PREFIX, EDGE.ROUTER_PREFIX] as const
/**
* Remaps a condition or router edge sourceHandle from the old block ID to the new one.
* Handle formats:
* - Condition: `condition-{blockId}-{suffix}`
* - Router V2: `router-{blockId}-{suffix}`
*
* @returns The remapped handle string, or the original if no remapping needed
*/
export function remapConditionEdgeHandle(
sourceHandle: string,
oldBlockId: string,
newBlockId: string
): string {
for (const handlePrefix of HANDLE_PREFIXES) {
if (!sourceHandle.startsWith(handlePrefix)) continue
const innerId = sourceHandle.slice(handlePrefix.length)
if (!innerId.startsWith(`${oldBlockId}-`)) continue
const suffix = innerId.slice(oldBlockId.length)
return `${handlePrefix}${newBlockId}${suffix}`
}
return sourceHandle
}
@@ -8,6 +8,7 @@ import {
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, min } from 'drizzle-orm'
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import type { Variable } from '@/stores/panel/variables/types'
@@ -77,6 +78,40 @@ function remapVariableIdsInSubBlocks(
return updated
}
/**
* Remaps condition/router block IDs within subBlocks when a block is duplicated.
* Returns a new object without mutating the input.
*/
function remapConditionIdsInSubBlocks(
subBlocks: Record<string, any>,
oldBlockId: string,
newBlockId: string
): Record<string, any> {
const updated: Record<string, any> = {}
for (const [key, subBlock] of Object.entries(subBlocks)) {
if (
subBlock &&
typeof subBlock === 'object' &&
(subBlock.type === 'condition-input' || subBlock.type === 'router-input') &&
typeof subBlock.value === 'string'
) {
try {
const parsed = JSON.parse(subBlock.value)
if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldBlockId, newBlockId)) {
updated[key] = { ...subBlock, value: JSON.stringify(parsed) }
continue
}
} catch {
// Not valid JSON, skip
}
}
updated[key] = subBlock
}
return updated
}
/**
* Duplicate a workflow with all its blocks, edges, and subflows
* This is a shared helper used by both the workflow duplicate API and folder duplicate API
@@ -259,6 +294,15 @@ export async function duplicateWorkflow(
)
}
// Remap condition/router IDs to use the new block ID
if (updatedSubBlocks && typeof updatedSubBlocks === 'object') {
updatedSubBlocks = remapConditionIdsInSubBlocks(
updatedSubBlocks as Record<string, any>,
block.id,
newBlockId
)
}
return {
...block,
id: newBlockId,
@@ -286,15 +330,24 @@ export async function duplicateWorkflow(
.where(eq(workflowEdges.workflowId, sourceWorkflowId))
if (sourceEdges.length > 0) {
const newEdges = sourceEdges.map((edge) => ({
...edge,
id: crypto.randomUUID(), // Generate new edge ID
workflowId: newWorkflowId,
sourceBlockId: blockIdMapping.get(edge.sourceBlockId) || edge.sourceBlockId,
targetBlockId: blockIdMapping.get(edge.targetBlockId) || edge.targetBlockId,
createdAt: now,
updatedAt: now,
}))
const newEdges = sourceEdges.map((edge) => {
const newSourceBlockId = blockIdMapping.get(edge.sourceBlockId) || edge.sourceBlockId
const newSourceHandle =
edge.sourceHandle && blockIdMapping.has(edge.sourceBlockId)
? remapConditionEdgeHandle(edge.sourceHandle, edge.sourceBlockId, newSourceBlockId)
: edge.sourceHandle
return {
...edge,
id: crypto.randomUUID(),
workflowId: newWorkflowId,
sourceBlockId: newSourceBlockId,
targetBlockId: blockIdMapping.get(edge.targetBlockId) || edge.targetBlockId,
sourceHandle: newSourceHandle,
createdAt: now,
updatedAt: now,
}
})
await tx.insert(workflowEdges).values(newEdges)
logger.info(`[${requestId}] Copied ${sourceEdges.length} edges with updated block references`)
+27 -1
View File
@@ -14,6 +14,7 @@ import { and, desc, eq, inArray, sql } from 'drizzle-orm'
import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
import type { DbOrTx } from '@/lib/db/types'
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
import {
backfillCanonicalModes,
migrateSubblockIds,
@@ -833,7 +834,12 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
Object.entries(state.blocks || {}).forEach(([oldId, block]) => {
const newId = blockIdMapping.get(oldId)!
// Duplicated blocks are always unlocked so users can edit them
const newBlock: BlockState = { ...block, id: newId, locked: false }
const newBlock: BlockState = {
...block,
id: newId,
subBlocks: JSON.parse(JSON.stringify(block.subBlocks)),
locked: false,
}
// Update parentId reference if it exists
if (newBlock.data?.parentId) {
@@ -857,6 +863,21 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
updatedSubBlock.value = blockIdMapping.get(updatedSubBlock.value) ?? updatedSubBlock.value
}
// Remap condition/router IDs embedded in condition-input/router-input subBlocks
if (
(updatedSubBlock.type === 'condition-input' || updatedSubBlock.type === 'router-input') &&
typeof updatedSubBlock.value === 'string'
) {
try {
const parsed = JSON.parse(updatedSubBlock.value)
if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldId, newId)) {
updatedSubBlock.value = JSON.stringify(parsed)
}
} catch {
// Not valid JSON, skip
}
}
updatedSubBlocks[subId] = updatedSubBlock
})
newBlock.subBlocks = updatedSubBlocks
@@ -871,12 +892,17 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
const newId = edgeIdMapping.get(edge.id)!
const newSource = blockIdMapping.get(edge.source) || edge.source
const newTarget = blockIdMapping.get(edge.target) || edge.target
const newSourceHandle =
edge.sourceHandle && blockIdMapping.has(edge.source)
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
: edge.sourceHandle
newEdges.push({
...edge,
id: newId,
source: newSource,
target: newTarget,
sourceHandle: newSourceHandle,
})
})
+70 -14
View File
@@ -2,6 +2,7 @@ import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
@@ -363,13 +364,15 @@ export function regenerateWorkflowIds(
const nameMap = new Map<string, string>()
const newBlocks: Record<string, BlockState> = {}
// First pass: generate new IDs
// First pass: generate new IDs and remap condition/router IDs in subBlocks
Object.entries(workflowState.blocks).forEach(([oldId, block]) => {
const newId = uuidv4()
blockIdMap.set(oldId, newId)
const oldNormalizedName = normalizeName(block.name)
nameMap.set(oldNormalizedName, oldNormalizedName)
newBlocks[newId] = { ...block, id: newId }
const newBlock = { ...block, id: newId, subBlocks: JSON.parse(JSON.stringify(block.subBlocks)) }
remapConditionIds(newBlock.subBlocks, {}, oldId, newId)
newBlocks[newId] = newBlock
})
// Second pass: update parentId references
@@ -385,12 +388,21 @@ export function regenerateWorkflowIds(
}
})
const newEdges = workflowState.edges.map((edge) => ({
...edge,
id: uuidv4(),
source: blockIdMap.get(edge.source) || edge.source,
target: blockIdMap.get(edge.target) || edge.target,
}))
const newEdges = workflowState.edges.map((edge) => {
const newSource = blockIdMap.get(edge.source) || edge.source
const newSourceHandle =
edge.sourceHandle && blockIdMap.has(edge.source)
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
: edge.sourceHandle
return {
...edge,
id: uuidv4(),
source: newSource,
target: blockIdMap.get(edge.target) || edge.target,
sourceHandle: newSourceHandle,
}
})
const newLoops: Record<string, Loop> = {}
if (workflowState.loops) {
@@ -429,6 +441,37 @@ export function regenerateWorkflowIds(
}
}
/**
* Remaps condition/router block IDs within subBlock values when a block is duplicated.
* Mutates both `subBlocks` and `subBlockValues` in place (callers must pass cloned data).
*/
export function remapConditionIds(
subBlocks: Record<string, SubBlockState>,
subBlockValues: Record<string, unknown>,
oldBlockId: string,
newBlockId: string
): void {
for (const [subBlockId, subBlock] of Object.entries(subBlocks)) {
if (subBlock.type !== 'condition-input' && subBlock.type !== 'router-input') continue
const value = subBlockValues[subBlockId] ?? subBlock.value
if (typeof value !== 'string') continue
try {
const parsed = JSON.parse(value)
if (!Array.isArray(parsed)) continue
if (remapConditionBlockIds(parsed, oldBlockId, newBlockId)) {
const newValue = JSON.stringify(parsed)
subBlock.value = newValue
subBlockValues[subBlockId] = newValue
}
} catch {
// Not valid JSON, skip
}
}
}
export function regenerateBlockIds(
blocks: Record<string, BlockState>,
edges: Edge[],
@@ -497,6 +540,7 @@ export function regenerateBlockIds(
id: newId,
name: newName,
position: newPosition,
subBlocks: JSON.parse(JSON.stringify(block.subBlocks)),
// Temporarily keep data as-is, we'll fix parentId in second pass
data: block.data ? { ...block.data } : block.data,
// Duplicated blocks are always unlocked so users can edit them
@@ -510,6 +554,9 @@ export function regenerateBlockIds(
if (subBlockValues[oldId]) {
newSubBlockValues[newId] = JSON.parse(JSON.stringify(subBlockValues[oldId]))
}
// Remap condition/router IDs in the duplicated block
remapConditionIds(newBlock.subBlocks, newSubBlockValues[newId] || {}, oldId, newId)
})
// Second pass: update parentId references for nested blocks
@@ -542,12 +589,21 @@ export function regenerateBlockIds(
}
})
const newEdges = edges.map((edge) => ({
...edge,
id: uuidv4(),
source: blockIdMap.get(edge.source) || edge.source,
target: blockIdMap.get(edge.target) || edge.target,
}))
const newEdges = edges.map((edge) => {
const newSource = blockIdMap.get(edge.source) || edge.source
const newSourceHandle =
edge.sourceHandle && blockIdMap.has(edge.source)
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
: edge.sourceHandle
return {
...edge,
id: uuidv4(),
source: newSource,
target: blockIdMap.get(edge.target) || edge.target,
sourceHandle: newSourceHandle,
}
})
const newLoops: Record<string, Loop> = {}
Object.entries(loops).forEach(([oldLoopId, loop]) => {
+17 -3
View File
@@ -12,6 +12,7 @@ import {
filterValidEdges,
getUniqueBlockName,
mergeSubblockState,
remapConditionIds,
} from '@/stores/workflows/utils'
import type {
Position,
@@ -611,6 +612,21 @@ export const useWorkflowStore = create<WorkflowStore>()(
{}
)
// Remap condition/router IDs in the duplicated subBlocks
const clonedSubBlockValues = activeWorkflowId
? JSON.parse(
JSON.stringify(
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}
)
)
: {}
remapConditionIds(
newSubBlocks as Record<string, SubBlockState>,
clonedSubBlockValues,
id,
newId
)
const newState = {
blocks: {
...get().blocks,
@@ -630,14 +646,12 @@ export const useWorkflowStore = create<WorkflowStore>()(
}
if (activeWorkflowId) {
const subBlockValues =
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId]: {
...state.workflowValues[activeWorkflowId],
[newId]: JSON.parse(JSON.stringify(subBlockValues)),
[newId]: clonedSubBlockValues,
},
},
}))