From 5fa95c2413b9cd8898a3270ee76908f248d6dda5 Mon Sep 17 00:00:00 2001 From: moklick Date: Mon, 8 Jan 2024 15:02:09 +0100 Subject: [PATCH 1/8] chore(react): add multi-setnodes example --- .../src/examples/MultiSetNodes/index.tsx | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 examples/react/src/examples/MultiSetNodes/index.tsx diff --git a/examples/react/src/examples/MultiSetNodes/index.tsx b/examples/react/src/examples/MultiSetNodes/index.tsx new file mode 100644 index 00000000..f05c2b50 --- /dev/null +++ b/examples/react/src/examples/MultiSetNodes/index.tsx @@ -0,0 +1,87 @@ +import { useCallback } from 'react'; +import { + ReactFlow, + Controls, + addEdge, + Connection, + useNodesState, + useEdgesState, + Background, + Node, + Edge, + Panel, + useReactFlow, + ReactFlowProvider, +} from '@xyflow/react'; + +const initNodes: Node[] = [ + { + id: '1', + data: { + label: 'hallo', + }, + position: { x: 0, y: 0 }, + }, + { + id: '2', + data: { + label: 'world', + }, + position: { x: 200, y: 0 }, + }, +]; + +const initEdges: Edge[] = []; + +const CustomNodeFlow = () => { + const { setNodes } = useReactFlow(); + const [nodes, , onNodesChange] = useNodesState(initNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); + + const onConnect = useCallback((connection: Connection) => setEdges((eds) => addEdge(connection, eds)), [setEdges]); + + const updateNodes = () => { + setNodes((nds) => + nds.map((n) => { + if (n.id === '1') { + return { ...n, data: { label: 'updated' } }; + } + + return n; + }) + ); + + setNodes((nds) => + nds.map((n) => { + if (n.id === '2') { + return { ...n, data: { label: 'updated' } }; + } + + return n; + }) + ); + }; + + return ( + + + + + + + + ); +}; + +export default () => ( + + + +); From 9d4ecff2eebccbc2618651f034de423b7f690106 Mon Sep 17 00:00:00 2001 From: moklick Date: Mon, 8 Jan 2024 18:01:45 +0100 Subject: [PATCH 2/8] refactor(react/setNodes): naive batching --- examples/react/src/App/routes.ts | 6 ++ .../src/examples/MultiSetNodes/index.tsx | 60 +++++++++---------- .../src/examples/MultiSetNodes/style.css | 3 + packages/react/src/hooks/useReactFlow.ts | 32 +++++++--- 4 files changed, 61 insertions(+), 40 deletions(-) create mode 100644 examples/react/src/examples/MultiSetNodes/style.css diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts index 2f3d55ba..76ab08e4 100644 --- a/examples/react/src/App/routes.ts +++ b/examples/react/src/App/routes.ts @@ -21,6 +21,7 @@ import Interaction from '../examples/Interaction'; import Intersection from '../examples/Intersection'; import Layouting from '../examples/Layouting'; import MultiFlows from '../examples/MultiFlows'; +import MultiSetNodes from '../examples/MultiSetNodes'; import NodeResizer from '../examples/NodeResizer'; import NodeTypeChange from '../examples/NodeTypeChange'; import NodeTypesObjectChange from '../examples/NodeTypesObjectChange'; @@ -180,6 +181,11 @@ const routes: IRoute[] = [ path: 'layouting', component: Layouting, }, + { + name: 'Multi setNodes', + path: 'multi-setnodes', + component: MultiSetNodes, + }, { name: 'Multi Flows', path: 'multiflows', diff --git a/examples/react/src/examples/MultiSetNodes/index.tsx b/examples/react/src/examples/MultiSetNodes/index.tsx index f05c2b50..f04e0ddf 100644 --- a/examples/react/src/examples/MultiSetNodes/index.tsx +++ b/examples/react/src/examples/MultiSetNodes/index.tsx @@ -14,52 +14,45 @@ import { ReactFlowProvider, } from '@xyflow/react'; -const initNodes: Node[] = [ - { - id: '1', +import './style.css'; + +const initNodes: Node[] = []; + +for (let i = 0; i < 100; i++) { + initNodes.push({ + id: i.toString(), data: { - label: 'hallo', + label: `node ${i + 1}`, }, - position: { x: 0, y: 0 }, - }, - { - id: '2', - data: { - label: 'world', - }, - position: { x: 200, y: 0 }, - }, -]; + position: { x: (i % 10) * 60, y: Math.floor(i / 10) * 60 }, + }); +} const initEdges: Edge[] = []; const CustomNodeFlow = () => { - const { setNodes } = useReactFlow(); + const { setNodes, updateNodeData } = useReactFlow(); const [nodes, , onNodesChange] = useNodesState(initNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); const onConnect = useCallback((connection: Connection) => setEdges((eds) => addEdge(connection, eds)), [setEdges]); - const updateNodes = () => { - setNodes((nds) => - nds.map((n) => { - if (n.id === '1') { - return { ...n, data: { label: 'updated' } }; - } + const multiSetNodes = () => { + nodes.forEach((node) => + setNodes((nds) => + nds.map((n) => { + if (n.id === node.id) { + return { ...n, data: { label: 'node set' } }; + } - return n; - }) + return n; + }) + ) ); + }; - setNodes((nds) => - nds.map((n) => { - if (n.id === '2') { - return { ...n, data: { label: 'updated' } }; - } - - return n; - }) - ); + const multiUpdateNodes = () => { + nodes.forEach((node) => updateNodeData(node.id, { label: 'node update' })); }; return ( @@ -74,7 +67,8 @@ const CustomNodeFlow = () => { - + + ); diff --git a/examples/react/src/examples/MultiSetNodes/style.css b/examples/react/src/examples/MultiSetNodes/style.css new file mode 100644 index 00000000..11f62226 --- /dev/null +++ b/examples/react/src/examples/MultiSetNodes/style.css @@ -0,0 +1,3 @@ +.react-flow .react-flow__node { + width: 50px; +} diff --git a/packages/react/src/hooks/useReactFlow.ts b/packages/react/src/hooks/useReactFlow.ts index 198599d5..f933664e 100644 --- a/packages/react/src/hooks/useReactFlow.ts +++ b/packages/react/src/hooks/useReactFlow.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import { getElementsToRemove, getOverlappingArea, isRectObject, nodeToRect, type Rect } from '@xyflow/system'; import useViewportHelper from './useViewportHelper'; @@ -30,6 +30,10 @@ export function useReactFlow(); + const setNodesTimeout = useRef>(); + const getNodes = useCallback>(() => { return store.getState().nodes.map((n) => ({ ...n })) as NodeType[]; }, []); @@ -50,16 +54,30 @@ export function useReactFlow>((payload) => { const { nodes, setNodes, hasDefaultNodes, onNodesChange } = store.getState(); - const nextNodes = typeof payload === 'function' ? payload(nodes as NodeType[]) : payload; + setNodesData.current = setNodesData.current || nodes; + const nextNodes = typeof payload === 'function' ? payload(setNodesData.current as NodeType[]) : payload; + + setNodesData.current = nextNodes; if (hasDefaultNodes) { setNodes(nextNodes); } else if (onNodesChange) { - const changes = - nextNodes.length === 0 - ? nodes.map((node) => ({ type: 'remove', id: node.id } as NodeRemoveChange)) - : nextNodes.map((node) => ({ item: node, type: 'reset' } as NodeResetChange)); - onNodesChange(changes); + if (setNodesTimeout.current) { + clearTimeout(setNodesTimeout.current); + } + + // if there are multiple synchronous setNodes calls, we only want to call onNodesChange once + // for this, we use a timeout to wait for the last call and store updated nodes in setNodesData + // this is not perfect, but should work in most cases + setNodesTimeout.current = setTimeout(() => { + const changes = + nextNodes.length === 0 + ? nodes.map((node) => ({ type: 'remove', id: node.id } as NodeRemoveChange)) + : nextNodes.map((node) => ({ item: node, type: 'reset' } as NodeResetChange)); + onNodesChange(changes); + + setNodesData.current = undefined; + }, 0); } }, []); From 8c05ec419ebc1f5dd9e0cc542fedf7010c21e5fd Mon Sep 17 00:00:00 2001 From: Hayleigh Thompson Date: Wed, 10 Jan 2024 18:17:58 +0000 Subject: [PATCH 3/8] :recycle: Refactor 'applyChanges' for better perf. --- packages/react/src/utils/changes.ts | 169 ++++++++++++++++------------ 1 file changed, 94 insertions(+), 75 deletions(-) diff --git a/packages/react/src/utils/changes.ts b/packages/react/src/utils/changes.ts index be9460d0..0aa089a0 100644 --- a/packages/react/src/utils/changes.ts +++ b/packages/react/src/utils/changes.ts @@ -54,100 +54,119 @@ function applyChanges(changes: any[], elements: any[]): any[] { return changes.filter((c) => c.type === 'reset').map((c) => c.item); } - let remainingChanges = []; const updatedElements: any[] = []; + // By storing a map of changes for each element, we can a quick lookup as we + // iterate over the elements array! + const changesMap = new Map(); for (const change of changes) { if (change.type === 'add') { updatedElements.push(change.item); + continue; + } else if (change.type === 'remove') { + // For a 'remove' change we can safely ignore any other changes queued for + // the same element, it's going to be removed anyway! + changesMap.set(change.id, [change]); } else { - remainingChanges.push(change); + const elementChanges = changesMap.get(change.id); + // If we already have a 'remove' change queued for this element, we don't + // need to bother queueing any other changes for it, + // + // We might end up needlessly iterating over this changes array but in + // practice it will be very small (e.g. less than 5 or so items). + if (elementChanges?.some((c) => c.type === 'remove')) continue; + if (elementChanges) { + // If we have some changes queued already, we can do a mutable update of + // that array and save ourselves some copying. + elementChanges.push(change); + } else { + changesMap.set(change.id, [change]); + } } } - for (const item of elements) { - const nextChanges: any[] = []; - const _remainingChanges: any[] = []; + for (const element of elements) { + const changes = changesMap.get(element.id); - for (const change of remainingChanges) { - if (change.id === item.id) { - nextChanges.push(change); - } else { - _remainingChanges.push(change); - } - } - - remainingChanges = _remainingChanges; - - if (nextChanges.length === 0) { - updatedElements.push(item); + // When there are no changes for an element we can just push it unmodified, + // no need to copy it. + if (!changes) { + updatedElements.push(element); continue; } - const updateItem = { ...item }; - - for (const currentChange of nextChanges) { - if (currentChange) { - switch (currentChange.type) { - case 'select': { - updateItem.selected = currentChange.selected; - break; - } - case 'position': { - if (typeof currentChange.position !== 'undefined') { - updateItem.position = currentChange.position; - } - - if (typeof currentChange.positionAbsolute !== 'undefined') { - if (!updateItem.computed) { - updateItem.computed = {}; - } - updateItem.computed.positionAbsolute = currentChange.positionAbsolute; - } - - if (typeof currentChange.dragging !== 'undefined') { - updateItem.dragging = currentChange.dragging; - } - - if (updateItem.expandParent) { - handleParentExpand(updatedElements, updateItem); - } - break; - } - case 'dimensions': { - if (typeof currentChange.dimensions !== 'undefined') { - if (!updateItem.computed) { - updateItem.computed = {}; - } - updateItem.computed.width = currentChange.dimensions.width; - updateItem.computed.height = currentChange.dimensions.height; - } - - if (typeof currentChange.updateStyle !== 'undefined') { - updateItem.style = { ...(updateItem.style || {}), ...currentChange.dimensions }; - } - - if (typeof currentChange.resizing === 'boolean') { - updateItem.resizing = currentChange.resizing; - } - - if (updateItem.expandParent) { - handleParentExpand(updatedElements, updateItem); - } - break; - } - case 'remove': { - continue; - } - } - } - updatedElements.push(updateItem); + // If we have a 'remove' change queued, it'll be the only change in the array + if (changes[0].type === 'remove') { + continue; } + + // For other types of changes, we want to start with a shallow copy of the + // object so React knows this element has changed. Sequential changes will + /// each _mutate_ this object, so there's only ever one copy. + const updatedElement = { ...element }; + + for (const change of changes) { + applyChange(change, updatedElement, elements); + } + + updatedElements.push(updatedElement); } return updatedElements; } +// Applies a single change to an element. This is a *mutable* update. +function applyChange(change: any, element: any, elements: any[] = []): any { + switch (change.type) { + case 'select': { + element.selected = change.selected; + break; + } + + case 'position': { + if (typeof change.position !== 'undefined') { + element.position = change.position; + } + + if (typeof change.positionAbsolute !== 'undefined') { + element.computed ??= {}; + element.computed.positionAbsolute = change.positionAbsolute; + } + + if (typeof change.dragging !== 'undefined') { + element.dragging = change.dragging; + } + + if (element.expandParent) { + handleParentExpand(elements, element); + } + break; + } + + case 'dimensions': { + if (typeof change.dimensions !== 'undefined') { + element.computed ??= {}; + element.computed.width = change.dimensions.width; + element.computed.height = change.dimensions.height; + } + + if (typeof change.updateStyle !== 'undefined') { + element.style = Object.assign({}, element.style, change.updateStyle); + } + + if (typeof change.resizing === 'boolean') { + element.resizing = change.resizing; + } + + if (element.expandParent) { + handleParentExpand(elements, element); + } + + break; + } + } +} + /** * Drop in function that applies node changes to an array of nodes. * @public From f4def7d6c1e162845d8930576194bb33465d6893 Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 11 Jan 2024 10:27:07 +0100 Subject: [PATCH 4/8] refactor(react/changes): handle resize --- examples/react/src/examples/MultiSetNodes/index.tsx | 1 + examples/react/src/examples/MultiSetNodes/style.css | 2 +- packages/react/src/utils/changes.ts | 7 ++++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/react/src/examples/MultiSetNodes/index.tsx b/examples/react/src/examples/MultiSetNodes/index.tsx index f04e0ddf..fcb49924 100644 --- a/examples/react/src/examples/MultiSetNodes/index.tsx +++ b/examples/react/src/examples/MultiSetNodes/index.tsx @@ -63,6 +63,7 @@ const CustomNodeFlow = () => { onEdgesChange={onEdgesChange} onConnect={onConnect} fitView + className="multiset" > diff --git a/examples/react/src/examples/MultiSetNodes/style.css b/examples/react/src/examples/MultiSetNodes/style.css index 11f62226..2f427666 100644 --- a/examples/react/src/examples/MultiSetNodes/style.css +++ b/examples/react/src/examples/MultiSetNodes/style.css @@ -1,3 +1,3 @@ -.react-flow .react-flow__node { +.multiset .react-flow__node { width: 50px; } diff --git a/packages/react/src/utils/changes.ts b/packages/react/src/utils/changes.ts index 62593219..9f2df299 100644 --- a/packages/react/src/utils/changes.ts +++ b/packages/react/src/utils/changes.ts @@ -152,10 +152,11 @@ function applyChange(change: any, element: any, elements: any[] = []): any { element.computed ??= {}; element.computed.width = change.dimensions.width; element.computed.height = change.dimensions.height; - } - if (typeof change.updateStyle !== 'undefined') { - element.style = Object.assign({}, element.style, change.updateStyle); + if (change.resizing) { + element.width = change.dimensions.width; + element.height = change.dimensions.height; + } } if (typeof change.resizing === 'boolean') { From cf6c9a86b79f1c70befdd86b0616b2a6044effb6 Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 11 Jan 2024 11:08:32 +0100 Subject: [PATCH 5/8] refactor(react/changes): cleanup --- packages/react/src/utils/changes.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/react/src/utils/changes.ts b/packages/react/src/utils/changes.ts index 9f2df299..87b87c3a 100644 --- a/packages/react/src/utils/changes.ts +++ b/packages/react/src/utils/changes.ts @@ -73,12 +73,7 @@ function applyChanges(changes: any[], elements: any[]): any[] { changesMap.set(change.id, [change]); } else { const elementChanges = changesMap.get(change.id); - // If we already have a 'remove' change queued for this element, we don't - // need to bother queueing any other changes for it, - // - // We might end up needlessly iterating over this changes array but in - // practice it will be very small (e.g. less than 5 or so items). - if (elementChanges?.some((c) => c.type === 'remove')) continue; + if (elementChanges) { // If we have some changes queued already, we can do a mutable update of // that array and save ourselves some copying. From 2ba35de18622a11bccb63c1b0226dffeeedfe9c6 Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 11 Jan 2024 16:03:02 +0100 Subject: [PATCH 6/8] refactor(applyChanges): use specific replace events instead of a rough reset --- .../components/utils/apply-changes.cy.ts | 12 +-- .../react/src/examples/UseNodesData/index.tsx | 2 +- .../react/src/examples/UseReactFlow/index.tsx | 26 +++++- packages/react/src/hooks/useReactFlow.ts | 82 +++++++++---------- packages/react/src/types/changes.ts | 15 ++-- packages/react/src/utils/changes.ts | 69 ++++++++++++++-- 6 files changed, 145 insertions(+), 61 deletions(-) diff --git a/examples/react/cypress/components/utils/apply-changes.cy.ts b/examples/react/cypress/components/utils/apply-changes.cy.ts index 9bdf01af..1548b7e9 100644 --- a/examples/react/cypress/components/utils/apply-changes.cy.ts +++ b/examples/react/cypress/components/utils/apply-changes.cy.ts @@ -156,20 +156,22 @@ describe('applyChanges Testing', () => { expect(nextNodes[0].computed).to.be.deep.equal({ width: newWidth, height: newHeight }); }); - it('resets nodes/edges', () => { + it('replaces nodes/edges', () => { const nodesLength = nodes.length; - const nodeChanges: NodeChange[] = [{ type: 'reset', item: nodes[0] }]; + const nodeChanges: NodeChange[] = [{ type: 'replace', id: nodes[0].id, item: nodes[1] }]; const nextNodes = applyNodeChanges(nodeChanges, nodes); expect(nodes.length).length.to.be.equal(nodesLength); - expect(nextNodes.length).to.be.equal(nodeChanges.length); + expect(nextNodes.length).to.be.equal(nodesLength); + expect(nextNodes[0]).to.be.deep.equal(nodes[1]); const edgesLength = edges.length; - const edgeChange: EdgeChange[] = [{ type: 'reset', item: edges[0] }]; + const edgeChange: EdgeChange[] = [{ type: 'replace', id: edges[0].id, item: edges[1] }]; const nextEdges = applyEdgeChanges(edgeChange, edges); expect(edges.length).length.to.be.equal(edgesLength); - expect(nextEdges.length).to.be.equal(edgeChange.length); + expect(nextEdges.length).to.be.equal(edgesLength); + expect(nextEdges[0]).to.be.deep.equal(edges[1]); }); }); diff --git a/examples/react/src/examples/UseNodesData/index.tsx b/examples/react/src/examples/UseNodesData/index.tsx index 7bb1a94e..d6b697a9 100644 --- a/examples/react/src/examples/UseNodesData/index.tsx +++ b/examples/react/src/examples/UseNodesData/index.tsx @@ -78,7 +78,7 @@ const initEdges: Edge[] = [ ]; const CustomNodeFlow = () => { - const [nodes, setNodes, onNodesChange] = useNodesState(initNodes); + const [nodes, , onNodesChange] = useNodesState(initNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); const onConnect = useCallback((connection: Connection) => setEdges((eds) => addEdge(connection, eds)), [setEdges]); diff --git a/examples/react/src/examples/UseReactFlow/index.tsx b/examples/react/src/examples/UseReactFlow/index.tsx index 69f3c762..bed127fb 100644 --- a/examples/react/src/examples/UseReactFlow/index.tsx +++ b/examples/react/src/examples/UseReactFlow/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, MouseEvent, useEffect } from 'react'; +import { useCallback, MouseEvent, useEffect, useRef } from 'react'; import { ReactFlow, Background, @@ -68,6 +68,7 @@ const UseZoomPanHelperFlow = () => { getNodes, getEdges, deleteElements, + updateNodeData, } = useReactFlow(); const onPaneClick = useCallback( @@ -125,12 +126,31 @@ const UseZoomPanHelperFlow = () => { deleteElements({ nodes: [{ id: '2' }], edges: [{ id: 'e1-3' }] }); }, []); + const edgeAdded = useRef(false); + useEffect(() => { - addEdges({ id: 'e3-4', source: '3', target: '4' }); + if (!edgeAdded.current) { + addEdges({ id: 'e3-4', source: '3', target: '4' }); + edgeAdded.current = true; + } }, [addEdges]); const onResetNodes = useCallback(() => setNodesHook(initialNodes), [setNodesHook]); + const onSetNodes = () => { + setNodes([ + { id: 'a', position: { x: 0, y: 0 }, data: { label: 'Node a' } }, + { id: 'b', position: { x: 0, y: 150 }, data: { label: 'Node b' } }, + ]); + + setEdges([{ id: 'a-b', source: 'a', target: 'b' }]); + }; + + const onUpdateNode = () => { + updateNodeData('1', { label: 'update' }); + updateNodeData('2', { label: 'update' }); + }; + return ( { + + diff --git a/packages/react/src/hooks/useReactFlow.ts b/packages/react/src/hooks/useReactFlow.ts index b8b01920..25a8310e 100644 --- a/packages/react/src/hooks/useReactFlow.ts +++ b/packages/react/src/hooks/useReactFlow.ts @@ -8,14 +8,12 @@ import type { Instance, NodeAddChange, EdgeAddChange, - NodeResetChange, - EdgeResetChange, - NodeRemoveChange, - EdgeRemoveChange, Node, Edge, + NodeChange, + EdgeChange, } from '../types'; -import { isNode } from '../utils'; +import { getElementsDiffChanges, isNode } from '../utils'; /** * Hook for accessing the ReactFlow instance. @@ -30,10 +28,6 @@ export function useReactFlow(); - const setNodesTimeout = useRef>(); - const getNodes = useCallback>(() => { return store.getState().nodes.map((n) => ({ ...n })) as NodeType[]; }, []); @@ -52,48 +46,54 @@ export function useReactFlow e.id === id) as EdgeType; }, []); + // this is used to handle multiple syncronous setNodes calls + const setNodesData = useRef(); + const setNodesTimeout = useRef>(); const setNodes = useCallback>((payload) => { - const { nodes, setNodes, hasDefaultNodes, onNodesChange } = store.getState(); - setNodesData.current = setNodesData.current || nodes; - const nextNodes = typeof payload === 'function' ? payload(setNodesData.current as NodeType[]) : payload; + const { nodes = [], setNodes, hasDefaultNodes, onNodesChange, nodeLookup } = store.getState(); + const nextNodes = typeof payload === 'function' ? payload((setNodesData.current as NodeType[]) || nodes) : payload; setNodesData.current = nextNodes; - if (hasDefaultNodes) { - setNodes(nextNodes); - } else if (onNodesChange) { - if (setNodesTimeout.current) { - clearTimeout(setNodesTimeout.current); - } - - // if there are multiple synchronous setNodes calls, we only want to call onNodesChange once - // for this, we use a timeout to wait for the last call and store updated nodes in setNodesData - // this is not perfect, but should work in most cases - setNodesTimeout.current = setTimeout(() => { - const changes = - nextNodes.length === 0 - ? nodes.map((node) => ({ type: 'remove', id: node.id } as NodeRemoveChange)) - : nextNodes.map((node) => ({ item: node, type: 'reset' } as NodeResetChange)); - onNodesChange(changes); - - setNodesData.current = undefined; - }, 0); + if (setNodesTimeout.current) { + clearTimeout(setNodesTimeout.current); } + + // if there are multiple synchronous setNodes calls, we only want to call onNodesChange once + // for this, we use a timeout to wait for the last call and store updated nodes in setNodesData + // this is not perfect, but should work in most cases + setNodesTimeout.current = setTimeout(() => { + if (hasDefaultNodes) { + setNodes(nextNodes); + } else if (onNodesChange) { + const changes: NodeChange[] = getElementsDiffChanges({ items: setNodesData.current, lookup: nodeLookup }); + onNodesChange(changes); + setNodesData.current = undefined; + } + }, 0); }, []); + // this is used to handle multiple syncronous setNodes calls + const setEdgesData = useRef(); + const setEdgesTimeout = useRef>(); const setEdges = useCallback>((payload) => { - const { edges = [], setEdges, hasDefaultEdges, onEdgesChange } = store.getState(); - const nextEdges = typeof payload === 'function' ? payload(edges as EdgeType[]) : payload; + const { edges = [], setEdges, hasDefaultEdges, onEdgesChange, edgeLookup } = store.getState(); + const nextEdges = typeof payload === 'function' ? payload((setEdgesData.current as EdgeType[]) || edges) : payload; - if (hasDefaultEdges) { - setEdges(nextEdges); - } else if (onEdgesChange) { - const changes = - nextEdges.length === 0 - ? edges.map((edge) => ({ type: 'remove', id: edge.id } as EdgeRemoveChange)) - : nextEdges.map((edge) => ({ item: edge, type: 'reset' } as EdgeResetChange)); - onEdgesChange(changes); + setEdgesData.current = nextEdges; + + if (setEdgesTimeout.current) { + clearTimeout(setEdgesTimeout.current); } + + setEdgesTimeout.current = setTimeout(() => { + if (hasDefaultEdges) { + setEdges(nextEdges); + } else if (onEdgesChange) { + const changes: EdgeChange[] = getElementsDiffChanges({ items: nextEdges, lookup: edgeLookup }); + onEdgesChange(changes); + } + }, 0); }, []); const addNodes = useCallback>((payload) => { diff --git a/packages/react/src/types/changes.ts b/packages/react/src/types/changes.ts index 9a60e089..3dfdffa2 100644 --- a/packages/react/src/types/changes.ts +++ b/packages/react/src/types/changes.ts @@ -34,9 +34,10 @@ export type NodeAddChange = { type: 'add'; }; -export type NodeResetChange = { +export type NodeReplaceChange = { + id: string; item: NodeType; - type: 'reset'; + type: 'replace'; }; /** @@ -49,7 +50,7 @@ export type NodeChange = | NodeSelectionChange | NodeRemoveChange | NodeAddChange - | NodeResetChange; + | NodeReplaceChange; export type EdgeSelectionChange = NodeSelectionChange; export type EdgeRemoveChange = NodeRemoveChange; @@ -57,8 +58,10 @@ export type EdgeAddChange = { item: EdgeType; type: 'add'; }; -export type EdgeResetChange = { + +export type EdgeReplaceChange = { + id: string; item: EdgeType; - type: 'reset'; + type: 'replace'; }; -export type EdgeChange = EdgeSelectionChange | EdgeRemoveChange | EdgeAddChange | EdgeResetChange; +export type EdgeChange = EdgeSelectionChange | EdgeRemoveChange | EdgeAddChange | EdgeReplaceChange; diff --git a/packages/react/src/utils/changes.ts b/packages/react/src/utils/changes.ts index 87b87c3a..cd148a6e 100644 --- a/packages/react/src/utils/changes.ts +++ b/packages/react/src/utils/changes.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { EdgeLookup, NodeLookup } from '@xyflow/system'; import type { Node, Edge, EdgeChange, NodeChange, NodeSelectionChange, EdgeSelectionChange } from '../types'; export function handleParentExpand(updatedElements: any[], updateItem: any) { @@ -53,11 +54,6 @@ export function handleParentExpand(updatedElements: any[], updateItem: any) { // When you drag a node for example, React Flow will send a position change update. // This function then applies the changes and returns the updated elements. function applyChanges(changes: any[], elements: any[]): any[] { - // we need this hack to handle the setNodes and setEdges function of the useReactFlow hook for controlled flows - if (changes.some((c) => c.type === 'reset')) { - return changes.filter((c) => c.type === 'reset').map((c) => c.item); - } - const updatedElements: any[] = []; // By storing a map of changes for each element, we can a quick lookup as we // iterate over the elements array! @@ -67,7 +63,7 @@ function applyChanges(changes: any[], elements: any[]): any[] { if (change.type === 'add') { updatedElements.push(change.item); continue; - } else if (change.type === 'remove') { + } else if (change.type === 'remove' || change.type === 'replace') { // For a 'remove' change we can safely ignore any other changes queued for // the same element, it's going to be removed anyway! changesMap.set(change.id, [change]); @@ -99,6 +95,11 @@ function applyChanges(changes: any[], elements: any[]): any[] { continue; } + if (changes[0].type === 'replace') { + updatedElements.push({ ...changes[0].item }); + continue; + } + // For other types of changes, we want to start with a shallow copy of the // object so React knows this element has changed. Sequential changes will /// each _mutate_ this object, so there's only ever one copy. @@ -245,3 +246,59 @@ export function getSelectionChanges( return changes; } + +/** + * This function is used to find the changes between two sets of elements. + * It is used to determine which nodes or edges have been added, removed or replaced. + * + * @internal + * @param params.items = the next set of elements (nodes or edges) + * @param params.lookup = a lookup map of the current store elements + * @returns an array of changes + */ +export function getElementsDiffChanges({ + items, + lookup, +}: { + items: Node[] | undefined; + lookup: NodeLookup; +}): NodeChange[]; +export function getElementsDiffChanges({ + items, + lookup, +}: { + items: Edge[] | undefined; + lookup: EdgeLookup; +}): EdgeChange[]; +export function getElementsDiffChanges({ + items = [], + lookup, +}: { + items: any[] | undefined; + lookup: Map; +}): any[] { + const changes: any[] = []; + const itemsLookup = new Map(items.map((item) => [item.id, item])); + + for (const item of items) { + const storeItem = lookup.get(item.id); + + if (storeItem !== undefined && storeItem !== item) { + changes.push({ id: item.id, item: item, type: 'replace' }); + } + + if (storeItem === undefined) { + changes.push({ item: item, type: 'add' }); + } + } + + for (const [id] of lookup) { + const nextNode = itemsLookup.get(id); + + if (nextNode === undefined) { + changes.push({ id, type: 'remove' }); + } + } + + return changes; +} From 70d76611b16ee216c5206a6ca819ee7a8a87ee73 Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 11 Jan 2024 16:26:59 +0100 Subject: [PATCH 7/8] chore(applyChanges): cleanup --- examples/react/src/examples/Basic/index.tsx | 42 ++++++++++++++++--- .../src/examples/UseNodesData/TextNode.tsx | 13 ++++-- packages/react/src/hooks/useReactFlow.ts | 7 +++- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/examples/react/src/examples/Basic/index.tsx b/examples/react/src/examples/Basic/index.tsx index 4f1a51d2..8d9c7dfd 100644 --- a/examples/react/src/examples/Basic/index.tsx +++ b/examples/react/src/examples/Basic/index.tsx @@ -1,4 +1,4 @@ -import { MouseEvent } from 'react'; +import { MouseEvent, useCallback } from 'react'; import { ReactFlow, MiniMap, @@ -53,10 +53,11 @@ const initialEdges: Edge[] = [ const defaultEdgeOptions = {}; const BasicFlow = () => { - const instance = useReactFlow(); + const { setNodes, getNodes, setEdges, getEdges, deleteElements, updateNodeData, toObject, setViewport } = + useReactFlow(); const updatePos = () => { - instance.setNodes((nodes) => + setNodes((nodes) => nodes.map((node) => { node.position = { x: Math.random() * 400, @@ -68,11 +69,11 @@ const BasicFlow = () => { ); }; - const logToObject = () => console.log(instance.toObject()); - const resetTransform = () => instance.setViewport({ x: 0, y: 0, zoom: 1 }); + const logToObject = () => console.log(toObject()); + const resetTransform = () => setViewport({ x: 0, y: 0, zoom: 1 }); const toggleClassnames = () => { - instance.setNodes((nodes) => + setNodes((nodes) => nodes.map((node) => { node.className = node.className === 'light' ? 'dark' : 'light'; @@ -81,6 +82,30 @@ const BasicFlow = () => { ); }; + const deleteSelectedElements = useCallback(() => { + const selectedNodes = getNodes().filter((node) => node.selected); + const selectedEdges = getEdges().filter((edge) => edge.selected); + deleteElements({ nodes: selectedNodes, edges: selectedEdges }); + }, [deleteElements]); + + const deleteSomeElements = useCallback(() => { + deleteElements({ nodes: [{ id: '2' }], edges: [{ id: 'e1-3' }] }); + }, []); + + const onSetNodes = () => { + setNodes([ + { id: 'a', position: { x: 0, y: 0 }, data: { label: 'Node a' } }, + { id: 'b', position: { x: 0, y: 150 }, data: { label: 'Node b' } }, + ]); + + setEdges([{ id: 'a-b', source: 'a', target: 'b' }]); + }; + + const onUpdateNode = () => { + updateNodeData('1', { label: 'update' }); + updateNodeData('2', { label: 'update' }); + }; + return ( { + + + + + ); diff --git a/examples/react/src/examples/UseNodesData/TextNode.tsx b/examples/react/src/examples/UseNodesData/TextNode.tsx index 6ca424e6..113d41fb 100644 --- a/examples/react/src/examples/UseNodesData/TextNode.tsx +++ b/examples/react/src/examples/UseNodesData/TextNode.tsx @@ -1,16 +1,21 @@ -import { memo, ChangeEventHandler } from 'react'; +import { memo, useState } from 'react'; import { Position, NodeProps, Handle, useReactFlow } from '@xyflow/react'; function TextNode({ id, data }: NodeProps) { const { updateNodeData } = useReactFlow(); - - const onChange: ChangeEventHandler = (evt) => updateNodeData(id, { text: evt.target.value }); + const [text, setText] = useState(data.text); + const updateText = (text: string) => { + // avoid jumping caret with a synchronous update + setText(text); + // update actual node data + updateNodeData(id, { text }); + }; return (
node {id}
- + updateText(evt.target.value)} value={text} />
diff --git a/packages/react/src/hooks/useReactFlow.ts b/packages/react/src/hooks/useReactFlow.ts index 25a8310e..048a957b 100644 --- a/packages/react/src/hooks/useReactFlow.ts +++ b/packages/react/src/hooks/useReactFlow.ts @@ -68,12 +68,13 @@ export function useReactFlow(); const setEdgesTimeout = useRef>(); const setEdges = useCallback>((payload) => { @@ -93,6 +94,8 @@ export function useReactFlow Date: Thu, 11 Jan 2024 16:38:07 +0100 Subject: [PATCH 8/8] chore(changelog): add changes --- packages/react/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 146e178c..cbdb7cf1 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -5,6 +5,8 @@ ### Minor changes - fix `deleteElements` +- refactor internal `applyChanges` +- batch `setNodes` and `setEdges` from `useReactFlow` ## 12.0.0-next.5