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/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/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/MultiSetNodes/index.tsx b/examples/react/src/examples/MultiSetNodes/index.tsx new file mode 100644 index 00000000..fcb49924 --- /dev/null +++ b/examples/react/src/examples/MultiSetNodes/index.tsx @@ -0,0 +1,82 @@ +import { useCallback } from 'react'; +import { + ReactFlow, + Controls, + addEdge, + Connection, + useNodesState, + useEdgesState, + Background, + Node, + Edge, + Panel, + useReactFlow, + ReactFlowProvider, +} from '@xyflow/react'; + +import './style.css'; + +const initNodes: Node[] = []; + +for (let i = 0; i < 100; i++) { + initNodes.push({ + id: i.toString(), + data: { + label: `node ${i + 1}`, + }, + position: { x: (i % 10) * 60, y: Math.floor(i / 10) * 60 }, + }); +} + +const initEdges: Edge[] = []; + +const CustomNodeFlow = () => { + 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 multiSetNodes = () => { + nodes.forEach((node) => + setNodes((nds) => + nds.map((n) => { + if (n.id === node.id) { + return { ...n, data: { label: 'node set' } }; + } + + return n; + }) + ) + ); + }; + + const multiUpdateNodes = () => { + nodes.forEach((node) => updateNodeData(node.id, { label: 'node update' })); + }; + + return ( + + + + + + + + + ); +}; + +export default () => ( + + + +); diff --git a/examples/react/src/examples/MultiSetNodes/style.css b/examples/react/src/examples/MultiSetNodes/style.css new file mode 100644 index 00000000..2f427666 --- /dev/null +++ b/examples/react/src/examples/MultiSetNodes/style.css @@ -0,0 +1,3 @@ +.multiset .react-flow__node { + width: 50px; +} 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/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/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 diff --git a/packages/react/src/hooks/useReactFlow.ts b/packages/react/src/hooks/useReactFlow.ts index 518bf683..048a957b 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'; @@ -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. @@ -48,34 +46,57 @@ 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(); - const nextNodes = typeof payload === 'function' ? payload(nodes as NodeType[]) : payload; + const { nodes = [], setNodes, hasDefaultNodes, onNodesChange, nodeLookup } = store.getState(); + const nextNodes = typeof payload === 'function' ? payload((setNodesData.current as NodeType[]) || nodes) : payload; - 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); + setNodesData.current = nextNodes; + + 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 setEdges 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); + } + + setEdgesData.current = undefined; + }, 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 eda08176..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,112 +54,120 @@ 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); - } - - 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' || 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]); } else { - remainingChanges.push(change); + const elementChanges = changesMap.get(change.id); + + 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 }; - let isDeletion = false; - - 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; - - // this is needed for the node resizer to work - if (currentChange.resizing) { - updateItem.width = currentChange.dimensions.width; - updateItem.height = currentChange.dimensions.height; - } - } - - if (typeof currentChange.resizing === 'boolean') { - updateItem.resizing = currentChange.resizing; - } - - if (updateItem.expandParent) { - handleParentExpand(updatedElements, updateItem); - } - break; - } - case 'remove': { - isDeletion = true; - continue; - } - } - } + // If we have a 'remove' change queued, it'll be the only change in the array + if (changes[0].type === 'remove') { + continue; } - if (!isDeletion) { - updatedElements.push(updateItem); + 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. + 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 (change.resizing) { + element.width = change.dimensions.width; + element.height = change.dimensions.height; + } + } + + 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 @@ -237,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; +}