From 6b355e9b54d4be7a3c967924fb13d3146d9e4707 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 4 Mar 2026 15:51:32 -0800 Subject: [PATCH] fix(subflows): recurse into all descendants for lock, enable, and protection checks (#3412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(subflows): recurse into all descendants for lock, enable, and protection checks * fix(subflows): prevent container resize on initial render and clean up code - Add canvasReadyRef to skip container dimension recalculation during ReactFlow init — position changes from extent clamping fired before block heights are measured, causing containers to resize on page load - Resolve globals.css merge conflict, remove global z-index overrides (handled via ReactFlow zIndex prop instead) - Clean up subflow-node: hoist static helpers to module scope, remove unused ref, fix nested ternary readability, rename outlineColor→ringColor Co-Authored-By: Claude Opus 4.6 * fix(subflows): use full ancestor-chain protection for descendant enable-toggle The enable-toggle for descendants was checking only direct `locked` status instead of walking the full ancestor chain via `isBlockProtected`. This meant a block nested 2+ levels inside a locked subflow could still be toggled. Also added TSDoc clarifying why boxShadow works for subflow ring indicators. Co-Authored-By: Claude Opus 4.6 * revert(subflows): remove canvasReadyRef height-gating approach The canvasReadyRef gating in onNodesChange didn't fully fix the container resize-on-load issue. Reverting to address properly later. Co-Authored-By: Claude Opus 4.6 * fix: remove unintentional edge-interaction CSS from globals Leftover from merge conflict resolution — not part of this PR's changes. Co-Authored-By: Claude Opus 4.6 * fix(editor): correct isAncestorLocked when block and ancestor both locked, restore fade-in transition isAncestorLocked was derived from isBlockProtected which short-circuits on block.locked, so a self-locked block inside a locked ancestor showed "Unlock block" instead of "Ancestor container is locked". Now walks the ancestor chain independently. Also restores the accidentally removed transition-opacity duration-150 class on the ReactFlow container. Co-Authored-By: Claude Opus 4.6 * fix(subflows): use full ancestor-chain protection for top-level enable-toggle, restore edge-label z-index The top-level block check in batchToggleEnabled used block.locked (self only) while descendants used isBlockProtected (full ancestor chain). A block inside a locked ancestor but not itself locked would bypass the check. Now all three layers (store, collaborative hook, DB operations) consistently use isBlockProtected/isDbBlockProtected at both levels. Also restores the accidentally removed edge-labels z-index rule, bumped from 60 to 1001 so labels render above child nodes (zIndex: 1000). Co-Authored-By: Claude Opus 4.6 * fix(subflows): extract isAncestorProtected utility, add cycle detection to all traversals - Extract isAncestorProtected from utils.ts so editor.tsx doesn't duplicate the ancestor-chain walk. isBlockProtected now delegates to it. - Add visited-set cycle detection to all ancestor walks (isBlockProtected, isAncestorProtected, isDbBlockProtected) and descendant searches (findAllDescendantNodes, findDbDescendants) to guard against corrupt parentId references. - Document why click-catching div has no event bubbling concern (ReactFlow renders children as viewport siblings, not DOM children). Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../panel/components/editor/editor.tsx | 24 +-- .../components/subflows/subflow-node.tsx | 124 ++++++------ .../utils/block-protection-utils.ts | 25 +-- .../[workspaceId]/w/[workflowId]/workflow.tsx | 47 ++--- apps/sim/hooks/use-collaborative-workflow.ts | 36 ++-- apps/sim/socket/database/operations.ts | 186 +++++++----------- apps/sim/stores/workflows/workflow/store.ts | 38 ++-- apps/sim/stores/workflows/workflow/utils.ts | 57 ++++-- 8 files changed, 247 insertions(+), 290 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index 42e88b22a5..35d7bc5c67 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -40,6 +40,10 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config' import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' +import { + isAncestorProtected, + isBlockProtected, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils' import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' import { getBlock } from '@/blocks/registry' import type { SubBlockType } from '@/blocks/types' @@ -107,12 +111,11 @@ export function Editor() { const userPermissions = useUserPermissionsContext() - // Check if block is locked (or inside a locked container) and compute edit permission + // Check if block is locked (or inside a locked ancestor) and compute edit permission // Locked blocks cannot be edited by anyone (admins can only lock/unlock) const blocks = useWorkflowStore((state) => state.blocks) - const parentId = currentBlock?.data?.parentId as string | undefined - const isParentLocked = parentId ? (blocks[parentId]?.locked ?? false) : false - const isLocked = (currentBlock?.locked ?? false) || isParentLocked + const isLocked = currentBlockId ? isBlockProtected(currentBlockId, blocks) : false + const isAncestorLocked = currentBlockId ? isAncestorProtected(currentBlockId, blocks) : false const canEditBlock = userPermissions.canEdit && !isLocked const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) @@ -247,10 +250,7 @@ export function Editor() { const block = blocks[blockId] if (!block) return - const parentId = block.data?.parentId as string | undefined - const isParentLocked = parentId ? (blocks[parentId]?.locked ?? false) : false - const isLocked = (block.locked ?? false) || isParentLocked - if (!userPermissions.canEdit || isLocked) return + if (!userPermissions.canEdit || isBlockProtected(blockId, blocks)) return renamingBlockIdRef.current = blockId setEditedName(block.name || '') @@ -364,11 +364,11 @@ export function Editor() { )}
- {/* Locked indicator - clickable to unlock if user has admin permissions, block is locked, and parent is not locked */} + {/* Locked indicator - clickable to unlock if user has admin permissions, block is locked directly, and not locked by an ancestor */} {isLocked && currentBlock && ( - {userPermissions.canAdmin && currentBlock.locked && !isParentLocked ? ( + {userPermissions.canAdmin && currentBlock.locked && !isAncestorLocked ? (