From 6db2bd053ff516a484b5d10efee33bd8957fdcee Mon Sep 17 00:00:00 2001 From: Alaric Baraou Date: Sat, 21 Mar 2026 05:52:02 +0800 Subject: [PATCH 1/3] fix(react): prevent empty store during ReactFlow remount StoreUpdater was rendered after GraphView, so on remount its cleanup (reset) and repopulation effects fired after GraphView's subtree effects. Custom nodes reading the store in their effects would see empty nodes/nodeLookup/edges during this window. Move StoreUpdater before GraphView so its effects run first, and switch to useIsomorphicLayoutEffect so the store is repopulated before any child useEffect or useLayoutEffect fires. --- .../storeAvailabilityOnRemount.cy.tsx | 123 ++++++++++++++++++ .../src/components/StoreUpdater/index.tsx | 12 +- .../react/src/container/ReactFlow/index.tsx | 122 ++++++++--------- 3 files changed, 193 insertions(+), 64 deletions(-) create mode 100644 examples/react/cypress/components/reactflow/storeAvailabilityOnRemount.cy.tsx diff --git a/examples/react/cypress/components/reactflow/storeAvailabilityOnRemount.cy.tsx b/examples/react/cypress/components/reactflow/storeAvailabilityOnRemount.cy.tsx new file mode 100644 index 0000000000..602dcbdd25 --- /dev/null +++ b/examples/react/cypress/components/reactflow/storeAvailabilityOnRemount.cy.tsx @@ -0,0 +1,123 @@ +import React, { useCallback, useEffect, useLayoutEffect, useState } from 'react'; +import { ReactFlow, ReactFlowProvider, useStoreApi, type Node, type Edge, type NodeTypes, type NodeProps } from '@xyflow/react'; + +import { edges as initialEdges } from '../../fixtures/simpleflow'; + +type CheckResult = { nodesLength: number; lookupSize: number }; + +// We use a global ref so the custom node can report without prop drilling. +// Each test resets this before mounting. +let checkSpy: ((result: CheckResult) => void) | null = null; + +/** + * Custom node rendered inside GraphView's subtree. Verifies that + * the store (nodes, nodeLookup, etc.) is populated by the time + * custom node effects fire after a key-based remount. + */ +function CheckerNode({ data }: NodeProps) { + const store = useStoreApi(); + const useHook = data.useLayout ? useLayoutEffect : useEffect; + + useHook(() => { + if (checkSpy) { + const { nodes, nodeLookup } = store.getState(); + checkSpy({ nodesLength: nodes.length, lookupSize: nodeLookup.size }); + } + }); + + return
{data.label as string}
; +} + +const nodeTypes: NodeTypes = { checker: CheckerNode }; + +const makeNodes = (useLayout = false): Node[] => [ + { id: '1', type: 'checker', data: { label: 'Node 1', useLayout }, position: { x: 0, y: 0 } }, + { id: '2', type: 'checker', data: { label: 'Node 2', useLayout }, position: { x: 200, y: 200 } }, +]; + +describe(' remount: store availability', () => { + it('node useEffect sees populated store after key remount', () => { + const spy = cy.spy().as('spy'); + checkSpy = spy; + + const nodes = makeNodes(false); + + cy.mount( + + + + ); + + cy.get('@spy').should('have.been.calledWithMatch', { lookupSize: 2 }); + cy.get('[data-testid="remount"]').click(); + cy.get('@spy').should('always.have.been.calledWithMatch', { lookupSize: 2 }); + }); + + it('node useLayoutEffect sees populated store after key remount', () => { + const spy = cy.spy().as('spy'); + checkSpy = spy; + + const nodes = makeNodes(true); + + cy.mount( + + + + ); + + cy.get('@spy').should('have.been.calledWithMatch', { lookupSize: 2 }); + cy.get('[data-testid="remount"]').click(); + cy.get('@spy').should('always.have.been.calledWithMatch', { lookupSize: 2 }); + }); + + it('node useEffect sees populated store after key remount with defaultNodes', () => { + const spy = cy.spy().as('spy'); + checkSpy = spy; + + const nodes = makeNodes(false); + + const DefaultFlow = () => { + const [remountKey, setRemountKey] = useState(0); + + return ( + <> + + +
+ + + ); + }; + + cy.mount( + + + + ); + + cy.get('@spy').should('have.been.calledWithMatch', { lookupSize: 2 }); + cy.get('[data-testid="remount"]').click(); + cy.get('@spy').should('always.have.been.calledWithMatch', { lookupSize: 2 }); + }); +}); + +const RemountableFlow = ({ nodes, edges }: { nodes: Node[]; edges: Edge[] }) => { + const [remountKey, setRemountKey] = useState(0); + + const handleRemount = useCallback(() => { + setRemountKey((k) => k + 1); + }, []); + + return ( + <> + + +
+ + + ); +}; diff --git a/packages/react/src/components/StoreUpdater/index.tsx b/packages/react/src/components/StoreUpdater/index.tsx index 223e62bc27..a22895a418 100644 --- a/packages/react/src/components/StoreUpdater/index.tsx +++ b/packages/react/src/components/StoreUpdater/index.tsx @@ -3,10 +3,11 @@ * We distinguish between values we can update directly with `useDirectStoreUpdater` (like `snapGrid`) * and values that have a dedicated setter function in the store (like `setNodes`). */ -import { useEffect, useRef } from 'react'; +import { useRef } from 'react'; import { shallow } from 'zustand/shallow'; import { infiniteExtent, type CoordinateExtent, mergeAriaLabelConfig, AriaLabelConfig } from '@xyflow/system'; +import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect'; import { useStore, useStoreApi } from '../../hooks/useStore'; import type { Node, Edge, ReactFlowState, ReactFlowProps, FitViewOptions } from '../../types'; import { defaultNodeOrigin } from '../../container/ReactFlow/init-values'; @@ -125,7 +126,12 @@ export function StoreUpdater(); - useEffect(() => { + // We use layout effects here so that the store is always populated before + // any child useEffect or useLayoutEffect fires. With regular useEffect, the + // cleanup calls reset() which empties the store, and child effects can run + // before the new mount effect repopulates it — causing children to read + // empty nodeLookup/nodes/edges during a remount. + useIsomorphicLayoutEffect(() => { setDefaultNodesAndEdges(props.defaultNodes, props.defaultEdges); return () => { @@ -137,7 +143,7 @@ export function StoreUpdater>>(initPrevValues); - useEffect( + useIsomorphicLayoutEffect( () => { for (const fieldName of fieldsToTrack) { const fieldValue = props[fieldName]; diff --git a/packages/react/src/container/ReactFlow/index.tsx b/packages/react/src/container/ReactFlow/index.tsx index b04f295589..1bbe506c2b 100644 --- a/packages/react/src/container/ReactFlow/index.tsx +++ b/packages/react/src/container/ReactFlow/index.tsx @@ -189,6 +189,67 @@ function ReactFlow( nodeExtent={nodeExtent} zIndexMode={zIndexMode} > + + nodes={nodes} + edges={edges} + defaultNodes={defaultNodes} + defaultEdges={defaultEdges} + onConnect={onConnect} + onConnectStart={onConnectStart} + onConnectEnd={onConnectEnd} + onClickConnectStart={onClickConnectStart} + onClickConnectEnd={onClickConnectEnd} + nodesDraggable={nodesDraggable} + autoPanOnNodeFocus={autoPanOnNodeFocus} + nodesConnectable={nodesConnectable} + nodesFocusable={nodesFocusable} + edgesFocusable={edgesFocusable} + edgesReconnectable={edgesReconnectable} + elementsSelectable={elementsSelectable} + elevateNodesOnSelect={elevateNodesOnSelect} + elevateEdgesOnSelect={elevateEdgesOnSelect} + minZoom={minZoom} + maxZoom={maxZoom} + nodeExtent={nodeExtent} + onNodesChange={onNodesChange} + onEdgesChange={onEdgesChange} + snapToGrid={snapToGrid} + snapGrid={snapGrid} + connectionMode={connectionMode} + translateExtent={translateExtent} + connectOnClick={connectOnClick} + defaultEdgeOptions={defaultEdgeOptions} + fitView={fitView} + fitViewOptions={fitViewOptions} + onNodesDelete={onNodesDelete} + onEdgesDelete={onEdgesDelete} + onDelete={onDelete} + onNodeDragStart={onNodeDragStart} + onNodeDrag={onNodeDrag} + onNodeDragStop={onNodeDragStop} + onSelectionDrag={onSelectionDrag} + onSelectionDragStart={onSelectionDragStart} + onSelectionDragStop={onSelectionDragStop} + onMove={onMove} + onMoveStart={onMoveStart} + onMoveEnd={onMoveEnd} + noPanClassName={noPanClassName} + nodeOrigin={nodeOrigin} + rfId={rfId} + autoPanOnConnect={autoPanOnConnect} + autoPanOnNodeDrag={autoPanOnNodeDrag} + autoPanSpeed={autoPanSpeed} + onError={onError} + connectionRadius={connectionRadius} + isValidConnection={isValidConnection} + selectNodesOnDrag={selectNodesOnDrag} + nodeDragThreshold={nodeDragThreshold} + connectionDragThreshold={connectionDragThreshold} + onBeforeDelete={onBeforeDelete} + debug={debug} + ariaLabelConfig={ariaLabelConfig} + zIndexMode={zIndexMode} + /> onInit={onInit} onNodeClick={onNodeClick} @@ -254,67 +315,6 @@ function ReactFlow( viewport={viewport} onViewportChange={onViewportChange} /> - - nodes={nodes} - edges={edges} - defaultNodes={defaultNodes} - defaultEdges={defaultEdges} - onConnect={onConnect} - onConnectStart={onConnectStart} - onConnectEnd={onConnectEnd} - onClickConnectStart={onClickConnectStart} - onClickConnectEnd={onClickConnectEnd} - nodesDraggable={nodesDraggable} - autoPanOnNodeFocus={autoPanOnNodeFocus} - nodesConnectable={nodesConnectable} - nodesFocusable={nodesFocusable} - edgesFocusable={edgesFocusable} - edgesReconnectable={edgesReconnectable} - elementsSelectable={elementsSelectable} - elevateNodesOnSelect={elevateNodesOnSelect} - elevateEdgesOnSelect={elevateEdgesOnSelect} - minZoom={minZoom} - maxZoom={maxZoom} - nodeExtent={nodeExtent} - onNodesChange={onNodesChange} - onEdgesChange={onEdgesChange} - snapToGrid={snapToGrid} - snapGrid={snapGrid} - connectionMode={connectionMode} - translateExtent={translateExtent} - connectOnClick={connectOnClick} - defaultEdgeOptions={defaultEdgeOptions} - fitView={fitView} - fitViewOptions={fitViewOptions} - onNodesDelete={onNodesDelete} - onEdgesDelete={onEdgesDelete} - onDelete={onDelete} - onNodeDragStart={onNodeDragStart} - onNodeDrag={onNodeDrag} - onNodeDragStop={onNodeDragStop} - onSelectionDrag={onSelectionDrag} - onSelectionDragStart={onSelectionDragStart} - onSelectionDragStop={onSelectionDragStop} - onMove={onMove} - onMoveStart={onMoveStart} - onMoveEnd={onMoveEnd} - noPanClassName={noPanClassName} - nodeOrigin={nodeOrigin} - rfId={rfId} - autoPanOnConnect={autoPanOnConnect} - autoPanOnNodeDrag={autoPanOnNodeDrag} - autoPanSpeed={autoPanSpeed} - onError={onError} - connectionRadius={connectionRadius} - isValidConnection={isValidConnection} - selectNodesOnDrag={selectNodesOnDrag} - nodeDragThreshold={nodeDragThreshold} - connectionDragThreshold={connectionDragThreshold} - onBeforeDelete={onBeforeDelete} - debug={debug} - ariaLabelConfig={ariaLabelConfig} - zIndexMode={zIndexMode} - /> onSelectionChange={onSelectionChange} /> {children} From 64115cd086d2c04235f1cae80acb45455fd0de49 Mon Sep 17 00:00:00 2001 From: Alaric Baraou Date: Mon, 23 Mar 2026 09:18:52 +0800 Subject: [PATCH 2/3] address review: reset spy history before remount, add changeset Reset spy history before clicking remount so the post-remount assertion only checks calls from the fresh mount, not pre-remount calls that trivially pass. --- .changeset/fix-store-reset-timing.md | 5 +++++ .../components/reactflow/storeAvailabilityOnRemount.cy.tsx | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 .changeset/fix-store-reset-timing.md diff --git a/.changeset/fix-store-reset-timing.md b/.changeset/fix-store-reset-timing.md new file mode 100644 index 0000000000..bbb4027171 --- /dev/null +++ b/.changeset/fix-store-reset-timing.md @@ -0,0 +1,5 @@ +--- +'@xyflow/react': patch +--- + +Fix empty store during ReactFlow remount by reordering StoreUpdater before GraphView and using layout effects diff --git a/examples/react/cypress/components/reactflow/storeAvailabilityOnRemount.cy.tsx b/examples/react/cypress/components/reactflow/storeAvailabilityOnRemount.cy.tsx index 602dcbdd25..af1a2acb87 100644 --- a/examples/react/cypress/components/reactflow/storeAvailabilityOnRemount.cy.tsx +++ b/examples/react/cypress/components/reactflow/storeAvailabilityOnRemount.cy.tsx @@ -49,7 +49,9 @@ describe(' remount: store availability', () => { ); cy.get('@spy').should('have.been.calledWithMatch', { lookupSize: 2 }); + cy.then(() => spy.resetHistory()); cy.get('[data-testid="remount"]').click(); + cy.get('@spy').should('have.been.called'); cy.get('@spy').should('always.have.been.calledWithMatch', { lookupSize: 2 }); }); @@ -66,7 +68,9 @@ describe(' remount: store availability', () => { ); cy.get('@spy').should('have.been.calledWithMatch', { lookupSize: 2 }); + cy.then(() => spy.resetHistory()); cy.get('[data-testid="remount"]').click(); + cy.get('@spy').should('have.been.called'); cy.get('@spy').should('always.have.been.calledWithMatch', { lookupSize: 2 }); }); @@ -98,7 +102,9 @@ describe(' remount: store availability', () => { ); cy.get('@spy').should('have.been.calledWithMatch', { lookupSize: 2 }); + cy.then(() => spy.resetHistory()); cy.get('[data-testid="remount"]').click(); + cy.get('@spy').should('have.been.called'); cy.get('@spy').should('always.have.been.calledWithMatch', { lookupSize: 2 }); }); }); From a6c938fb2e5ed030512ef75d665ac80dc3a66bc6 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Wed, 25 Mar 2026 10:54:09 +0100 Subject: [PATCH 3/3] Explicitly allow missing `type` field in BuiltInNode type definition This improves the DX in cases like this: const NODES: BuiltInNode[] = [ // ~~~~~~~~~~~~~ // TS error here currently, because these nodes // don't explicitly set `type: "default"` { id: "1", position: { x: 0, y: 0 }, data: { label: "Node 1" } }, { id: "2", position: { x: 100, y: 100 }, data: { label: "Node 2" } }, ]; --- .changeset/allow-undefined-builtin-node-type.md | 6 ++++++ packages/react/src/types/nodes.ts | 2 +- packages/svelte/src/lib/types/nodes.ts | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .changeset/allow-undefined-builtin-node-type.md diff --git a/.changeset/allow-undefined-builtin-node-type.md b/.changeset/allow-undefined-builtin-node-type.md new file mode 100644 index 0000000000..b89e83b1b5 --- /dev/null +++ b/.changeset/allow-undefined-builtin-node-type.md @@ -0,0 +1,6 @@ +--- +"@xyflow/react": patch +"@xyflow/svelte": patch +--- + +Allow `type` field to be missing in `BuiltInNode` (no `type` field is the same as `type: "default"`) diff --git a/packages/react/src/types/nodes.ts b/packages/react/src/types/nodes.ts index 4c85a54b76..98f20d662c 100644 --- a/packages/react/src/types/nodes.ts +++ b/packages/react/src/types/nodes.ts @@ -93,7 +93,7 @@ export type NodeWrapperProps = { * ``` */ export type BuiltInNode = - | Node<{ label: string }, 'input' | 'output' | 'default'> + | Node<{ label: string }, 'input' | 'output' | 'default' | undefined> | Node, 'group'>; /** diff --git a/packages/svelte/src/lib/types/nodes.ts b/packages/svelte/src/lib/types/nodes.ts index 7088e2ea47..faef351eee 100644 --- a/packages/svelte/src/lib/types/nodes.ts +++ b/packages/svelte/src/lib/types/nodes.ts @@ -63,5 +63,5 @@ export type NodeTypes = Record< >; export type BuiltInNode = - | Node<{ label: string }, 'input' | 'output' | 'default'> + | Node<{ label: string }, 'input' | 'output' | 'default' | undefined> | Node, 'group'>;