@@ -921,45 +921,6 @@ const WorkflowContent = React.memo(() => {
921921 [ removeEdge ]
922922 )
923923
924- /** Handles ActionBar remove-from-subflow events. */
925- useEffect ( ( ) => {
926- const handleRemoveFromSubflow = ( event : Event ) => {
927- const customEvent = event as CustomEvent < { blockIds : string [ ] } >
928- const blockIds = customEvent . detail ?. blockIds
929- if ( ! blockIds || blockIds . length === 0 ) return
930-
931- try {
932- const validBlockIds = blockIds . filter ( ( id ) => {
933- const block = blocks [ id ]
934- return block ?. data ?. parentId
935- } )
936- if ( validBlockIds . length === 0 ) return
937-
938- const movingNodeIds = new Set ( validBlockIds )
939-
940- const boundaryEdges = edgesForDisplay . filter ( ( e ) => {
941- const sourceInSelection = movingNodeIds . has ( e . source )
942- const targetInSelection = movingNodeIds . has ( e . target )
943- return sourceInSelection !== targetInSelection
944- } )
945-
946- for ( const blockId of validBlockIds ) {
947- const edgesForThisNode = boundaryEdges . filter (
948- ( e ) => e . source === blockId || e . target === blockId
949- )
950- removeEdgesForNode ( blockId , edgesForThisNode )
951- updateNodeParent ( blockId , null , edgesForThisNode )
952- }
953- } catch ( err ) {
954- logger . error ( 'Failed to remove from subflow' , { err } )
955- }
956- }
957-
958- window . addEventListener ( 'remove-from-subflow' , handleRemoveFromSubflow as EventListener )
959- return ( ) =>
960- window . removeEventListener ( 'remove-from-subflow' , handleRemoveFromSubflow as EventListener )
961- } , [ blocks , edgesForDisplay , removeEdgesForNode , updateNodeParent ] )
962-
963924 /** Finds the closest block to a position for auto-connect. */
964925 const findClosestOutput = useCallback (
965926 ( newNodePosition : { x : number ; y : number } ) : BlockData | null => {
@@ -1827,6 +1788,18 @@ const WorkflowContent = React.memo(() => {
18271788 return
18281789 }
18291790
1791+ // Recovery: detect and clear invalid parent references to prevent infinite recursion
1792+ if ( block . data ?. parentId ) {
1793+ if ( block . data . parentId === block . id ) {
1794+ block . data = { ...block . data , parentId : undefined , extent : undefined }
1795+ } else {
1796+ const parentBlock = blocks [ block . data . parentId ]
1797+ if ( parentBlock ?. data ?. parentId === block . id ) {
1798+ block . data = { ...block . data , parentId : undefined , extent : undefined }
1799+ }
1800+ }
1801+ }
1802+
18301803 // Handle container nodes differently
18311804 if ( block . type === 'loop' || block . type === 'parallel' ) {
18321805 nodeArray . push ( {
@@ -1965,6 +1938,67 @@ const WorkflowContent = React.memo(() => {
19651938 } )
19661939 } , [ derivedNodes ] )
19671940
1941+ /** Handles ActionBar remove-from-subflow events. */
1942+ useEffect ( ( ) => {
1943+ const handleRemoveFromSubflow = ( event : Event ) => {
1944+ const customEvent = event as CustomEvent < { blockIds : string [ ] } >
1945+ const blockIds = customEvent . detail ?. blockIds
1946+ if ( ! blockIds || blockIds . length === 0 ) return
1947+
1948+ try {
1949+ const validBlockIds = blockIds . filter ( ( id ) => {
1950+ const block = blocks [ id ]
1951+ return block ?. data ?. parentId
1952+ } )
1953+ if ( validBlockIds . length === 0 ) return
1954+
1955+ const movingNodeIds = new Set ( validBlockIds )
1956+
1957+ const boundaryEdges = edgesForDisplay . filter ( ( e ) => {
1958+ const sourceInSelection = movingNodeIds . has ( e . source )
1959+ const targetInSelection = movingNodeIds . has ( e . target )
1960+ return sourceInSelection !== targetInSelection
1961+ } )
1962+
1963+ // Collect absolute positions BEFORE updating parents
1964+ const absolutePositions = new Map < string , { x : number ; y : number } > ( )
1965+ for ( const blockId of validBlockIds ) {
1966+ absolutePositions . set ( blockId , getNodeAbsolutePosition ( blockId ) )
1967+ }
1968+
1969+ for ( const blockId of validBlockIds ) {
1970+ const edgesForThisNode = boundaryEdges . filter (
1971+ ( e ) => e . source === blockId || e . target === blockId
1972+ )
1973+ removeEdgesForNode ( blockId , edgesForThisNode )
1974+ updateNodeParent ( blockId , null , edgesForThisNode )
1975+ }
1976+
1977+ // Immediately update displayNodes to prevent React Flow from using stale parent data
1978+ setDisplayNodes ( ( nodes ) =>
1979+ nodes . map ( ( n ) => {
1980+ const absPos = absolutePositions . get ( n . id )
1981+ if ( absPos ) {
1982+ return {
1983+ ...n ,
1984+ position : absPos ,
1985+ parentId : undefined ,
1986+ extent : undefined ,
1987+ }
1988+ }
1989+ return n
1990+ } )
1991+ )
1992+ } catch ( err ) {
1993+ logger . error ( 'Failed to remove from subflow' , { err } )
1994+ }
1995+ }
1996+
1997+ window . addEventListener ( 'remove-from-subflow' , handleRemoveFromSubflow as EventListener )
1998+ return ( ) =>
1999+ window . removeEventListener ( 'remove-from-subflow' , handleRemoveFromSubflow as EventListener )
2000+ } , [ blocks , edgesForDisplay , removeEdgesForNode , updateNodeParent , getNodeAbsolutePosition ] )
2001+
19682002 /** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */
19692003 const onNodesChange = useCallback ( ( changes : NodeChange [ ] ) => {
19702004 setDisplayNodes ( ( nds ) => applyNodeChanges ( changes , nds ) )
@@ -2409,6 +2443,8 @@ const WorkflowContent = React.memo(() => {
24092443 // Store the original parent ID when starting to drag
24102444 const currentParentId = blocks [ node . id ] ?. data ?. parentId || null
24112445 setDragStartParentId ( currentParentId )
2446+ // Initialize potentialParentId to the current parent so a click without movement doesn't remove from subflow
2447+ setPotentialParentId ( currentParentId )
24122448 // Store starting position for undo/redo move entry
24132449 setDragStartPosition ( {
24142450 id : node . id ,
@@ -2432,7 +2468,7 @@ const WorkflowContent = React.memo(() => {
24322468 }
24332469 } )
24342470 } ,
2435- [ blocks , setDragStartPosition , getNodes ]
2471+ [ blocks , setDragStartPosition , getNodes , potentialParentId , setPotentialParentId ]
24362472 )
24372473
24382474 /** Handles node drag stop to establish parent-child relationships. */
@@ -2666,12 +2702,29 @@ const WorkflowContent = React.memo(() => {
26662702 const affectedEdges = [ ...edgesToRemove , ...edgesToAdd ]
26672703 updateNodeParent ( node . id , potentialParentId , affectedEdges )
26682704
2705+ setDisplayNodes ( ( nodes ) =>
2706+ nodes . map ( ( n ) => {
2707+ if ( n . id === node . id ) {
2708+ return {
2709+ ...n ,
2710+ position : relativePositionBefore ,
2711+ parentId : potentialParentId ,
2712+ extent : 'parent' as const ,
2713+ }
2714+ }
2715+ return n
2716+ } )
2717+ )
2718+
26692719 // Now add the edges after parent update
26702720 edgesToAdd . forEach ( ( edge ) => addEdge ( edge ) )
26712721
26722722 window . dispatchEvent ( new CustomEvent ( 'skip-edge-recording' , { detail : { skip : false } } ) )
26732723 } else if ( ! potentialParentId && dragStartParentId ) {
26742724 // Moving OUT of a subflow to canvas
2725+ // Get absolute position BEFORE removing from parent
2726+ const absolutePosition = getNodeAbsolutePosition ( node . id )
2727+
26752728 // Remove edges connected to this node since it's leaving its parent
26762729 const edgesToRemove = edgesForDisplay . filter (
26772730 ( e ) => e . source === node . id || e . target === node . id
@@ -2690,6 +2743,21 @@ const WorkflowContent = React.memo(() => {
26902743 // Clear the parent relationship
26912744 updateNodeParent ( node . id , null , edgesToRemove )
26922745
2746+ // Immediately update displayNodes to prevent React Flow from using stale parent data
2747+ setDisplayNodes ( ( nodes ) =>
2748+ nodes . map ( ( n ) => {
2749+ if ( n . id === node . id ) {
2750+ return {
2751+ ...n ,
2752+ position : absolutePosition ,
2753+ parentId : undefined ,
2754+ extent : undefined ,
2755+ }
2756+ }
2757+ return n
2758+ } )
2759+ )
2760+
26932761 logger . info ( 'Moved node out of subflow' , {
26942762 blockId : node . id ,
26952763 sourceParentId : dragStartParentId ,
0 commit comments