From 1e44d64fc705f6b16d2d7063acf681ab9c6f3ade Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 12 Mar 2026 00:00:26 -0700 Subject: [PATCH 1/3] 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 --- apps/sim/lib/workflows/condition-ids.ts | 57 +++++++++++++ .../lib/workflows/persistence/duplicate.ts | 71 ++++++++++++++-- apps/sim/stores/workflows/utils.ts | 83 +++++++++++++++---- apps/sim/stores/workflows/workflow/store.ts | 20 ++++- 4 files changed, 205 insertions(+), 26 deletions(-) create mode 100644 apps/sim/lib/workflows/condition-ids.ts diff --git a/apps/sim/lib/workflows/condition-ids.ts b/apps/sim/lib/workflows/condition-ids.ts new file mode 100644 index 00000000000..054975d880d --- /dev/null +++ b/apps/sim/lib/workflows/condition-ids.ts @@ -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 +} diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index cee5f467a35..b1a2be23316 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -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, + oldBlockId: string, + newBlockId: string +): Record { + const updated: Record = {} + + 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, + 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`) diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index 1352db5d10e..62146f73243 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -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() const newBlocks: Record = {} - // 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 } + 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 = {} 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, + subBlockValues: Record, + 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, edges: Edge[], @@ -510,6 +553,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 +588,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 = {} Object.entries(loops).forEach(([oldLoopId, loop]) => { diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index ba9c9bd35fb..bc97773d5cf 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -12,6 +12,7 @@ import { filterValidEdges, getUniqueBlockName, mergeSubblockState, + remapConditionIds, } from '@/stores/workflows/utils' import type { Position, @@ -611,6 +612,21 @@ export const useWorkflowStore = create()( {} ) + // Remap condition/router IDs in the duplicated subBlocks + const clonedSubBlockValues = activeWorkflowId + ? JSON.parse( + JSON.stringify( + useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {} + ) + ) + : {} + remapConditionIds( + newSubBlocks as Record, + clonedSubBlockValues, + id, + newId + ) + const newState = { blocks: { ...get().blocks, @@ -630,14 +646,12 @@ export const useWorkflowStore = create()( } 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, }, }, })) From 8d8d35ac6e1722f4585a8fb979c2cfb9f196033e Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 12 Mar 2026 01:00:28 -0700 Subject: [PATCH 2/3] 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 --- apps/sim/stores/workflows/utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index 62146f73243..e82bfcd7310 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -370,7 +370,7 @@ export function regenerateWorkflowIds( blockIdMap.set(oldId, newId) const oldNormalizedName = normalizeName(block.name) nameMap.set(oldNormalizedName, oldNormalizedName) - const newBlock = { ...block, id: newId } + const newBlock = { ...block, id: newId, subBlocks: JSON.parse(JSON.stringify(block.subBlocks)) } remapConditionIds(newBlock.subBlocks, {}, oldId, newId) newBlocks[newId] = newBlock }) @@ -540,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 From f0886af7025069af3123101340640761ff2715fa Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 12 Mar 2026 01:05:37 -0700 Subject: [PATCH 3/3] 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 --- apps/sim/lib/workflows/persistence/utils.ts | 28 ++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 89b7b7f6029..3bfa9af38ac 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -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, }) })