diff --git a/packages/react/src/components/BatchProvider/index.tsx b/packages/react/src/components/BatchProvider/index.tsx new file mode 100644 index 00000000..a3acec3e --- /dev/null +++ b/packages/react/src/components/BatchProvider/index.tsx @@ -0,0 +1,80 @@ +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<{ + nodeQueue: Queue; + edgeQueue: Queue; +} | null>(null); + +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..a9fa64b8 --- /dev/null +++ b/packages/react/src/components/BatchProvider/useQueue.ts @@ -0,0 +1,44 @@ +import { useState } from 'react'; +import { createQueue } from './utils'; +import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect'; +import { QueueItem } from './types'; + +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 mutable 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; +} diff --git a/packages/react/src/components/BatchProvider/utils.ts b/packages/react/src/components/BatchProvider/utils.ts new file mode 100644 index 00000000..50026c87 --- /dev/null +++ b/packages/react/src/components/BatchProvider/utils.ts @@ -0,0 +1,16 @@ +import type { Queue, QueueItem } from './types'; + +export 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/useReactFlow.ts b/packages/react/src/hooks/useReactFlow.ts index 56c9ae5d..284aa3ac 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,110 +50,39 @@ export function useReactFlow>((id) => store.getState().edgeLookup.get(id) as EdgeType, []); - type SetElementsQueue = { - nodes: (NodeType[] | ((nodes: NodeType[]) => NodeType[]))[]; - edges: (EdgeType[] | ((edges: EdgeType[]) => EdgeType[]))[]; - }; + const setNodes = useCallback>( + (payload) => { + batchContext?.nodeQueue.push(payload as NodeType[]); + }, + [batchContext] + ); - // 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); + const setEdges = useCallback>( + (payload) => { + batchContext?.edgeQueue.push(payload as EdgeType[]); + }, + [batchContext] + ); - // 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; - } + const addNodes = useCallback>( + (payload) => { + const newNodes = Array.isArray(payload) ? payload : [payload]; - if (setElementsQueue.current.nodes.length) { - const { nodes = [], setNodes, hasDefaultNodes, onNodesChange, nodeLookup } = store.getState(); + // Queueing a functional update means that we won't worry about other calls + // to `setNodes` that might happen elsewhere. + batchContext?.nodeQueue.push((nodes) => [...nodes, ...newNodes]); + }, + [batchContext] + ); - // 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; - } + const addEdges = useCallback>( + (payload) => { + const newEdges = Array.isArray(payload) ? payload : [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); - }, []); - - const setEdges = useCallback>((payload) => { - setElementsQueue.current.edges.push(payload); - setShouldFlushQueue(true); - }, []); - - 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); - }, []); - - 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]); + }, + [batchContext] + ); const toObject = useCallback>(() => { const { nodes = [], edges = [], transform } = store.getState(); 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' });