From 62fa5c5cee7e6abf3e7335c0dcb2aa8e3bdd0fb0 Mon Sep 17 00:00:00 2001 From: Solomon Astley Date: Thu, 12 Mar 2026 09:44:52 -0700 Subject: [PATCH 1/6] Set `nodesSelectionActive` when zero selected nodes remain in flow --- packages/react/src/components/SelectionListener/index.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/react/src/components/SelectionListener/index.tsx b/packages/react/src/components/SelectionListener/index.tsx index 89ed2c264f..ea67a0488e 100644 --- a/packages/react/src/components/SelectionListener/index.tsx +++ b/packages/react/src/components/SelectionListener/index.tsx @@ -53,6 +53,10 @@ function SelectionListenerInner { const params = { nodes: selectedNodes as NodeType[], edges: selectedEdges as EdgeType[] }; + if (selectedNodes.length === 0 && store.getState().nodesSelectionActive) { + store.setState({ nodesSelectionActive: false }); + } + onSelectionChange?.(params); store.getState().onSelectionChangeHandlers.forEach((fn) => fn(params)); }, [selectedNodes, selectedEdges, onSelectionChange]); From dd54e86b91da29c1f58f646ad9a99f96f0c4a2e5 Mon Sep 17 00:00:00 2001 From: Solomon Astley Date: Thu, 12 Mar 2026 10:01:56 -0700 Subject: [PATCH 2/6] Add changeset --- .changeset/good-lamps-think.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/good-lamps-think.md diff --git a/.changeset/good-lamps-think.md b/.changeset/good-lamps-think.md new file mode 100644 index 0000000000..fa33aaa46d --- /dev/null +++ b/.changeset/good-lamps-think.md @@ -0,0 +1,5 @@ +--- +'@xyflow/react': patch +--- + +Ensure visual nodes selection state is cleared when zero selected nodes remain in the flow From befab3ba342aeb2a87766c1c6428e9a543173e35 Mon Sep 17 00:00:00 2001 From: Solomon Astley Date: Thu, 12 Mar 2026 10:08:09 -0700 Subject: [PATCH 3/6] Run effect to update `nodesSelectionActive` even when no selection change handlers are specified --- .../src/components/SelectionListener/index.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/react/src/components/SelectionListener/index.tsx b/packages/react/src/components/SelectionListener/index.tsx index ea67a0488e..0a592963d9 100644 --- a/packages/react/src/components/SelectionListener/index.tsx +++ b/packages/react/src/components/SelectionListener/index.tsx @@ -44,7 +44,7 @@ function areEqual(a: SelectorSlice, b: SelectorSlice) { ); } -function SelectionListenerInner({ +export function SelectionListener({ onSelectionChange, }: SelectionListenerProps) { const store = useStoreApi(); @@ -63,17 +63,3 @@ function SelectionListenerInner !!s.onSelectionChangeHandlers; - -export function SelectionListener({ - onSelectionChange, -}: SelectionListenerProps) { - const storeHasSelectionChangeHandlers = useStore(changeSelector); - - if (onSelectionChange || storeHasSelectionChangeHandlers) { - return onSelectionChange={onSelectionChange} />; - } - - return null; -} From 52d452b08ed92a39875afd4ab3eb79fed77ea2ea Mon Sep 17 00:00:00 2001 From: Solomon Astley Date: Tue, 17 Mar 2026 08:15:13 -0700 Subject: [PATCH 4/6] Centralize nodesSelectionActive update in setNodes of store --- .../components/SelectionListener/index.tsx | 20 +++++-- packages/react/src/store/index.ts | 57 ++++++++++++------- 2 files changed, 52 insertions(+), 25 deletions(-) diff --git a/packages/react/src/components/SelectionListener/index.tsx b/packages/react/src/components/SelectionListener/index.tsx index 0a592963d9..89ed2c264f 100644 --- a/packages/react/src/components/SelectionListener/index.tsx +++ b/packages/react/src/components/SelectionListener/index.tsx @@ -44,7 +44,7 @@ function areEqual(a: SelectorSlice, b: SelectorSlice) { ); } -export function SelectionListener({ +function SelectionListenerInner({ onSelectionChange, }: SelectionListenerProps) { const store = useStoreApi(); @@ -53,13 +53,23 @@ export function SelectionListener { const params = { nodes: selectedNodes as NodeType[], edges: selectedEdges as EdgeType[] }; - if (selectedNodes.length === 0 && store.getState().nodesSelectionActive) { - store.setState({ nodesSelectionActive: false }); - } - onSelectionChange?.(params); store.getState().onSelectionChangeHandlers.forEach((fn) => fn(params)); }, [selectedNodes, selectedEdges, onSelectionChange]); return null; } + +const changeSelector = (s: ReactFlowState) => !!s.onSelectionChangeHandlers; + +export function SelectionListener({ + onSelectionChange, +}: SelectionListenerProps) { + const storeHasSelectionChangeHandlers = useStore(changeSelector); + + if (onSelectionChange || storeHasSelectionChangeHandlers) { + return onSelectionChange={onSelectionChange} />; + } + + return null; +} diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts index 816059f008..392d53cf09 100644 --- a/packages/react/src/store/index.ts +++ b/packages/react/src/store/index.ts @@ -1,22 +1,22 @@ import { createWithEqualityFn } from 'zustand/traditional'; import { - adoptUserNodes, - updateAbsolutePositions, - panBy as panBySystem, - updateNodeInternals as updateNodeInternalsSystem, - updateConnectionLookup, - handleExpandParent, - NodeChange, - EdgeSelectionChange, - NodeSelectionChange, - ParentExpandChild, - initialConnection, - NodeOrigin, - CoordinateExtent, - fitViewport, - getHandlePosition, - Position, - ZIndexMode + adoptUserNodes, + updateAbsolutePositions, + panBy as panBySystem, + updateNodeInternals as updateNodeInternalsSystem, + updateConnectionLookup, + handleExpandParent, + NodeChange, + EdgeSelectionChange, + NodeSelectionChange, + ParentExpandChild, + initialConnection, + NodeOrigin, + CoordinateExtent, + fitViewport, + getHandlePosition, + Position, + ZIndexMode } from '@xyflow/system'; import { applyEdgeChanges, applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes'; @@ -97,7 +97,16 @@ const createStore = ({ zIndexMode, }), setNodes: (nodes: Node[]) => { - const { nodeLookup, parentLookup, nodeOrigin, elevateNodesOnSelect, fitViewQueued, zIndexMode } = get(); + const { + nodeLookup, + parentLookup, + nodeOrigin, + elevateNodesOnSelect, + fitViewQueued, + zIndexMode, + nodesSelectionActive + } = get(); + /* * setNodes() is called exclusively in response to user actions: * - either when the `` prop is updated in the controlled ReactFlow setup, @@ -115,11 +124,19 @@ const createStore = ({ zIndexMode, }); + const nextNodesSelectionActive = nodesSelectionActive && nodes.some((node) => node.selected); + if (fitViewQueued && nodesInitialized) { resolveFitView(); - set({ nodes, nodesInitialized, fitViewQueued: false, fitViewOptions: undefined }); + set({ + nodes, + nodesInitialized, + fitViewQueued: false, + fitViewOptions: undefined, + nodesSelectionActive: nextNodesSelectionActive + }); } else { - set({ nodes, nodesInitialized }); + set({ nodes, nodesInitialized, nodesSelectionActive: nextNodesSelectionActive }); } }, setEdges: (edges: Edge[]) => { From 759042d108b34317a12c94e2bb51c8ea4b3422e6 Mon Sep 17 00:00:00 2001 From: Solomon Astley Date: Tue, 17 Mar 2026 08:17:28 -0700 Subject: [PATCH 5/6] Fix spacing in store/index.ts --- packages/react/src/store/index.ts | 34 +++++++++++++++---------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts index 392d53cf09..d243010391 100644 --- a/packages/react/src/store/index.ts +++ b/packages/react/src/store/index.ts @@ -1,22 +1,22 @@ import { createWithEqualityFn } from 'zustand/traditional'; import { - adoptUserNodes, - updateAbsolutePositions, - panBy as panBySystem, - updateNodeInternals as updateNodeInternalsSystem, - updateConnectionLookup, - handleExpandParent, - NodeChange, - EdgeSelectionChange, - NodeSelectionChange, - ParentExpandChild, - initialConnection, - NodeOrigin, - CoordinateExtent, - fitViewport, - getHandlePosition, - Position, - ZIndexMode + adoptUserNodes, + updateAbsolutePositions, + panBy as panBySystem, + updateNodeInternals as updateNodeInternalsSystem, + updateConnectionLookup, + handleExpandParent, + NodeChange, + EdgeSelectionChange, + NodeSelectionChange, + ParentExpandChild, + initialConnection, + NodeOrigin, + CoordinateExtent, + fitViewport, + getHandlePosition, + Position, + ZIndexMode } from '@xyflow/system'; import { applyEdgeChanges, applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes'; From 0e48d846fa6e10ac7eed48bf19312ead2274f3ae Mon Sep 17 00:00:00 2001 From: Solomon Astley Date: Tue, 24 Mar 2026 16:11:41 -0700 Subject: [PATCH 6/6] Return `hasSelectedNodes` from `adoptUserNodes` and use it to update `nodesSelectionActive` --- packages/react/src/store/index.ts | 6 +++--- packages/react/src/store/initialState.ts | 2 +- .../svelte/src/lib/store/initial-store.svelte.ts | 2 +- packages/system/src/utils/store.ts | 12 ++++++++++-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts index d243010391..292d62ae58 100644 --- a/packages/react/src/store/index.ts +++ b/packages/react/src/store/index.ts @@ -104,7 +104,7 @@ const createStore = ({ elevateNodesOnSelect, fitViewQueued, zIndexMode, - nodesSelectionActive + nodesSelectionActive, } = get(); /* @@ -116,7 +116,7 @@ const createStore = ({ * relevant for internal React Flow operations. */ - const nodesInitialized = adoptUserNodes(nodes, nodeLookup, parentLookup, { + const { nodesInitialized, hasSelectedNodes } = adoptUserNodes(nodes, nodeLookup, parentLookup, { nodeOrigin, nodeExtent, elevateNodesOnSelect, @@ -124,7 +124,7 @@ const createStore = ({ zIndexMode, }); - const nextNodesSelectionActive = nodesSelectionActive && nodes.some((node) => node.selected); + const nextNodesSelectionActive = nodesSelectionActive && hasSelectedNodes; if (fitViewQueued && nodesInitialized) { resolveFitView(); diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts index 26a5c045d8..53470d11ac 100644 --- a/packages/react/src/store/initialState.ts +++ b/packages/react/src/store/initialState.ts @@ -56,7 +56,7 @@ const getInitialState = ({ const storeNodeExtent = nodeExtent ?? infiniteExtent; updateConnectionLookup(connectionLookup, edgeLookup, storeEdges); - const nodesInitialized = adoptUserNodes(storeNodes, nodeLookup, parentLookup, { + const { nodesInitialized } = adoptUserNodes(storeNodes, nodeLookup, parentLookup, { nodeOrigin: storeNodeOrigin, nodeExtent: storeNodeExtent, zIndexMode, diff --git a/packages/svelte/src/lib/store/initial-store.svelte.ts b/packages/svelte/src/lib/store/initial-store.svelte.ts index fb7dfc9994..0c1553ff93 100644 --- a/packages/svelte/src/lib/store/initial-store.svelte.ts +++ b/packages/svelte/src/lib/store/initial-store.svelte.ts @@ -120,7 +120,7 @@ export function getInitialStore(signals.props.zIndexMode ?? 'basic'); nodesInitialized: boolean = $derived.by(() => { - const nodesInitialized = adoptUserNodes(signals.nodes, this.nodeLookup, this.parentLookup, { + const { nodesInitialized } = adoptUserNodes(signals.nodes, this.nodeLookup, this.parentLookup, { nodeExtent: this.nodeExtent, nodeOrigin: this.nodeOrigin, elevateNodesOnSelect: signals.props.elevateNodesOnSelect ?? true, diff --git a/packages/system/src/utils/store.ts b/packages/system/src/utils/store.ts index b499dfee3d..1493c0b8dc 100644 --- a/packages/system/src/utils/store.ts +++ b/packages/system/src/utils/store.ts @@ -121,18 +121,24 @@ export function isManualZIndexMode(zIndexMode?: ZIndexMode): boolean { return zIndexMode === 'manual'; } +type AdoptUserNodesReturn = { + nodesInitialized: boolean; + hasSelectedNodes: boolean; +}; + export function adoptUserNodes( nodes: NodeType[], nodeLookup: NodeLookup>, parentLookup: ParentLookup>, options: UpdateNodesOptions = {} -): boolean { +): AdoptUserNodesReturn { const _options = mergeObjects(adoptUserNodesDefaultOptions, options); const rootParentIndex = { i: 0 }; const tmpLookup = new Map(nodeLookup); const selectedNodeZ: number = _options?.elevateNodesOnSelect && !isManualZIndexMode(_options.zIndexMode) ? SELECTED_NODE_Z : 0; let nodesInitialized = nodes.length > 0; + let hasSelectedNodes = false; nodeLookup.clear(); parentLookup.clear(); @@ -178,9 +184,11 @@ export function adoptUserNodes( if (userNode.parentId) { updateChildNode(internalNode, nodeLookup, parentLookup, options, rootParentIndex); } + + hasSelectedNodes ||= userNode.selected ?? false; } - return nodesInitialized; + return { nodesInitialized, hasSelectedNodes }; } function updateParentLookup(