From cec7da54dfff818c18cbf3267bcf383888260ad7 Mon Sep 17 00:00:00 2001 From: peterkogo Date: Thu, 28 Aug 2025 10:44:54 +0200 Subject: [PATCH 1/8] initial version of middlewares --- .../react/src/examples/Overview/index.tsx | 36 ++++++- .../src/components/BatchProvider/index.tsx | 24 ++++- packages/react/src/hooks/useMiddleware.ts | 97 +++++++++++++++++++ packages/react/src/index.ts | 2 + packages/react/src/store/index.ts | 8 +- packages/react/src/store/initialState.ts | 5 + packages/react/src/types/store.ts | 5 + 7 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 packages/react/src/hooks/useMiddleware.ts diff --git a/examples/react/src/examples/Overview/index.tsx b/examples/react/src/examples/Overview/index.tsx index 8dddbb0d..8e6a3441 100644 --- a/examples/react/src/examples/Overview/index.tsx +++ b/examples/react/src/examples/Overview/index.tsx @@ -17,6 +17,8 @@ import { ConnectionMode, OnBeforeDelete, OnDelete, + useNodeChangeMiddleware, + NodeChange, } from '@xyflow/react'; const onNodeDragStart = (_: ReactMouseEvent, node: Node, nodes: Node[]) => console.log('drag start', node, nodes); @@ -191,6 +193,37 @@ const nodeColor = (n: Node): string => { return '#fff'; }; +function RestrictExtent({ + minX = -Infinity, + minY = -Infinity, + maxX = Infinity, + maxY = Infinity, +}: { + minX?: number; + minY?: number; + maxX?: number; + maxY?: number; +}) { + useNodeChangeMiddleware( + useCallback( + (changes: NodeChange[]) => { + return changes.map((change) => { + if (change.type === 'position' && change.position) { + const { position } = change; + position.x = Math.min(Math.max(position.x, minX), maxX); + position.y = Math.min(Math.max(position.y, minY), maxY); + change.position = position; + } + return change; + }); + }, + [minX, minY, maxX, maxY] + ) + ); + + return null; +} + const OverviewFlow = () => { const [nodes, , onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); @@ -234,8 +267,9 @@ const OverviewFlow = () => { maxZoom={Infinity} onBeforeDelete={onBeforeDelete} onDelete={onDelete} - onPaneMouseMove={onPaneMouseMove} + // onPaneMouseMove={onPaneMouseMove} > + diff --git a/packages/react/src/components/BatchProvider/index.tsx b/packages/react/src/components/BatchProvider/index.tsx index b2ed270f..d70d6fa8 100644 --- a/packages/react/src/components/BatchProvider/index.tsx +++ b/packages/react/src/components/BatchProvider/index.tsx @@ -28,7 +28,15 @@ export function BatchProvider(); const nodeQueueHandler = useCallback((queueItems: QueueItem[]) => { - const { nodes = [], setNodes, hasDefaultNodes, onNodesChange, nodeLookup, fitViewQueued } = store.getState(); + const { + nodes = [], + setNodes, + hasDefaultNodes, + onNodesChange, + nodeLookup, + fitViewQueued, + nodeChangeMiddleware, + } = store.getState(); /* * This is essentially an `Array.reduce` in imperative clothing. Processing @@ -40,11 +48,23 @@ export function BatchProvider[]; + console.log('this workds'); + + for (const middleware of nodeChangeMiddleware.values()) { + changes = middleware(changes); + } + + // for (const middleware of nodeSyncMiddleware.values()) { + // changes = middleware(changes); + // } + + // Do we always want to setNodes(next) or only when there are changes? + if (hasDefaultNodes) { setNodes(next); } diff --git a/packages/react/src/hooks/useMiddleware.ts b/packages/react/src/hooks/useMiddleware.ts new file mode 100644 index 00000000..b2192564 --- /dev/null +++ b/packages/react/src/hooks/useMiddleware.ts @@ -0,0 +1,97 @@ +import { NodeChange } from '@xyflow/system'; +import { useEffect, useRef } from 'react'; +import { useStoreApi } from './useStore'; +import { Edge, Node } from '../types'; + +/** + * We identify each middleware with a symbol that is unique for each hook instance. + */ + +function useSymbol() { + const symbol = useRef(); + if (!symbol.current) { + symbol.current = Symbol(); + } + return symbol; +} + +export function useNodeChangeMiddleware( + fn: (changes: NodeChange[]) => NodeChange[] +) { + const store = useStoreApi(); + const symbol = useSymbol(); + + useEffect(() => { + const { nodeChangeMiddleware } = store.getState(); + nodeChangeMiddleware.set(symbol.current!, fn); + }, [fn]); + + useEffect(() => { + const { nodeChangeMiddleware } = store.getState(); + return () => { + nodeChangeMiddleware.delete(symbol.current!); + }; + }, []); +} + +export function useNodeChangeMiddlewareDynamic( + fn: (changes: NodeChange[]) => NodeChange[] +) { + const store = useStoreApi(); + const symbol = useSymbol(); + + const { nodeChangeMiddleware } = store.getState(); + nodeChangeMiddleware.delete(symbol.current!); + nodeChangeMiddleware.set(symbol.current!, fn); +} + +// export function useEdgeChangeMiddleware(fn: (changes: EdgeChange[]) => EdgeChange[]) { +// const store = useStoreApi(); +// const symbol = useSymbol(); + +// useEffect(() => { +// const { edgeChangeMiddleware } = store.getState(); +// edgeChangeMiddleware.set(symbol.current!, fn); +// }, [fn]); + +// useEffect(() => { +// const { edgeChangeMiddleware } = store.getState(); +// return () => { +// edgeChangeMiddleware.delete(symbol.current!); +// }; +// }, []); +// } + +// export function useNodeSyncMiddleware(fn: (changes: NodeChange[]) => NodeChange[]) { +// const store = useStoreApi(); +// const symbol = useSymbol(); + +// useEffect(() => { +// const { nodeSyncMiddleware } = store.getState(); +// nodeSyncMiddleware.set(symbol.current!, fn); +// }, [fn]); + +// useEffect(() => { +// const { nodeSyncMiddleware } = store.getState(); +// return () => { +// nodeSyncMiddleware.delete(symbol.current!); +// }; +// }, []); +// } + +// export function useEdgeSyncMiddleware(fn: (changes: EdgeChange[]) => EdgeChange[]) { +// const store = useStoreApi(); +// const symbol = useSymbol(); + +// useEffect(() => { +// const { edgeSyncMiddleware } = store.getState(); +// edgeSyncMiddleware.set(symbol.current!, fn); +// }, [fn]); + +// useEffect(() => { +// const { edgeSyncMiddleware } = store.getState(); +// return () => { +// edgeSyncMiddleware.delete(symbol.current!); +// }; +// }, []); +// } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b4a67f2e..859e51e8 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -30,6 +30,8 @@ export { useConnection } from './hooks/useConnection'; export { useInternalNode } from './hooks/useInternalNode'; export { useNodeId } from './contexts/NodeIdContext'; +export { useNodeChangeMiddleware, useNodeChangeMiddlewareDynamic } from './hooks/useMiddleware'; + export { applyNodeChanges, applyEdgeChanges } from './utils/changes'; export { isNode, isEdge } from './utils/general'; diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts index 085f8b1e..0391b041 100644 --- a/packages/react/src/store/index.ts +++ b/packages/react/src/store/index.ts @@ -175,8 +175,8 @@ const createStore = ({ }, updateNodePositions: (nodeDragItems, dragging = false) => { const parentExpandChildren: ParentExpandChild[] = []; - const changes = []; - const { nodeLookup, triggerNodeChanges } = get(); + let changes = []; + const { nodeLookup, triggerNodeChanges, nodeChangeMiddleware } = get(); for (const [id, dragItem] of nodeDragItems) { // we are using the nodelookup to be sure to use the current expandParent and parentId value @@ -216,6 +216,10 @@ const createStore = ({ changes.push(...parentExpandChanges); } + for (const middleware of nodeChangeMiddleware.values()) { + changes = middleware(changes); + } + triggerNodeChanges(changes); }, triggerNodeChanges: (changes) => { diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts index bfc9baaa..917ef3e7 100644 --- a/packages/react/src/store/initialState.ts +++ b/packages/react/src/store/initialState.ts @@ -146,6 +146,11 @@ const getInitialState = ({ lib: 'react', debug: false, ariaLabelConfig: defaultAriaLabelConfig, + + nodeChangeMiddleware: new Map(), + edgeChangeMiddleware: new Map(), + nodeSyncMiddleware: new Map(), + edgeSyncMiddleware: new Map(), }; }; diff --git a/packages/react/src/types/store.ts b/packages/react/src/types/store.ts index 1fe0d308..a1ab5d64 100644 --- a/packages/react/src/types/store.ts +++ b/packages/react/src/types/store.ts @@ -152,6 +152,11 @@ export type ReactFlowStore[]) => NodeChange[]>; + edgeChangeMiddleware: Map[]) => EdgeChange[]>; + nodeSyncMiddleware: Map[]) => NodeChange[]>; + edgeSyncMiddleware: Map[]) => EdgeChange[]>; }; export type ReactFlowActions = { From e23d4f0ed616f485fc1e961ba28279d999c6e3a9 Mon Sep 17 00:00:00 2001 From: peterkogo Date: Thu, 28 Aug 2025 11:16:19 +0200 Subject: [PATCH 2/8] add middleware example --- examples/react/src/App/routes.ts | 6 + .../react/src/examples/Middlewares/index.tsx | 125 ++++++++++++++++++ .../react/src/examples/Overview/index.tsx | 34 +---- .../src/components/BatchProvider/index.tsx | 2 - 4 files changed, 132 insertions(+), 35 deletions(-) create mode 100644 examples/react/src/examples/Middlewares/index.tsx diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts index 52f80979..d781ff4d 100644 --- a/examples/react/src/App/routes.ts +++ b/examples/react/src/App/routes.ts @@ -59,6 +59,7 @@ import DevTools from '../examples/DevTools'; import Redux from '../examples/Redux'; import MovingHandles from '../examples/MovingHandles'; import DetachedHandle from '../examples/DetachedHandle'; +import Middlewares from '../examples/Middlewares'; export interface IRoute { name: string; @@ -232,6 +233,11 @@ const routes: IRoute[] = [ path: 'layouting', component: Layouting, }, + { + name: 'Middlewares', + path: 'middlewares', + component: Middlewares, + }, { name: 'Multi setNodes', path: 'multi-setnodes', diff --git a/examples/react/src/examples/Middlewares/index.tsx b/examples/react/src/examples/Middlewares/index.tsx new file mode 100644 index 00000000..b3950b3d --- /dev/null +++ b/examples/react/src/examples/Middlewares/index.tsx @@ -0,0 +1,125 @@ +import { useCallback } from 'react'; +import { + ReactFlow, + MiniMap, + Background, + BackgroundVariant, + Controls, + ReactFlowProvider, + Node, + Edge, + useReactFlow, + Panel, + useNodesState, + useEdgesState, + addEdge, + Connection, + useNodeChangeMiddleware, + NodeChange, +} from '@xyflow/react'; +import { initialNodes, initialEdges } from '../CancelConnection/data'; + +const a = { id: 'a', data: { label: 'A' }, position: { x: 250, y: 5 } }; +const b = { id: 'b', data: { label: 'B' }, position: { x: 100, y: 100 } }; +const c = { id: 'c', data: { label: 'C' }, position: { x: 400, y: 100 } }; + +function RestrictExtent({ + minX = -Infinity, + minY = -Infinity, + maxX = Infinity, + maxY = Infinity, +}: { + minX?: number; + minY?: number; + maxX?: number; + maxY?: number; +}) { + useNodeChangeMiddleware( + useCallback( + (changes: NodeChange[]) => { + return changes.map((change) => { + const { type } = change; + if (type === 'position') { + const { position } = change; + if (position) { + position.x = Math.min(Math.max(position.x, minX), maxX); + position.y = Math.min(Math.max(position.y, minY), maxY); + change.position = position; + } + } else if (type === 'add' || type === 'replace') { + const { item } = change; + if (item) { + item.position.x = Math.min(Math.max(item.position.x, minX), maxX); + item.position.y = Math.min(Math.max(item.position.y, minY), maxY); + change.item = item; + } + } + + return change; + }); + }, + [minX, minY, maxX, maxY] + ) + ); + + return null; +} + +const SetNotesBatchingFlow = () => { + const { setNodes, updateNode } = useReactFlow(); + + const [nodes, , onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + const onConnect = useCallback((params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)), [setEdges]); + + const triggerMultipleSetNodes = useCallback(() => { + setNodes([a]); + setNodes((nodes) => [...nodes, b]); + setNodes((nodes) => [...nodes, c]); + setNodes((nodes) => + nodes.map((node) => + node.id === 'a' ? { ...node, position: { x: node.position.x + 2000, y: node.position.y + 20 } } : node + ) + ); + }, []); + + const triggerMultipleUpdateNodes = useCallback(() => { + triggerMultipleSetNodes(); + updateNode('a', (a) => ({ position: { x: a.position.x + 20, y: a.position.y + 20 } })); + updateNode('b', (b) => ({ position: { x: b.position.x + 20, y: b.position.y + 20 } })); + updateNode('c', (c) => ({ position: { x: c.position.x + 20, y: c.position.y + 20 } })); + updateNode('a', (a) => ({ data: { ...a.data, label: `A ${Date.now()}` } })); + updateNode('b', (b) => ({ data: { ...b.data, label: `B ${Date.now()}` } })); + updateNode('c', (c) => ({ data: { ...c.data, label: `C ${Date.now()}` } })); + }, []); + + return ( + + + + + + + + + + + ); +}; + +export default function App() { + return ( + + + + ); +} diff --git a/examples/react/src/examples/Overview/index.tsx b/examples/react/src/examples/Overview/index.tsx index 8e6a3441..7add02f0 100644 --- a/examples/react/src/examples/Overview/index.tsx +++ b/examples/react/src/examples/Overview/index.tsx @@ -193,37 +193,6 @@ const nodeColor = (n: Node): string => { return '#fff'; }; -function RestrictExtent({ - minX = -Infinity, - minY = -Infinity, - maxX = Infinity, - maxY = Infinity, -}: { - minX?: number; - minY?: number; - maxX?: number; - maxY?: number; -}) { - useNodeChangeMiddleware( - useCallback( - (changes: NodeChange[]) => { - return changes.map((change) => { - if (change.type === 'position' && change.position) { - const { position } = change; - position.x = Math.min(Math.max(position.x, minX), maxX); - position.y = Math.min(Math.max(position.y, minY), maxY); - change.position = position; - } - return change; - }); - }, - [minX, minY, maxX, maxY] - ) - ); - - return null; -} - const OverviewFlow = () => { const [nodes, , onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); @@ -267,9 +236,8 @@ const OverviewFlow = () => { maxZoom={Infinity} onBeforeDelete={onBeforeDelete} onDelete={onDelete} - // onPaneMouseMove={onPaneMouseMove} + onPaneMouseMove={onPaneMouseMove} > - diff --git a/packages/react/src/components/BatchProvider/index.tsx b/packages/react/src/components/BatchProvider/index.tsx index d70d6fa8..28f3aa4b 100644 --- a/packages/react/src/components/BatchProvider/index.tsx +++ b/packages/react/src/components/BatchProvider/index.tsx @@ -53,8 +53,6 @@ export function BatchProvider[]; - console.log('this workds'); - for (const middleware of nodeChangeMiddleware.values()) { changes = middleware(changes); } From 12ae96129c64629a52bc25284aeeb43f3e05207f Mon Sep 17 00:00:00 2001 From: peterkogo Date: Thu, 28 Aug 2025 11:17:26 +0200 Subject: [PATCH 3/8] remove imports --- examples/react/src/examples/Overview/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/react/src/examples/Overview/index.tsx b/examples/react/src/examples/Overview/index.tsx index 7add02f0..8dddbb0d 100644 --- a/examples/react/src/examples/Overview/index.tsx +++ b/examples/react/src/examples/Overview/index.tsx @@ -17,8 +17,6 @@ import { ConnectionMode, OnBeforeDelete, OnDelete, - useNodeChangeMiddleware, - NodeChange, } from '@xyflow/react'; const onNodeDragStart = (_: ReactMouseEvent, node: Node, nodes: Node[]) => console.log('drag start', node, nodes); From 8726778c02b74444e8a0ae7143e98b711e3ea205 Mon Sep 17 00:00:00 2001 From: peterkogo Date: Fri, 29 Aug 2025 11:24:10 +0200 Subject: [PATCH 4/8] improved middleware example --- .../examples/Middlewares/RestrictExtent.tsx | 55 +++++++++++++++++++ .../react/src/examples/Middlewares/index.tsx | 52 ++---------------- 2 files changed, 61 insertions(+), 46 deletions(-) create mode 100644 examples/react/src/examples/Middlewares/RestrictExtent.tsx diff --git a/examples/react/src/examples/Middlewares/RestrictExtent.tsx b/examples/react/src/examples/Middlewares/RestrictExtent.tsx new file mode 100644 index 00000000..beec2630 --- /dev/null +++ b/examples/react/src/examples/Middlewares/RestrictExtent.tsx @@ -0,0 +1,55 @@ +import { NodeChange, Panel, useNodeChangeMiddleware } from '@xyflow/react'; +import { useCallback, useState } from 'react'; + +export function RestrictExtent({ + label = 'Restrict Extent', + minX = -Infinity, + minY = -Infinity, + maxX = Infinity, + maxY = Infinity, +}: { + label?: string; + minX?: number; + minY?: number; + maxX?: number; + maxY?: number; +}) { + const [isEnabled, setIsEnabled] = useState(false); + useNodeChangeMiddleware( + useCallback( + (changes: NodeChange[]) => { + if (!isEnabled) return changes; + return changes.map((change) => { + const { type } = change; + if (type === 'position') { + const { position } = change; + if (position) { + position.x = Math.min(Math.max(position.x, minX), maxX); + position.y = Math.min(Math.max(position.y, minY), maxY); + change.position = position; + } + } else if (type === 'add' || type === 'replace') { + const { item } = change; + if (item) { + item.position.x = Math.min(Math.max(item.position.x, minX), maxX); + item.position.y = Math.min(Math.max(item.position.y, minY), maxY); + change.item = item; + } + } + + return change; + }); + }, + [minX, minY, maxX, maxY, isEnabled] + ) + ); + + return ( +
+ +
+ ); +} diff --git a/examples/react/src/examples/Middlewares/index.tsx b/examples/react/src/examples/Middlewares/index.tsx index b3950b3d..c7a05770 100644 --- a/examples/react/src/examples/Middlewares/index.tsx +++ b/examples/react/src/examples/Middlewares/index.tsx @@ -6,7 +6,6 @@ import { BackgroundVariant, Controls, ReactFlowProvider, - Node, Edge, useReactFlow, Panel, @@ -14,57 +13,14 @@ import { useEdgesState, addEdge, Connection, - useNodeChangeMiddleware, - NodeChange, } from '@xyflow/react'; import { initialNodes, initialEdges } from '../CancelConnection/data'; +import { RestrictExtent } from './RestrictExtent'; const a = { id: 'a', data: { label: 'A' }, position: { x: 250, y: 5 } }; const b = { id: 'b', data: { label: 'B' }, position: { x: 100, y: 100 } }; const c = { id: 'c', data: { label: 'C' }, position: { x: 400, y: 100 } }; -function RestrictExtent({ - minX = -Infinity, - minY = -Infinity, - maxX = Infinity, - maxY = Infinity, -}: { - minX?: number; - minY?: number; - maxX?: number; - maxY?: number; -}) { - useNodeChangeMiddleware( - useCallback( - (changes: NodeChange[]) => { - return changes.map((change) => { - const { type } = change; - if (type === 'position') { - const { position } = change; - if (position) { - position.x = Math.min(Math.max(position.x, minX), maxX); - position.y = Math.min(Math.max(position.y, minY), maxY); - change.position = position; - } - } else if (type === 'add' || type === 'replace') { - const { item } = change; - if (item) { - item.position.x = Math.min(Math.max(item.position.x, minX), maxX); - item.position.y = Math.min(Math.max(item.position.y, minY), maxY); - change.item = item; - } - } - - return change; - }); - }, - [minX, minY, maxX, maxY] - ) - ); - - return null; -} - const SetNotesBatchingFlow = () => { const { setNodes, updateNode } = useReactFlow(); @@ -99,15 +55,19 @@ const SetNotesBatchingFlow = () => { edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} + onConnect={onConnect} className="react-flow-basic-example" minZoom={0.2} maxZoom={4} fitView > + + + + - From 3c0a625d7e97707ef0de0d07cb98179e6aaac797 Mon Sep 17 00:00:00 2001 From: moklick Date: Wed, 8 Oct 2025 21:44:20 +0200 Subject: [PATCH 5/8] chore(middleware): cleanup --- .../examples/Middlewares/RestrictExtent.tsx | 5 +- .../src/components/BatchProvider/index.tsx | 10 +-- packages/react/src/hooks/useMiddleware.ts | 87 +++++-------------- packages/react/src/index.ts | 5 +- packages/react/src/store/index.ts | 4 +- packages/react/src/store/initialState.ts | 6 +- packages/react/src/types/store.ts | 6 +- 7 files changed, 36 insertions(+), 87 deletions(-) diff --git a/examples/react/src/examples/Middlewares/RestrictExtent.tsx b/examples/react/src/examples/Middlewares/RestrictExtent.tsx index beec2630..7a447414 100644 --- a/examples/react/src/examples/Middlewares/RestrictExtent.tsx +++ b/examples/react/src/examples/Middlewares/RestrictExtent.tsx @@ -1,4 +1,4 @@ -import { NodeChange, Panel, useNodeChangeMiddleware } from '@xyflow/react'; +import { NodeChange, experimental_useOnNodesChangeMiddleware } from '@xyflow/react'; import { useCallback, useState } from 'react'; export function RestrictExtent({ @@ -15,7 +15,7 @@ export function RestrictExtent({ maxY?: number; }) { const [isEnabled, setIsEnabled] = useState(false); - useNodeChangeMiddleware( + experimental_useOnNodesChangeMiddleware( useCallback( (changes: NodeChange[]) => { if (!isEnabled) return changes; @@ -36,7 +36,6 @@ export function RestrictExtent({ change.item = item; } } - return change; }); }, diff --git a/packages/react/src/components/BatchProvider/index.tsx b/packages/react/src/components/BatchProvider/index.tsx index 28f3aa4b..f6c6436c 100644 --- a/packages/react/src/components/BatchProvider/index.tsx +++ b/packages/react/src/components/BatchProvider/index.tsx @@ -35,7 +35,7 @@ export function BatchProvider[]; - for (const middleware of nodeChangeMiddleware.values()) { + for (const middleware of onNodesChangeMiddlewareMap.values()) { changes = middleware(changes); } - // for (const middleware of nodeSyncMiddleware.values()) { - // changes = middleware(changes); - // } - - // Do we always want to setNodes(next) or only when there are changes? - if (hasDefaultNodes) { setNodes(next); } diff --git a/packages/react/src/hooks/useMiddleware.ts b/packages/react/src/hooks/useMiddleware.ts index b2192564..a83a0c85 100644 --- a/packages/react/src/hooks/useMiddleware.ts +++ b/packages/react/src/hooks/useMiddleware.ts @@ -1,12 +1,12 @@ -import { NodeChange } from '@xyflow/system'; import { useEffect, useRef } from 'react'; +import type { EdgeChange, NodeChange } from '@xyflow/system'; + import { useStoreApi } from './useStore'; -import { Edge, Node } from '../types'; +import type { Edge, Node } from '../types'; /** * We identify each middleware with a symbol that is unique for each hook instance. */ - function useSymbol() { const symbol = useRef(); if (!symbol.current) { @@ -15,83 +15,40 @@ function useSymbol() { return symbol; } -export function useNodeChangeMiddleware( +export function experimental_useOnNodesChangeMiddleware( fn: (changes: NodeChange[]) => NodeChange[] ) { const store = useStoreApi(); const symbol = useSymbol(); useEffect(() => { - const { nodeChangeMiddleware } = store.getState(); - nodeChangeMiddleware.set(symbol.current!, fn); + const { onNodesChangeMiddlewareMap } = store.getState(); + onNodesChangeMiddlewareMap.set(symbol.current!, fn); }, [fn]); useEffect(() => { - const { nodeChangeMiddleware } = store.getState(); + const { onNodesChangeMiddlewareMap } = store.getState(); return () => { - nodeChangeMiddleware.delete(symbol.current!); + onNodesChangeMiddlewareMap.delete(symbol.current!); }; }, []); } -export function useNodeChangeMiddlewareDynamic( - fn: (changes: NodeChange[]) => NodeChange[] +export function experimental_useOnEdgesChangeMiddleware( + fn: (changes: EdgeChange[]) => EdgeChange[] ) { - const store = useStoreApi(); + const store = useStoreApi(); const symbol = useSymbol(); - const { nodeChangeMiddleware } = store.getState(); - nodeChangeMiddleware.delete(symbol.current!); - nodeChangeMiddleware.set(symbol.current!, fn); + useEffect(() => { + const { onEdgesChangeMiddlewareMap } = store.getState(); + onEdgesChangeMiddlewareMap.set(symbol.current!, fn); + }, [fn]); + + useEffect(() => { + const { onEdgesChangeMiddlewareMap } = store.getState(); + return () => { + onEdgesChangeMiddlewareMap.delete(symbol.current!); + }; + }, []); } - -// export function useEdgeChangeMiddleware(fn: (changes: EdgeChange[]) => EdgeChange[]) { -// const store = useStoreApi(); -// const symbol = useSymbol(); - -// useEffect(() => { -// const { edgeChangeMiddleware } = store.getState(); -// edgeChangeMiddleware.set(symbol.current!, fn); -// }, [fn]); - -// useEffect(() => { -// const { edgeChangeMiddleware } = store.getState(); -// return () => { -// edgeChangeMiddleware.delete(symbol.current!); -// }; -// }, []); -// } - -// export function useNodeSyncMiddleware(fn: (changes: NodeChange[]) => NodeChange[]) { -// const store = useStoreApi(); -// const symbol = useSymbol(); - -// useEffect(() => { -// const { nodeSyncMiddleware } = store.getState(); -// nodeSyncMiddleware.set(symbol.current!, fn); -// }, [fn]); - -// useEffect(() => { -// const { nodeSyncMiddleware } = store.getState(); -// return () => { -// nodeSyncMiddleware.delete(symbol.current!); -// }; -// }, []); -// } - -// export function useEdgeSyncMiddleware(fn: (changes: EdgeChange[]) => EdgeChange[]) { -// const store = useStoreApi(); -// const symbol = useSymbol(); - -// useEffect(() => { -// const { edgeSyncMiddleware } = store.getState(); -// edgeSyncMiddleware.set(symbol.current!, fn); -// }, [fn]); - -// useEffect(() => { -// const { edgeSyncMiddleware } = store.getState(); -// return () => { -// edgeSyncMiddleware.delete(symbol.current!); -// }; -// }, []); -// } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 859e51e8..c6809d33 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -30,7 +30,10 @@ export { useConnection } from './hooks/useConnection'; export { useInternalNode } from './hooks/useInternalNode'; export { useNodeId } from './contexts/NodeIdContext'; -export { useNodeChangeMiddleware, useNodeChangeMiddlewareDynamic } from './hooks/useMiddleware'; +export { + experimental_useOnNodesChangeMiddleware, + experimental_useOnEdgesChangeMiddleware, +} from './hooks/useMiddleware'; export { applyNodeChanges, applyEdgeChanges } from './utils/changes'; export { isNode, isEdge } from './utils/general'; diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts index 0391b041..28984d91 100644 --- a/packages/react/src/store/index.ts +++ b/packages/react/src/store/index.ts @@ -176,7 +176,7 @@ const createStore = ({ updateNodePositions: (nodeDragItems, dragging = false) => { const parentExpandChildren: ParentExpandChild[] = []; let changes = []; - const { nodeLookup, triggerNodeChanges, nodeChangeMiddleware } = get(); + const { nodeLookup, triggerNodeChanges, onNodesChangeMiddlewareMap } = get(); for (const [id, dragItem] of nodeDragItems) { // we are using the nodelookup to be sure to use the current expandParent and parentId value @@ -216,7 +216,7 @@ const createStore = ({ changes.push(...parentExpandChanges); } - for (const middleware of nodeChangeMiddleware.values()) { + for (const middleware of onNodesChangeMiddlewareMap.values()) { changes = middleware(changes); } diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts index 917ef3e7..4ef80e74 100644 --- a/packages/react/src/store/initialState.ts +++ b/packages/react/src/store/initialState.ts @@ -147,10 +147,8 @@ const getInitialState = ({ debug: false, ariaLabelConfig: defaultAriaLabelConfig, - nodeChangeMiddleware: new Map(), - edgeChangeMiddleware: new Map(), - nodeSyncMiddleware: new Map(), - edgeSyncMiddleware: new Map(), + onNodesChangeMiddlewareMap: new Map(), + onEdgesChangeMiddlewareMap: new Map(), }; }; diff --git a/packages/react/src/types/store.ts b/packages/react/src/types/store.ts index a1ab5d64..36f0538f 100644 --- a/packages/react/src/types/store.ts +++ b/packages/react/src/types/store.ts @@ -153,10 +153,8 @@ export type ReactFlowStore[]) => NodeChange[]>; - edgeChangeMiddleware: Map[]) => EdgeChange[]>; - nodeSyncMiddleware: Map[]) => NodeChange[]>; - edgeSyncMiddleware: Map[]) => EdgeChange[]>; + onNodesChangeMiddlewareMap: Map[]) => NodeChange[]>; + onEdgesChangeMiddlewareMap: Map[]) => EdgeChange[]>; }; export type ReactFlowActions = { From 88c5480bf500b7b966d1bd76a8b5cc303fce2e48 Mon Sep 17 00:00:00 2001 From: moklick Date: Wed, 8 Oct 2025 21:47:35 +0200 Subject: [PATCH 6/8] chore(middleware): cleanup --- packages/react/src/hooks/useMiddleware.ts | 54 ------------------- .../src/hooks/useOnEdgesChangeMiddleware.ts | 24 +++++++++ .../src/hooks/useOnNodesChangeMiddleware.ts | 24 +++++++++ packages/react/src/index.ts | 6 +-- 4 files changed, 50 insertions(+), 58 deletions(-) delete mode 100644 packages/react/src/hooks/useMiddleware.ts create mode 100644 packages/react/src/hooks/useOnEdgesChangeMiddleware.ts create mode 100644 packages/react/src/hooks/useOnNodesChangeMiddleware.ts diff --git a/packages/react/src/hooks/useMiddleware.ts b/packages/react/src/hooks/useMiddleware.ts deleted file mode 100644 index a83a0c85..00000000 --- a/packages/react/src/hooks/useMiddleware.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useEffect, useRef } from 'react'; -import type { EdgeChange, NodeChange } from '@xyflow/system'; - -import { useStoreApi } from './useStore'; -import type { Edge, Node } from '../types'; - -/** - * We identify each middleware with a symbol that is unique for each hook instance. - */ -function useSymbol() { - const symbol = useRef(); - if (!symbol.current) { - symbol.current = Symbol(); - } - return symbol; -} - -export function experimental_useOnNodesChangeMiddleware( - fn: (changes: NodeChange[]) => NodeChange[] -) { - const store = useStoreApi(); - const symbol = useSymbol(); - - useEffect(() => { - const { onNodesChangeMiddlewareMap } = store.getState(); - onNodesChangeMiddlewareMap.set(symbol.current!, fn); - }, [fn]); - - useEffect(() => { - const { onNodesChangeMiddlewareMap } = store.getState(); - return () => { - onNodesChangeMiddlewareMap.delete(symbol.current!); - }; - }, []); -} - -export function experimental_useOnEdgesChangeMiddleware( - fn: (changes: EdgeChange[]) => EdgeChange[] -) { - const store = useStoreApi(); - const symbol = useSymbol(); - - useEffect(() => { - const { onEdgesChangeMiddlewareMap } = store.getState(); - onEdgesChangeMiddlewareMap.set(symbol.current!, fn); - }, [fn]); - - useEffect(() => { - const { onEdgesChangeMiddlewareMap } = store.getState(); - return () => { - onEdgesChangeMiddlewareMap.delete(symbol.current!); - }; - }, []); -} diff --git a/packages/react/src/hooks/useOnEdgesChangeMiddleware.ts b/packages/react/src/hooks/useOnEdgesChangeMiddleware.ts new file mode 100644 index 00000000..237b5280 --- /dev/null +++ b/packages/react/src/hooks/useOnEdgesChangeMiddleware.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react'; +import type { EdgeChange } from '@xyflow/system'; + +import { useStoreApi } from './useStore'; +import type { Edge, Node } from '../types'; + +export function experimental_useOnEdgesChangeMiddleware( + fn: (changes: EdgeChange[]) => EdgeChange[] +) { + const store = useStoreApi(); + const [symbol] = useState(() => Symbol()); + + useEffect(() => { + const { onEdgesChangeMiddlewareMap } = store.getState(); + onEdgesChangeMiddlewareMap.set(symbol, fn); + }, [fn]); + + useEffect(() => { + const { onEdgesChangeMiddlewareMap } = store.getState(); + return () => { + onEdgesChangeMiddlewareMap.delete(symbol); + }; + }, []); +} diff --git a/packages/react/src/hooks/useOnNodesChangeMiddleware.ts b/packages/react/src/hooks/useOnNodesChangeMiddleware.ts new file mode 100644 index 00000000..cf9231f8 --- /dev/null +++ b/packages/react/src/hooks/useOnNodesChangeMiddleware.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react'; +import type { NodeChange } from '@xyflow/system'; + +import { useStoreApi } from './useStore'; +import type { Edge, Node } from '../types'; + +export function experimental_useOnNodesChangeMiddleware( + fn: (changes: NodeChange[]) => NodeChange[] +) { + const store = useStoreApi(); + const [symbol] = useState(() => Symbol()); + + useEffect(() => { + const { onNodesChangeMiddlewareMap } = store.getState(); + onNodesChangeMiddlewareMap.set(symbol, fn); + }, [fn]); + + useEffect(() => { + const { onNodesChangeMiddlewareMap } = store.getState(); + return () => { + onNodesChangeMiddlewareMap.delete(symbol); + }; + }, []); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index c6809d33..f251ba5d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -30,10 +30,8 @@ export { useConnection } from './hooks/useConnection'; export { useInternalNode } from './hooks/useInternalNode'; export { useNodeId } from './contexts/NodeIdContext'; -export { - experimental_useOnNodesChangeMiddleware, - experimental_useOnEdgesChangeMiddleware, -} from './hooks/useMiddleware'; +export { experimental_useOnNodesChangeMiddleware } from './hooks/useOnNodesChangeMiddleware'; +export { experimental_useOnEdgesChangeMiddleware } from './hooks/useOnEdgesChangeMiddleware'; export { applyNodeChanges, applyEdgeChanges } from './utils/changes'; export { isNode, isEdge } from './utils/general'; From a523919d6789995e9d0f3dd29b0b47fc3b8d8439 Mon Sep 17 00:00:00 2001 From: moklick Date: Tue, 2 Dec 2025 16:40:26 +0100 Subject: [PATCH 7/8] chore(middleware): cleanup --- .changeset/selfish-nails-sing.md | 5 +++++ packages/react/src/hooks/useOnEdgesChangeMiddleware.ts | 6 ++++++ packages/react/src/hooks/useOnNodesChangeMiddleware.ts | 6 ++++++ 3 files changed, 17 insertions(+) create mode 100644 .changeset/selfish-nails-sing.md diff --git a/.changeset/selfish-nails-sing.md b/.changeset/selfish-nails-sing.md new file mode 100644 index 00000000..d2119c6b --- /dev/null +++ b/.changeset/selfish-nails-sing.md @@ -0,0 +1,5 @@ +--- +'@xyflow/react': patch +--- + +Add `experimental_useOnNodesChangeMiddleware` hook diff --git a/packages/react/src/hooks/useOnEdgesChangeMiddleware.ts b/packages/react/src/hooks/useOnEdgesChangeMiddleware.ts index 237b5280..d79d6773 100644 --- a/packages/react/src/hooks/useOnEdgesChangeMiddleware.ts +++ b/packages/react/src/hooks/useOnEdgesChangeMiddleware.ts @@ -4,6 +4,12 @@ import type { EdgeChange } from '@xyflow/system'; import { useStoreApi } from './useStore'; import type { Edge, Node } from '../types'; +/** + * Registers a middleware function to transform edge changes. + * + * @public + * @param fn - Middleware function. Should be memoized with useCallback to avoid re-registration. + */ export function experimental_useOnEdgesChangeMiddleware( fn: (changes: EdgeChange[]) => EdgeChange[] ) { diff --git a/packages/react/src/hooks/useOnNodesChangeMiddleware.ts b/packages/react/src/hooks/useOnNodesChangeMiddleware.ts index cf9231f8..fe7ea6f6 100644 --- a/packages/react/src/hooks/useOnNodesChangeMiddleware.ts +++ b/packages/react/src/hooks/useOnNodesChangeMiddleware.ts @@ -4,6 +4,12 @@ import type { NodeChange } from '@xyflow/system'; import { useStoreApi } from './useStore'; import type { Edge, Node } from '../types'; +/** + * Registers a middleware function to transform node changes. + * + * @public + * @param fn - Middleware function. Should be memoized with useCallback to avoid re-registration. + */ export function experimental_useOnNodesChangeMiddleware( fn: (changes: NodeChange[]) => NodeChange[] ) { From 2b77a1e228bf54e5e422fea8f4c473a37bf8e361 Mon Sep 17 00:00:00 2001 From: Moritz Klack Date: Tue, 2 Dec 2025 16:43:46 +0100 Subject: [PATCH 8/8] Update '@xyflow/react' version and add new hook This change updates the version of '@xyflow/react' from patch to minor and adds the 'experimental_useOnNodesChangeMiddleware' hook. --- .changeset/selfish-nails-sing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/selfish-nails-sing.md b/.changeset/selfish-nails-sing.md index d2119c6b..9cbc5b81 100644 --- a/.changeset/selfish-nails-sing.md +++ b/.changeset/selfish-nails-sing.md @@ -1,5 +1,5 @@ --- -'@xyflow/react': patch +'@xyflow/react': minor --- Add `experimental_useOnNodesChangeMiddleware` hook