diff --git a/examples/react/src/examples/UseNodesData/index.tsx b/examples/react/src/examples/UseNodesData/index.tsx index dc0b43dd..dabc1d4d 100644 --- a/examples/react/src/examples/UseNodesData/index.tsx +++ b/examples/react/src/examples/UseNodesData/index.tsx @@ -45,6 +45,12 @@ const initNodes: MyNode[] = [ data: { text: '' }, position: { x: 100, y: 0 }, }, + { + id: '1b', + type: 'uppercase', + data: { text: '' }, + position: { x: 100, y: -100 }, + }, { id: '2', type: 'text', @@ -53,9 +59,14 @@ const initNodes: MyNode[] = [ }, position: { x: 0, y: 100 }, }, - { - id: '3', + id: '3a', + type: 'result', + data: {}, + position: { x: 300, y: -75 }, + }, + { + id: '3b', type: 'result', data: {}, position: { x: 300, y: 50 }, @@ -69,14 +80,24 @@ const initEdges: Edge[] = [ target: '1a', }, { - id: 'e1a-3', - source: '1a', - target: '3', + id: 'e1a-3a', + source: '1b', + target: '3a', }, { - id: 'e2-3', + id: 'e1-1b', + source: '1', + target: '1b', + }, + { + id: 'e1a-3b', + source: '1a', + target: '3b', + }, + { + id: 'e2-3b', source: '2', - target: '3', + target: '3b', }, ]; diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 43fb4636..6685ad5b 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,12 @@ # @xyflow/react +## 12.0.0-next.16 + +## Patch changes + +- fix batching for `setNodes`, `updateNode`, `updateNodeData` etc. +- fix `useNodesInitialized` + ## 12.0.0-next.15 ## Patch changes diff --git a/packages/react/package.json b/packages/react/package.json index 54a6dfa5..3b99d721 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@xyflow/react", - "version": "12.0.0-next.15", + "version": "12.0.0-next.16", "description": "React Flow - A highly customizable React library for building node-based editors and interactive flow charts.", "keywords": [ "react", diff --git a/packages/react/src/components/BatchProvider/index.tsx b/packages/react/src/components/BatchProvider/index.tsx new file mode 100644 index 00000000..1dbab718 --- /dev/null +++ b/packages/react/src/components/BatchProvider/index.tsx @@ -0,0 +1,88 @@ +import { createContext, ReactNode, useCallback, useContext, useMemo } from 'react'; +import { EdgeChange, NodeChange } from '@xyflow/system'; + +import { useStoreApi } from '../../hooks/useStore'; +import { getElementsDiffChanges } from '../../utils'; +import { Queue, QueueItem } from './types'; +import type { Edge, Node } from '../../types'; +import { useQueue } from './useQueue'; + +const BatchContext = createContext<{ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + nodeQueue: Queue; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + edgeQueue: Queue; +} | null>(null); + +/** + * This is a context provider that holds and processes the node and edge update queues + * that are needed to handle setNodes, addNodes, setEdges and addEdges. + * + * @internal + */ +export function BatchProvider({ + children, +}: { + children: ReactNode; +}) { + const store = useStoreApi(); + + const nodeQueueHandler = useCallback((queueItems: QueueItem[]) => { + const { nodes = [], setNodes, hasDefaultNodes, onNodesChange, nodeLookup } = store.getState(); + + // This is essentially an `Array.reduce` in imperative clothing. Processing + // this queue is a relatively hot path so we'd like to avoid the overhead of + // array methods where we can. + let next = nodes as NodeType[]; + for (const payload of queueItems) { + next = typeof payload === 'function' ? payload(next) : payload; + } + + if (hasDefaultNodes) { + setNodes(next); + } else if (onNodesChange) { + onNodesChange( + getElementsDiffChanges({ + items: next, + lookup: nodeLookup, + }) as NodeChange[] + ); + } + }, []); + const nodeQueue = useQueue(nodeQueueHandler); + + const edgeQueueHandler = useCallback((queueItems: QueueItem[]) => { + const { edges = [], setEdges, hasDefaultEdges, onEdgesChange, edgeLookup } = store.getState(); + + let next = edges as EdgeType[]; + for (const payload of queueItems) { + next = typeof payload === 'function' ? payload(next) : payload; + } + + if (hasDefaultEdges) { + setEdges(next); + } else if (onEdgesChange) { + onEdgesChange( + getElementsDiffChanges({ + items: next, + lookup: edgeLookup, + }) as EdgeChange[] + ); + } + }, []); + const edgeQueue = useQueue(edgeQueueHandler); + + const value = useMemo(() => ({ nodeQueue, edgeQueue }), []); + + return {children}; +} + +export function useBatchContext() { + const batchContext = useContext(BatchContext); + + if (!batchContext) { + throw new Error('useBatchContext must be used within a BatchProvider'); + } + + return batchContext; +} diff --git a/packages/react/src/components/BatchProvider/types.ts b/packages/react/src/components/BatchProvider/types.ts new file mode 100644 index 00000000..53d7193b --- /dev/null +++ b/packages/react/src/components/BatchProvider/types.ts @@ -0,0 +1,7 @@ +export type QueueItem = T[] | ((items: T[]) => T[]); + +export type Queue = { + get: () => QueueItem[]; + reset: () => void; + push: (item: QueueItem) => void; +}; diff --git a/packages/react/src/components/BatchProvider/useQueue.ts b/packages/react/src/components/BatchProvider/useQueue.ts new file mode 100644 index 00000000..06d9e235 --- /dev/null +++ b/packages/react/src/components/BatchProvider/useQueue.ts @@ -0,0 +1,67 @@ +import { useState } from 'react'; + +import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect'; +import { Queue, QueueItem } from './types'; + +/** + * This hook returns a queue that can be used to batch updates. + * + * @param runQueue - a function that gets called when the queue is flushed + * @internal + * + * @returns a Queue object + */ +export function useQueue(runQueue: (items: QueueItem[]) => void) { + // Because we're using a ref above, we need some way to let React know when to + // actually process the queue. We flip this bit of state to `true` any time we + // mutate the queue and then flip it back to `false` after flushing the queue. + const [shouldFlush, setShouldFlush] = useState(false); + + // A reference of all the batched updates to process before the next render. We + // want a reference here so multiple synchronous calls to `setNodes` etc can be + // batched together. + const [queue] = useState(() => createQueue(() => setShouldFlush(true))); + + // Layout effects are guaranteed to run before the next render which means we + // shouldn't run into any issues with stale state or weird issues that come from + // rendering things one frame later than expected (we used to use `setTimeout`). + useIsomorphicLayoutEffect(() => { + // Because we need to flip the state back to false after flushing, this should + // trigger the hook again (!). If the hook is being run again we know that any + // updates should have been processed by now and we can safely clear the queue + // and bail early. + if (!shouldFlush) { + queue.reset(); + return; + } + + const queueItems = queue.get(); + + if (queueItems.length) { + runQueue(queueItems); + + queue.reset(); + } + + // Beacuse we're using reactive state to trigger this effect, we need to flip + // it back to false. + setShouldFlush(false); + }, [shouldFlush]); + + return queue; +} + +function createQueue(cb: () => void): Queue { + let queue: QueueItem[] = []; + + return { + get: () => queue, + reset: () => { + queue = []; + }, + push: (item) => { + queue.push(item); + cb(); + }, + }; +} diff --git a/packages/react/src/components/ReactFlowProvider/index.tsx b/packages/react/src/components/ReactFlowProvider/index.tsx index 33d3d4c4..f04ebebd 100644 --- a/packages/react/src/components/ReactFlowProvider/index.tsx +++ b/packages/react/src/components/ReactFlowProvider/index.tsx @@ -2,6 +2,7 @@ import { useState, type ReactNode } from 'react'; import { Provider } from '../../contexts/StoreContext'; import { createStore } from '../../store'; +import { BatchProvider } from '../BatchProvider'; import type { Node, Edge } from '../../types'; export type ReactFlowProviderProps = { @@ -37,5 +38,9 @@ export function ReactFlowProvider({ }) ); - return {children}; + return ( + + {children} + + ); } diff --git a/packages/react/src/hooks/useNodesInitialized.ts b/packages/react/src/hooks/useNodesInitialized.ts index 346e56d9..98bfa6b7 100644 --- a/packages/react/src/hooks/useNodesInitialized.ts +++ b/packages/react/src/hooks/useNodesInitialized.ts @@ -1,5 +1,6 @@ import { useStore } from './useStore'; import type { ReactFlowState } from '../types'; +import { nodeHasDimensions } from '@xyflow/system'; export type UseNodesInitializedOptions = { includeHiddenNodes?: boolean; @@ -10,9 +11,9 @@ const selector = (options: UseNodesInitializedOptions) => (s: ReactFlowState) => return false; } - for (const [, node] of s.nodeLookup) { - if (options.includeHiddenNodes || !node.hidden) { - if (node.internals.handleBounds === undefined) { + for (const [, { hidden, internals }] of s.nodeLookup) { + if (options.includeHiddenNodes || !hidden) { + if (internals.handleBounds === undefined || !nodeHasDimensions(internals.userNode)) { return false; } } diff --git a/packages/react/src/hooks/useReactFlow.ts b/packages/react/src/hooks/useReactFlow.ts index 56c9ae5d..4ac0e50c 100644 --- a/packages/react/src/hooks/useReactFlow.ts +++ b/packages/react/src/hooks/useReactFlow.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo } from 'react'; import { evaluateAbsolutePosition, getElementsToRemove, @@ -10,9 +10,9 @@ import { import useViewportHelper from './useViewportHelper'; import { useStoreApi } from './useStore'; +import { useBatchContext } from '../components/BatchProvider'; +import { isNode } from '../utils'; import type { ReactFlowInstance, Instance, Node, Edge, InternalNode } from '../types'; -import { getElementsDiffChanges, isNode } from '../utils'; -import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; /** * Hook for accessing the ReactFlow instance. @@ -26,6 +26,7 @@ export function useReactFlow { const viewportHelper = useViewportHelper(); const store = useStoreApi(); + const batchContext = useBatchContext(); const getNodes = useCallback>( () => store.getState().nodes.map((n) => ({ ...n })) as NodeType[], @@ -49,109 +50,22 @@ export function useReactFlow>((id) => store.getState().edgeLookup.get(id) as EdgeType, []); - type SetElementsQueue = { - nodes: (NodeType[] | ((nodes: NodeType[]) => NodeType[]))[]; - edges: (EdgeType[] | ((edges: EdgeType[]) => EdgeType[]))[]; - }; - - // A reference of all the batched updates to process before the next render. We - // want a mutable reference here so multiple synchronous calls to `setNodes` etc - // can be batched together. - const setElementsQueue = useRef({ nodes: [], edges: [] }); - // Because we're using a ref above, we need some way to let React know when to - // actually process the queue. We flip this bit of state to `true` any time we - // mutate the queue and then flip it back to `false` after flushing the queue. - const [shouldFlushQueue, setShouldFlushQueue] = useState(false); - - // Layout effects are guaranteed to run before the next render which means we - // shouldn't run into any issues with stale state or weird issues that come from - // rendering things one frame later than expected (we used to use `setTimeout`). - useIsomorphicLayoutEffect(() => { - // Because we need to flip the state back to false after flushing, this should - // trigger the hook again (!). If the hook is being run again we know that any - // updates should have been processed by now and we can safely clear the queue - // and bail early. - if (!shouldFlushQueue) { - setElementsQueue.current = { nodes: [], edges: [] }; - return; - } - - if (setElementsQueue.current.nodes.length) { - const { nodes = [], setNodes, hasDefaultNodes, onNodesChange, nodeLookup } = store.getState(); - - // This is essentially an `Array.reduce` in imperative clothing. Processing - // this queue is a relatively hot path so we'd like to avoid the overhead of - // array methods where we can. - let next = nodes as NodeType[]; - for (const payload of setElementsQueue.current.nodes) { - next = typeof payload === 'function' ? payload(next) : payload; - } - - if (hasDefaultNodes) { - setNodes(next); - } else if (onNodesChange) { - onNodesChange( - getElementsDiffChanges({ - items: next, - lookup: nodeLookup, - }) - ); - } - - setElementsQueue.current.nodes = []; - } - - if (setElementsQueue.current.edges.length) { - const { edges = [], setEdges, hasDefaultEdges, onEdgesChange, edgeLookup } = store.getState(); - - let next = edges as EdgeType[]; - for (const payload of setElementsQueue.current.edges) { - next = typeof payload === 'function' ? payload(next) : payload; - } - - if (hasDefaultEdges) { - setEdges(next); - } else if (onEdgesChange) { - onEdgesChange( - getElementsDiffChanges({ - items: next, - lookup: edgeLookup, - }) - ); - } - - setElementsQueue.current.edges = []; - } - - // Beacuse we're using reactive state to trigger this effect, we need to flip - // it back to false. - setShouldFlushQueue(false); - }, [shouldFlushQueue]); - const setNodes = useCallback>((payload) => { - setElementsQueue.current.nodes.push(payload); - setShouldFlushQueue(true); + batchContext.nodeQueue.push(payload as NodeType[]); }, []); const setEdges = useCallback>((payload) => { - setElementsQueue.current.edges.push(payload); - setShouldFlushQueue(true); + batchContext.edgeQueue.push(payload as EdgeType[]); }, []); const addNodes = useCallback>((payload) => { const newNodes = Array.isArray(payload) ? payload : [payload]; - - // Queueing a functional update means that we won't worry about other calls - // to `setNodes` that might happen elsewhere. - setElementsQueue.current.nodes.push((nodes) => [...nodes, ...newNodes]); - setShouldFlushQueue(true); + batchContext.nodeQueue.push((nodes) => [...nodes, ...newNodes]); }, []); const addEdges = useCallback>((payload) => { const newEdges = Array.isArray(payload) ? payload : [payload]; - - setElementsQueue.current.edges.push((edges) => [...edges, ...newEdges]); - setShouldFlushQueue(true); + batchContext.edgeQueue.push((edges) => [...edges, ...newEdges]); }, []); const toObject = useCallback>(() => { diff --git a/packages/react/src/utils/changes.ts b/packages/react/src/utils/changes.ts index 294af3e1..cdc83088 100644 --- a/packages/react/src/utils/changes.ts +++ b/packages/react/src/utils/changes.ts @@ -236,7 +236,8 @@ export function getElementsDiffChanges({ const itemsLookup = new Map(items.map((item) => [item.id, item])); for (const item of items) { - const storeItem = lookup.get(item.id); + const lookupItem = lookup.get(item.id); + const storeItem = lookupItem?.internals?.userNode ?? lookupItem; if (storeItem !== undefined && storeItem !== item) { changes.push({ id: item.id, item: item, type: 'replace' });