From 5fa95c2413b9cd8898a3270ee76908f248d6dda5 Mon Sep 17 00:00:00 2001 From: moklick Date: Mon, 8 Jan 2024 15:02:09 +0100 Subject: [PATCH 01/17] 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 02/17] 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 a35428d8a0ddbb401be567f4c29368298c55bff5 Mon Sep 17 00:00:00 2001 From: moklick Date: Wed, 10 Jan 2024 13:41:18 +0100 Subject: [PATCH 03/17] chore(tests): useNodes/useEdges --- examples/react/cypress/components/hooks/useEdges.cy.tsx | 1 - examples/react/cypress/components/hooks/useNodes.cy.tsx | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/react/cypress/components/hooks/useEdges.cy.tsx b/examples/react/cypress/components/hooks/useEdges.cy.tsx index 34b0ae3c..c1123d4d 100644 --- a/examples/react/cypress/components/hooks/useEdges.cy.tsx +++ b/examples/react/cypress/components/hooks/useEdges.cy.tsx @@ -13,7 +13,6 @@ describe('useEdges.cy.tsx', () => { ); - cy.get('@onChangeSpy').should('have.been.calledWith', []); cy.get('@onChangeSpy').should('have.been.calledWith', initialEdges); }); diff --git a/examples/react/cypress/components/hooks/useNodes.cy.tsx b/examples/react/cypress/components/hooks/useNodes.cy.tsx index 4f813e00..9c10ad70 100644 --- a/examples/react/cypress/components/hooks/useNodes.cy.tsx +++ b/examples/react/cypress/components/hooks/useNodes.cy.tsx @@ -15,8 +15,10 @@ const initialNodes: Node[] = nodes.map((n) => ({ const expectedNodes: Node[] = initialNodes.map((n) => ({ ...n, - positionAbsolute: n.position, - ...nodeDimensions, + computed: { + positionAbsolute: n.position, + ...nodeDimensions, + }, })); describe('useNodes.cy.tsx', () => { @@ -29,7 +31,6 @@ describe('useNodes.cy.tsx', () => { ); - cy.get('@onChangeSpy').should('have.been.calledWith', []); cy.get('@onChangeSpy').should('have.been.calledWith', expectedNodes); }); From 8c05ec419ebc1f5dd9e0cc542fedf7010c21e5fd Mon Sep 17 00:00:00 2001 From: Hayleigh Thompson Date: Wed, 10 Jan 2024 18:17:58 +0000 Subject: [PATCH 04/17] :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 05/17] 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 06/17] 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 08c6ed4a06daeb49417b189ef872d4f73ab1799e Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 11 Jan 2024 11:19:23 +0100 Subject: [PATCH 07/17] refactor(deleteElements): use onbeforedelete from store --- .../examples/usesvelteflow/Sidebar.svelte | 14 +++++++++-- packages/react/CHANGELOG.md | 6 +++++ .../react/src/hooks/useGlobalKeyHandler.ts | 4 ++-- packages/react/src/hooks/useReactFlow.ts | 3 ++- packages/react/src/types/instance.ts | 3 +-- packages/svelte/CHANGELOG.md | 10 ++++++++ .../svelte/src/lib/hooks/useSvelteFlow.ts | 23 ++++++++----------- 7 files changed, 43 insertions(+), 20 deletions(-) diff --git a/examples/svelte/src/routes/examples/usesvelteflow/Sidebar.svelte b/examples/svelte/src/routes/examples/usesvelteflow/Sidebar.svelte index a195cf1c..5e6d20a2 100644 --- a/examples/svelte/src/routes/examples/usesvelteflow/Sidebar.svelte +++ b/examples/svelte/src/routes/examples/usesvelteflow/Sidebar.svelte @@ -1,5 +1,5 @@