diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 6a5a81346e4..92f1988bbc6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -33,6 +33,7 @@ import { getEdgeZIndexForTarget, getNoteBlockHeight, normalizeCursorSourceHandleId, + sortNodesParentsFirst, useCanvasColorMode, } from '@sim/workflow-renderer' import { @@ -4830,9 +4831,14 @@ const WorkflowContent = React.memo( * * Subflow containers are skipped: their depth-based zIndex is what orders * them against their own children, and bumping it would break that. + * + * Containers are moved ahead of their children first: React Flow v12 places + * a child that precedes its parent at its parent-relative offset. Sorting + * here rather than in `displayNodes` covers the in-place patches too, since + * `nodesForRender` is the only array handed to React Flow. */ const nodesForRender = useMemo(() => { - const elevatedNodes = displayNodes.map((node) => { + const elevatedNodes = sortNodesParentsFirst(displayNodes).map((node) => { if (node.type === 'subflowNode') return node const target = getBlockZIndex(node.zIndex ?? BLOCK_Z_BASE, { isSelected: node.selected, 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 99107436c98..cdadfef7750 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 @@ -25,6 +25,7 @@ import { EDGE_Z_BASE, EDGE_Z_MAX, getEdgeZIndexForTarget, + sortNodesParentsFirst, useCanvasColorMode, } from '@sim/workflow-renderer' import { normalizeWorkflowEdgeHandles } from '@sim/workflow-types/workflow' @@ -402,13 +403,7 @@ export function PreviewWorkflow({ const nodeArray: Node[] = [] const blocksWithErrorEdge = new Set(errorSourceBlockKey ? errorSourceBlockKey.split(',') : []) - const sortedBlocks = Object.entries(workflowState.blocks || {}).sort( - ([, left], [, right]) => - calculateNestingDepth(left, workflowState.blocks) - - calculateNestingDepth(right, workflowState.blocks) - ) - - sortedBlocks.forEach(([blockId, block]) => { + Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => { if (!block || !block.type) { logger.warn(`Skipping invalid block: ${blockId}`) return @@ -501,7 +496,7 @@ export function PreviewWorkflow({ }) }) - return nodeArray + return sortNodesParentsFirst(nodeArray) }, [ blocksStructure, loopsStructure, diff --git a/packages/workflow-renderer/src/index.ts b/packages/workflow-renderer/src/index.ts index 920087e55e1..fcecb93d73e 100644 --- a/packages/workflow-renderer/src/index.ts +++ b/packages/workflow-renderer/src/index.ts @@ -17,6 +17,7 @@ export { type WorkflowEdgeViewProps, } from './edge/workflow-edge-view' export { humanizeBlockName } from './lib/humanize-block-name' +export { sortNodesParentsFirst } from './node-order' export { NOTE_MARKDOWN_FLOW, NoteBlockView, diff --git a/packages/workflow-renderer/src/node-order.test.ts b/packages/workflow-renderer/src/node-order.test.ts new file mode 100644 index 00000000000..7178f750851 --- /dev/null +++ b/packages/workflow-renderer/src/node-order.test.ts @@ -0,0 +1,72 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { sortNodesParentsFirst } from './node-order' + +const node = (id: string, parentId?: string) => (parentId ? { id, parentId } : { id }) +const ids = (nodes: Array<{ id: string }>) => nodes.map((n) => n.id) + +describe('sortNodesParentsFirst', () => { + it('moves a child that precedes its container behind it', () => { + /* The reported bug: a card created before the loop it was later dragged + into sits ahead of the loop in row order, so React Flow v12 placed it at + its loop-relative offset on every click. */ + const nodes = [node('start'), node('earlier'), node('sink', 'loop'), node('loop')] + + expect(ids(sortNodesParentsFirst(nodes))).toEqual(['start', 'earlier', 'loop', 'sink']) + }) + + it('returns the same array when every parent already precedes its children', () => { + const nodes = [node('start'), node('loop'), node('sink', 'loop'), node('later')] + + expect(sortNodesParentsFirst(nodes)).toBe(nodes) + }) + + it('leaves a parents-first array alone even when depth drops between siblings', () => { + const nodes = [node('loopA'), node('a1', 'loopA'), node('loopB'), node('b1', 'loopB')] + + expect(sortNodesParentsFirst(nodes)).toBe(nodes) + }) + + it('orders every level of a nested chain and keeps siblings in their original order', () => { + const nodes = [ + node('grandchild', 'inner'), + node('inner', 'outer'), + node('second', 'outer'), + node('first', 'outer'), + node('outer'), + node('top'), + ] + + expect(ids(sortNodesParentsFirst(nodes))).toEqual([ + 'outer', + 'top', + 'inner', + 'second', + 'first', + 'grandchild', + ]) + }) + + it('does not move a child whose parent is not in the array', () => { + const nodes = [node('orphan', 'missing'), node('top')] + + expect(sortNodesParentsFirst(nodes)).toBe(nodes) + }) + + it('terminates on a parent cycle', () => { + const nodes = [node('b', 'a'), node('a', 'b'), node('c', 'a')] + + expect(ids(sortNodesParentsFirst(nodes))).toHaveLength(3) + }) + + it('does not mutate its input when it has to reorder', () => { + const nodes = [node('sink', 'loop'), node('loop')] + const before = [...nodes] + + sortNodesParentsFirst(nodes) + + expect(nodes).toEqual(before) + }) +}) diff --git a/packages/workflow-renderer/src/node-order.ts b/packages/workflow-renderer/src/node-order.ts new file mode 100644 index 00000000000..f85bd0b8143 --- /dev/null +++ b/packages/workflow-renderer/src/node-order.ts @@ -0,0 +1,57 @@ +interface OrderableNode { + id: string + parentId?: string +} + +/** + * Returns the nodes with every container ahead of its descendants, otherwise + * in their original order. An array that already satisfies that is returned + * as-is, and a reorder sorts a copy — the caller's array is never sorted in + * place, which matters because the editor hands React state straight in. + * + * React Flow v12 adopts nodes in one pass over the array and resolves a child's + * absolute position against the parent it has *already* adopted; a child that + * precedes its parent is placed at its parent-relative offset as if that were + * absolute (and logged as "Parent node not found"). Adoption reruns whenever a + * node object changes identity — every click rebuilds them — and the misplaced + * card is only corrected by the next measurement pass, so it sits over the + * top-left of the canvas until something resizes it. v11 resolved positions in + * a second pass and never cared about order. The editor's block record is in + * database row order, which puts a block ahead of a container it was later + * dragged into. + */ +export function sortNodesParentsFirst(nodes: T[]): T[] { + const indexById = new Map() + for (let index = 0; index < nodes.length; index++) { + indexById.set(nodes[index].id, index) + } + + let ordered = true + for (let index = 0; index < nodes.length && ordered; index++) { + const parentId = nodes[index].parentId + if (!parentId) continue + const parentIndex = indexById.get(parentId) + if (parentIndex !== undefined && parentIndex > index) ordered = false + } + if (ordered) return nodes + + const depthById = new Map() + const depthOf = (node: T): number => { + const cached = depthById.get(node.id) + if (cached !== undefined) return cached + let depth = 0 + const visited = new Set([node.id]) + let parentId = node.parentId + while (parentId && !visited.has(parentId)) { + const parentIndex = indexById.get(parentId) + if (parentIndex === undefined) break + visited.add(parentId) + depth++ + parentId = nodes[parentIndex].parentId + } + depthById.set(node.id, depth) + return depth + } + + return [...nodes].sort((a, b) => depthOf(a) - depthOf(b)) +}