From 6d0de5c9250016b73ecc780618c456a1a269067f Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 22 Aug 2026 10:34:07 -0700 Subject: [PATCH 1/3] fix(workflow): hide idle nested subflow end handles --- .../workflow-preview/docs-container-node.tsx | 2 + .../workflow-preview/workflow-data.test.ts | 63 +++++++++++ .../workflow-preview/workflow-data.ts | 37 ++++++- apps/docs/package.json | 4 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 10 +- .../components/subflow/subflow.tsx | 1 + .../preview-workflow/preview-workflow.tsx | 15 ++- bun.lock | 1 + .../src/canvas-layers.test.ts | 34 ++++++ .../workflow-renderer/src/canvas-layers.ts | 34 ++++-- .../edge/workflow-edge-view-mount.test.tsx | 20 +++- .../src/edge/workflow-edge-view.tsx | 5 +- packages/workflow-renderer/src/index.ts | 1 + .../src/subflow/subflow-node-view.tsx | 20 +++- .../workflow-block-border-mount.test.tsx | 101 +++++++++++++++++- 15 files changed, 328 insertions(+), 20 deletions(-) create mode 100644 apps/docs/components/workflow-preview/workflow-data.test.ts diff --git a/apps/docs/components/workflow-preview/docs-container-node.tsx b/apps/docs/components/workflow-preview/docs-container-node.tsx index 85d098979d3..b0d27d22ed4 100644 --- a/apps/docs/components/workflow-preview/docs-container-node.tsx +++ b/apps/docs/components/workflow-preview/docs-container-node.tsx @@ -8,6 +8,7 @@ interface DocsContainerData { name: string blockType: string size?: { width: number; height: number } + parentId?: string } /** @@ -24,6 +25,7 @@ export const DocsContainerNode = memo(function DocsContainerNode({ name: data.name, width: data.size?.width, height: data.size?.height, + parentId: data.parentId, isPreview: true, } diff --git a/apps/docs/components/workflow-preview/workflow-data.test.ts b/apps/docs/components/workflow-preview/workflow-data.test.ts new file mode 100644 index 00000000000..7dbe6e51b01 --- /dev/null +++ b/apps/docs/components/workflow-preview/workflow-data.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ +import { BLOCK_Z_BASE, CONTAINER_CHILD_Z_BASE, getEdgeZIndex } from '@sim/workflow-renderer' +import { describe, expect, it } from 'vitest' +import { type PreviewBlock, type PreviewWorkflow, toReactFlowElements } from './workflow-data' + +const block = ( + overrides: Partial & Pick +): PreviewBlock => ({ + name: overrides.id, + bgColor: '#000000', + rows: [], + position: { x: 0, y: 0 }, + ...overrides, +}) + +const workflow: PreviewWorkflow = { + id: 'nested-subflows', + name: 'Nested subflows', + blocks: [ + block({ id: 'start', type: 'starter' }), + block({ id: 'loop', type: 'loop', size: { width: 500, height: 300 } }), + block({ + id: 'parallel', + type: 'parallel', + parentId: 'loop', + position: { x: 24, y: 64 }, + size: { width: 400, height: 200 }, + }), + block({ id: 'agent', type: 'agent', parentId: 'loop', position: { x: 24, y: 140 } }), + ], + edges: [ + { id: 'start-loop', source: 'start', target: 'loop' }, + { id: 'loop-parallel', source: 'loop', target: 'parallel' }, + { id: 'loop-agent', source: 'loop', target: 'agent' }, + ], +} + +describe('toReactFlowElements layering', () => { + it('places incoming edges on their container target layer', () => { + const { nodes, edges } = toReactFlowElements(workflow, false, { + highlightEdge: 'loop-parallel', + }) + const nodeById = new Map(nodes.map((node) => [node.id, node])) + const edgeById = new Map(edges.map((edge) => [edge.id, edge])) + + expect(nodeById.get('loop')?.zIndex).toBe(0) + expect(nodeById.get('parallel')?.zIndex).toBe(1) + expect(edgeById.get('start-loop')?.zIndex).toBe(0) + expect(edgeById.get('loop-parallel')?.zIndex).toBe(1) + }) + + it('keeps ordinary cards above normally layered edges', () => { + const { nodes, edges } = toReactFlowElements(workflow) + const nodeById = new Map(nodes.map((node) => [node.id, node])) + const edgeById = new Map(edges.map((edge) => [edge.id, edge])) + + expect(nodeById.get('start')?.zIndex).toBe(BLOCK_Z_BASE) + expect(nodeById.get('agent')?.zIndex).toBe(CONTAINER_CHILD_Z_BASE) + expect(edgeById.get('loop-agent')?.zIndex).toBe(getEdgeZIndex(0)) + }) +}) diff --git a/apps/docs/components/workflow-preview/workflow-data.ts b/apps/docs/components/workflow-preview/workflow-data.ts index a15b99a4759..4148fccaa70 100644 --- a/apps/docs/components/workflow-preview/workflow-data.ts +++ b/apps/docs/components/workflow-preview/workflow-data.ts @@ -1,3 +1,9 @@ +import { + BLOCK_Z_BASE, + CONTAINER_CHILD_Z_BASE, + getEdgeZIndex, + getEdgeZIndexForTarget, +} from '@sim/workflow-renderer' import { type Edge, type Node, Position } from 'reactflow' /** @@ -61,6 +67,24 @@ export interface HighlightOptions { selectedBlock?: string } +/** Semantic container depth used for z-order while docs positions stay flattened. */ +function getNestingDepth(block: PreviewBlock, blocksById: Map): number { + let depth = 0 + let parentId = block.parentId + const visited = new Set() + + while (parentId && !visited.has(parentId)) { + const parent = blocksById.get(parentId) + if (!parent) break + + visited.add(parentId) + depth += 1 + parentId = parent.parentId + } + + return depth +} + /** * Converts a {@link PreviewWorkflow} to React Flow nodes and edges. * @@ -81,6 +105,7 @@ export function toReactFlowElements( const nodes: Node[] = workflow.blocks.map((block, index) => { const isContainer = Boolean(block.size) + const nestingDepth = getNestingDepth(block, blocksById) // Nested blocks are authored relative to their container; render them at // absolute coordinates (not React Flow parentNode children) so the edges // between a container and its nested blocks render reliably and on top. @@ -92,7 +117,7 @@ export function toReactFlowElements( id: block.id, type: isContainer ? 'previewContainer' : 'previewBlock', position, - zIndex: isContainer ? 0 : 1, + zIndex: isContainer ? nestingDepth : block.parentId ? CONTAINER_CHILD_Z_BASE : BLOCK_Z_BASE, ...(block.size ? { style: { width: block.size.width, height: block.size.height } } : {}), data: { name: block.name, @@ -103,6 +128,7 @@ export function toReactFlowElements( tools: block.tools, hideTargetHandle: block.hideTargetHandle, size: block.size, + parentId: block.parentId, index, animate, isHighlighted: highlightBlock === block.id || selectedBlock === block.id, @@ -127,6 +153,14 @@ export function toReactFlowElements( // so edges into and out of Loop/Parallel containers still connect. const sourceBlock = blocksById.get(e.source) const targetBlock = blocksById.get(e.target) + const parentContainer = blocksById.get(sourceBlock?.parentId ?? targetBlock?.parentId ?? '') + const baseZIndex = getEdgeZIndex( + parentContainer ? getNestingDepth(parentContainer, blocksById) : undefined, + { isHighlighted: isEdgeHighlight } + ) + const targetContainerZIndex = targetBlock?.size + ? getNestingDepth(targetBlock, blocksById) + : undefined const sourceHandle = e.sourceHandle ?? (sourceBlock?.size ? `${sourceBlock.type}-end-source` : 'source') const targetHandle = targetBlock?.size ? undefined : 'target' @@ -142,6 +176,7 @@ export function toReactFlowElements( }, sourceHandle, targetHandle, + zIndex: getEdgeZIndexForTarget(baseZIndex, targetContainerZIndex), data: { animate, delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0, diff --git a/apps/docs/package.json b/apps/docs/package.json index 56a6a98107a..700fdfecbc6 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -10,6 +10,7 @@ "build": "fumadocs-mdx && NODE_OPTIONS='--max-old-space-size=8192' next build", "start": "next start", "postinstall": "fumadocs-mdx", + "test": "vitest run", "type-check": "fumadocs-mdx && tsc --noEmit", "lint": "biome check --write --unsafe .", "lint:check": "biome check .", @@ -47,6 +48,7 @@ "@types/react-dom": "^19.0.4", "postcss": "^8.5.3", "tailwindcss": "^4.0.12", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "vitest": "^4.1.0" } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 14bb7d539c5..57dab05ff3e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -28,6 +28,7 @@ import { EDGE_Z_MAX, getBlockZIndex, getEdgeZIndex, + getEdgeZIndexForTarget, getNoteBlockHeight, normalizeCursorSourceHandleId, } from '@sim/workflow-renderer' @@ -4886,10 +4887,16 @@ const WorkflowContent = React.memo( isEdgeSelected: isSelected, }), }) + const targetContainerZIndex = + targetNode?.type === 'subflowNode' ? (targetNode.zIndex ?? 0) : undefined + // The target node paints after an equal-z edge. A nested container is + // one depth above its parent, so this hides only the segment beneath + // the target while leaving the route visible over the parent body. + const zIndex = getEdgeZIndexForTarget(baseZIndex, targetContainerZIndex) return { ...edge, - zIndex: baseZIndex, + zIndex, data: { ...edge.data, isSelected, @@ -4898,6 +4905,7 @@ const WorkflowContent = React.memo( parentLoopId, sourceHandle: edge.sourceHandle, onDelete: handleEdgeDelete, + ...(targetContainerZIndex !== undefined ? { labelZIndex: zIndex } : {}), }, } }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx index 3027f64de2a..7ac85da0789 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx @@ -12,6 +12,7 @@ interface WorkflowPreviewSubflowData { width?: number height?: number kind: 'loop' | 'parallel' + parentId?: string /** Whether this subflow is enabled */ enabled?: boolean /** Whether this subflow is selected in preview mode */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx index e73be1a64be..ea37f3a2bad 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx @@ -22,6 +22,7 @@ import { CONTAINER_DIMENSIONS, EDGE_Z_BASE, EDGE_Z_MAX, + getEdgeZIndexForTarget, } from '@sim/workflow-renderer' import { normalizeWorkflowEdgeHandles } from '@sim/workflow-types/workflow' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' @@ -567,6 +568,14 @@ export function PreviewWorkflow({ return normalizeWorkflowEdgeHandles(workflowState.edges).map((edge) => { const status = getEdgeExecutionStatus(edge) const isErrorEdge = edge.sourceHandle === 'error' + const baseZIndex = + status === 'success' ? EDGE_Z_MAX : isErrorEdge ? EDGE_Z_BASE + 2 : EDGE_Z_BASE + const targetBlock = workflowState.blocks[edge.target] + const targetContainerZIndex = + targetBlock?.type === 'loop' || targetBlock?.type === 'parallel' + ? calculateNestingDepth(targetBlock, workflowState.blocks) + : undefined + return { id: edge.id, source: edge.source, @@ -580,12 +589,14 @@ export function PreviewWorkflow({ /* Inside the shared edge band, so a line clears the opaque container it crosses and still passes behind cards. Execution status orders edges within the band: a successful path draws over an error one, which - draws over an unexecuted one. */ - zIndex: status === 'success' ? EDGE_Z_MAX : isErrorEdge ? EDGE_Z_BASE + 2 : EDGE_Z_BASE, + draws over an unexecuted one. A Loop/Parallel target overrides that + ordering so its node paints over the incoming segment. */ + zIndex: getEdgeZIndexForTarget(baseZIndex, targetContainerZIndex), } }) }, [ edgesStructure, + workflowState.blocks, workflowState.edges, isValidWorkflowState, blockExecutionMap, diff --git a/bun.lock b/bun.lock index d5b11db7e06..9eb77d319d5 100644 --- a/bun.lock +++ b/bun.lock @@ -93,6 +93,7 @@ "postcss": "^8.5.3", "tailwindcss": "^4.0.12", "typescript": "^7.0.2", + "vitest": "^4.1.0", }, }, "apps/pii": { diff --git a/packages/workflow-renderer/src/canvas-layers.test.ts b/packages/workflow-renderer/src/canvas-layers.test.ts index 0bd30cba13b..d39b377fff3 100644 --- a/packages/workflow-renderer/src/canvas-layers.test.ts +++ b/packages/workflow-renderer/src/canvas-layers.test.ts @@ -9,6 +9,7 @@ import { EDGE_Z_MAX, getBlockZIndex, getEdgeZIndex, + getEdgeZIndexForTarget, } from './canvas-layers' /** @@ -69,3 +70,36 @@ describe('getEdgeZIndex', () => { expect(getEdgeZIndex(8)).toBeLessThan(getEdgeZIndex(undefined, { isHighlighted: true })) }) }) + +describe('getEdgeZIndexForTarget', () => { + it('shares a container target layer so the node paints over the incoming edge', () => { + const parentZIndex = 0 + const targetZIndex = 1 + const edgeZIndex = getEdgeZIndex(parentZIndex) + + const resolved = getEdgeZIndexForTarget(edgeZIndex, targetZIndex) + + expect(resolved).toBe(targetZIndex) + expect(resolved).toBeGreaterThan(parentZIndex) + }) + + it('places incoming edges beneath top-level container targets', () => { + expect(getEdgeZIndexForTarget(EDGE_Z_BASE, 0)).toBe(0) + }) + + it('does not let highlighting elevate an edge over its container target', () => { + const highlighted = getEdgeZIndex(undefined, { isHighlighted: true }) + + expect(getEdgeZIndexForTarget(highlighted, 2)).toBe(2) + }) + + it('does not let an execution edge elevate over its container target', () => { + expect(getEdgeZIndexForTarget(EDGE_Z_MAX, 2)).toBe(2) + }) + + it('leaves edges to ordinary blocks unchanged', () => { + const edgeZIndex = getEdgeZIndex(1) + + expect(getEdgeZIndexForTarget(edgeZIndex, undefined)).toBe(edgeZIndex) + }) +}) diff --git a/packages/workflow-renderer/src/canvas-layers.ts b/packages/workflow-renderer/src/canvas-layers.ts index a14c0b93405..e4bd8f8dfb7 100644 --- a/packages/workflow-renderer/src/canvas-layers.ts +++ b/packages/workflow-renderer/src/canvas-layers.ts @@ -9,12 +9,15 @@ * - {@link CONTAINER_CHILD_Z_BASE} — cards inside a container (same +1 / +10 steps) * - {@link CONNECTION_PICKER_Z} — the connection block picker * - * Containers and edges must occupy separate bands. A container paints an opaque - * body, so an edge sharing its z loses the equal-z tiebreak to DOM order — React - * Flow renders the nodes layer after the edges layer — and is drawn *behind* the - * container. That is what hid every line crossing a top-level subflow, whether - * in flight or persisted. Cards then sit above the edge band, so a line still - * passes behind card chrome, knobs, and the action-bar swell. + * Containers and ordinary edges occupy separate bands. A container paints an + * opaque body, so an edge sharing its z loses the equal-z tiebreak to DOM order + * — React Flow renders the nodes layer after the edges layer — and is drawn + * *behind* the container. Incoming container edges deliberately use that rule + * at their target's depth: the target sits above its parent by one depth, leaving + * the edge over the parent body but beneath the target. The edge can also be + * occluded by peer or higher-depth containers it crosses. Cards then sit above + * the edge band, so every other line still passes behind card chrome, knobs, and + * the action-bar swell. * * Shared by the editor canvas and the read-only preview because both render the * same graph through the same React Flow layering rules; a second scale drifted @@ -74,3 +77,22 @@ export function getEdgeZIndex( const depth = containerZIndex === undefined ? 0 : containerZIndex + 1 return Math.min(EDGE_Z_BASE + depth, EDGE_Z_DEPTH_MAX) } + +/** + * Keeps an incoming edge beneath a Loop/Parallel target without hiding it + * behind that target's parent. + * + * Containers use their nesting depth as z-index, so a nested target is exactly + * one layer above its parent. React Flow renders equal-z edges before nodes; + * sharing the target's layer therefore leaves the edge visible over the parent + * body while the target paints over the segment that reaches beneath it. + * + * `targetContainerZIndex` must only be supplied when the edge targets a + * container. Ordinary edges retain their existing depth/highlight ordering. + */ +export function getEdgeZIndexForTarget( + edgeZIndex: number, + targetContainerZIndex: number | undefined +): number { + return targetContainerZIndex ?? edgeZIndex +} diff --git a/packages/workflow-renderer/src/edge/workflow-edge-view-mount.test.tsx b/packages/workflow-renderer/src/edge/workflow-edge-view-mount.test.tsx index 5ab485dd780..f1a782c634d 100644 --- a/packages/workflow-renderer/src/edge/workflow-edge-view-mount.test.tsx +++ b/packages/workflow-renderer/src/edge/workflow-edge-view-mount.test.tsx @@ -1,12 +1,20 @@ /** * @vitest-environment jsdom */ -import { act } from 'react' +import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { Position } from 'reactflow' -import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { WorkflowEdgeView, type WorkflowEdgeViewProps } from '../index' +vi.mock('reactflow', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + EdgeLabelRenderer: ({ children }: { children: ReactNode }) => <>{children}, + } +}) + const mountedHosts = new Set() const mountedRoots = new Set() @@ -210,4 +218,12 @@ describe('WorkflowEdgeView', () => { expect(path?.style.stroke).toBe('var(--text-error)') }) + + it('keeps the selected-edge control on a container target occlusion layer', () => { + const { host } = renderEdge({ + data: { isSelected: true, labelZIndex: 1 }, + }) + + expect(host.querySelector('button')).toHaveStyle({ zIndex: 1 }) + }) }) diff --git a/packages/workflow-renderer/src/edge/workflow-edge-view.tsx b/packages/workflow-renderer/src/edge/workflow-edge-view.tsx index c76b5055c04..de8b7656581 100644 --- a/packages/workflow-renderer/src/edge/workflow-edge-view.tsx +++ b/packages/workflow-renderer/src/edge/workflow-edge-view.tsx @@ -7,6 +7,7 @@ import type { EdgeDiffStatus, EdgeRunStatus } from '../types' const EXECUTION_PULSE_LENGTH = 0.32 const EXECUTION_PULSE_CYCLE_LENGTH = 2.2 const EXECUTION_PULSE_DURATION = '1100ms' +const DEFAULT_EDGE_LABEL_Z_INDEX = 1011 /** * How far the glow reaches past the path, in user space. @@ -125,6 +126,8 @@ export function WorkflowEdgeView({ }, [isHorizontal, sourceX, sourceY, targetX, targetY]) const isSelected = data?.isSelected ?? false + const labelZIndex = + (data as { labelZIndex?: number } | undefined)?.labelZIndex ?? DEFAULT_EDGE_LABEL_Z_INDEX const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error' @@ -268,7 +271,7 @@ export function WorkflowEdgeView({ position: 'absolute', transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`, pointerEvents: 'all', - zIndex: 1011, + zIndex: labelZIndex, }} onClick={(e) => { e.preventDefault() diff --git a/packages/workflow-renderer/src/index.ts b/packages/workflow-renderer/src/index.ts index 4f39addccc7..ad4fdbcf999 100644 --- a/packages/workflow-renderer/src/index.ts +++ b/packages/workflow-renderer/src/index.ts @@ -6,6 +6,7 @@ export { EDGE_Z_MAX, getBlockZIndex, getEdgeZIndex, + getEdgeZIndexForTarget, } from './canvas-layers' export * from './dimensions' export { WorkflowEdgeView, type WorkflowEdgeViewProps } from './edge/workflow-edge-view' diff --git a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx index 40fbafd140a..58f207533f0 100644 --- a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx +++ b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx @@ -5,6 +5,7 @@ import { Handle, internalsSymbol, Position, + useStore as useReactFlowStore, useStoreApi as useReactFlowStoreApi, useUpdateNodeInternals, } from 'reactflow' @@ -343,6 +344,14 @@ export function SubflowNodeView({ const isPreviewSelected = data?.isPreviewSelected || false const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source' + const hasDisplayedEndEdge = useReactFlowStore( + useCallback( + (state) => + state.edges.some((edge) => edge.source === id && edge.sourceHandle === endHandleId), + [endHandleId, id] + ) + ) + const showFixedEndPort = !data.parentId || hasDisplayedEndEdge const BlockIcon = data.kind === 'loop' ? Repeat : Split const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel') const blockTypeLabel = data.kind === 'loop' ? 'Loop' : 'Parallel' @@ -474,13 +483,16 @@ export function SubflowNodeView({ position: HANDLE_POSITIONS.SUBFLOW_CONNECTION_Y, plateau: CURSOR_SWELL_LENGTH_PX, }, - { + ] + + if (showFixedEndPort) { + ports.push({ id: endHandleId, side: 'right', position: HANDLE_POSITIONS.SUBFLOW_CONNECTION_Y, plateau: CURSOR_SWELL_LENGTH_PX, - }, - ] + }) + } if (showActionMenu) { ports.push({ @@ -495,7 +507,7 @@ export function SubflowNodeView({ } return ports - }, [actionMenuSwellOpen, actionMenuWidth, endHandleId, showActionMenu]) + }, [actionMenuSwellOpen, actionMenuWidth, endHandleId, showActionMenu, showFixedEndPort]) return (
{ + reactFlowStore.setState({ edges }) + }, [edges, reactFlowStore]) + + return ( + undefined} + /> + ) +} + +function getSubflowSilhouette(host: HTMLElement, caseName: string) { + const path = host.querySelector( + `[data-subflow-case="${caseName}"] [data-type="subflowNode"] > svg > path[fill="var(--border-1)"]` + ) + expect(path).toBeTruthy() + return path?.getAttribute('d') +} + afterEach(() => { act(() => { mountedRoots.forEach((root) => root.unmount()) @@ -980,6 +1021,62 @@ describe('WorkflowBlockBorder mount', () => { ) }) + it.each(['loop', 'parallel'] as const)( + 'only paints the fixed %s end port when its topology needs it', + (kind) => { + const endHandleId = `${kind}-end-source` + const nestedConnectedEdges: Edge[] = [ + { + id: `${kind}-end-edge`, + source: `${kind}-nested-connected`, + sourceHandle: endHandleId, + target: `${kind}-sibling`, + targetHandle: 'target', + }, + ] + const { host } = mount( +
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+ ) + + const topLevelPath = getSubflowSilhouette(host, 'top-level') + const nestedIdlePath = getSubflowSilhouette(host, 'nested-idle') + const nestedConnectedPath = getSubflowSilhouette(host, 'nested-connected') + + expect(nestedIdlePath).not.toBe(topLevelPath) + expect(nestedConnectedPath).toBe(topLevelPath) + for (const caseName of ['top-level', 'nested-idle', 'nested-connected']) { + const subflow = host.querySelector(`[data-subflow-case="${caseName}"]`) + expect(subflow?.querySelector('[data-handleid="target"]')).toBeTruthy() + expect(subflow?.querySelector(`[data-handleid="${endHandleId}"]`)).toBeTruthy() + } + } + ) + it('retracts a selected loop action swell after hover ends', () => { vi.useFakeTimers() vi.stubGlobal( From 094c02704614636b39116a0653c8b0ba77448936 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 23 Aug 2026 11:47:19 -0700 Subject: [PATCH 2/3] perf(workflow): avoid repeated subflow edge scans --- .../src/subflow/subflow-node-view.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx index 58f207533f0..8cffbfbdf4e 100644 --- a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx +++ b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx @@ -344,14 +344,15 @@ export function SubflowNodeView({ const isPreviewSelected = data?.isPreviewSelected || false const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source' - const hasDisplayedEndEdge = useReactFlowStore( - useCallback( - (state) => - state.edges.some((edge) => edge.source === id && edge.sourceHandle === endHandleId), - [endHandleId, id] - ) + const displayedEdges = useReactFlowStore( + useCallback((state) => (data.parentId ? state.edges : null), [data.parentId]) + ) + const showFixedEndPort = useMemo( + () => + !displayedEdges || + displayedEdges.some((edge) => edge.source === id && edge.sourceHandle === endHandleId), + [displayedEdges, endHandleId, id] ) - const showFixedEndPort = !data.parentId || hasDisplayedEndEdge const BlockIcon = data.kind === 'loop' ? Repeat : Split const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel') const blockTypeLabel = data.kind === 'loop' ? 'Loop' : 'Parallel' From b71ba64d3c2e19f89906c05d25d87daae5d38978 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 23 Aug 2026 13:02:47 -0700 Subject: [PATCH 3/3] perf(workflow): stabilize subflow edge selector --- .../src/subflow/subflow-node-view.tsx | 24 +++++--- .../workflow-block-border-mount.test.tsx | 58 +++++++++++++++++-- 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx index 8cffbfbdf4e..4a0db6902ed 100644 --- a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx +++ b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx @@ -5,6 +5,7 @@ import { Handle, internalsSymbol, Position, + type ReactFlowState, useStore as useReactFlowStore, useStoreApi as useReactFlowStoreApi, useUpdateNodeInternals, @@ -344,14 +345,21 @@ export function SubflowNodeView({ const isPreviewSelected = data?.isPreviewSelected || false const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source' - const displayedEdges = useReactFlowStore( - useCallback((state) => (data.parentId ? state.edges : null), [data.parentId]) - ) - const showFixedEndPort = useMemo( - () => - !displayedEdges || - displayedEdges.some((edge) => edge.source === id && edge.sourceHandle === endHandleId), - [displayedEdges, endHandleId, id] + const showFixedEndPort = useReactFlowStore( + useMemo(() => { + let previousEdges: ReactFlowState['edges'] | undefined + let previousResult = !data.parentId + + return (state: ReactFlowState) => { + if (!data.parentId || state.edges === previousEdges) return previousResult + + previousEdges = state.edges + previousResult = state.edges.some( + (edge) => edge.source === id && edge.sourceHandle === endHandleId + ) + return previousResult + } + }, [data.parentId, endHandleId, id]) ) const BlockIcon = data.kind === 'loop' ? Repeat : Split const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel') diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-border-mount.test.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-border-mount.test.tsx index 6b6c1e3e782..ce93f0b8853 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-border-mount.test.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-border-mount.test.tsx @@ -5,7 +5,7 @@ * a knob-paint bug once threw only when a card had a coloured knob — invisible * on an idle canvas, fatal on node creation. */ -import { act, useLayoutEffect } from 'react' +import { act, Profiler, useLayoutEffect } from 'react' import { normalizeWorkflowEdgeSourceHandle, normalizeWorkflowEdgeTargetHandle, @@ -83,26 +83,26 @@ function mount(element: React.ReactElement) { return { host, root } } -const NO_EDGES: Edge[] = [] - function SubflowMountFixture({ id, kind, parentId, - edges = NO_EDGES, + edges, + onRender, }: { id: string kind: 'loop' | 'parallel' parentId?: string edges?: Edge[] + onRender?: () => void }) { const reactFlowStore = useReactFlowStoreApi() useLayoutEffect(() => { - reactFlowStore.setState({ edges }) + if (edges) reactFlowStore.setState({ edges }) }, [edges, reactFlowStore]) - return ( + const view = ( undefined} /> ) + + return onRender ? ( + + {view} + + ) : ( + view + ) } function getSubflowSilhouette(host: HTMLElement, caseName: string) { @@ -1077,6 +1085,44 @@ describe('WorkflowBlockBorder mount', () => { } ) + it('does not rerender a nested subflow when unrelated edges change', () => { + const baselineRender = vi.fn() + const unrelatedEdgeRender = vi.fn() + const unrelatedEdges: Edge[] = [ + { + id: 'unrelated-edge', + source: 'other-source', + sourceHandle: 'source', + target: 'other-target', + targetHandle: 'target', + }, + ] + + mount( +
+ + + + + + +
+ ) + + expect(unrelatedEdgeRender).toHaveBeenCalledTimes(baselineRender.mock.calls.length) + }) + it('retracts a selected loop action swell after hover ends', () => { vi.useFakeTimers() vi.stubGlobal(