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/.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 new file mode 100644 index 0000000000..af1a2acb87 --- /dev/null +++ b/examples/react/cypress/components/reactflow/storeAvailabilityOnRemount.cy.tsx @@ -0,0 +1,129 @@ +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.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 }); + }); + + 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.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 }); + }); + + 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.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 }); + }); +}); + +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} 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'>;