performance
This commit is contained in:
48
PERFORMANCE.md
Normal file
48
PERFORMANCE.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Performance notes
|
||||||
|
|
||||||
|
## Already in place
|
||||||
|
|
||||||
|
- **Throttled node updates during drag** – `onNodesChange` merges changes and flushes at most once per animation frame (~60 fps) so dragging doesn’t trigger hundreds of re-renders per second.
|
||||||
|
- **`nodeDragThreshold={1}`** – Avoids treating small pointer moves as drags and reduces noisy change events.
|
||||||
|
- **Memoized React Flow props** – `nodeTypes`, `edgeTypes`, `defaultEdgeOptions`, and `flowContextValue` are memoized so their references don’t change every render.
|
||||||
|
- **Custom `nodePropsAreEqual`** – Node components use `React.memo(..., nodePropsAreEqual)` so a node only re-renders when its own `id`, `data`, `width`, `height`, or `selected` change (e.g. dragging one node doesn’t force others to re-render when the library keeps their props reference stable).
|
||||||
|
- **ConfigNode derived data** – `incomingEdges`, `incomingIds`, and `connected*` are wrapped in `useMemo` so they aren’t recomputed on every render.
|
||||||
|
|
||||||
|
## Further improvements you can try
|
||||||
|
|
||||||
|
### 1. Split context (medium effort)
|
||||||
|
|
||||||
|
Right now every component that uses `useContext(FlowContext)` re-renders whenever `nodes` or `edges` change. You can split into:
|
||||||
|
|
||||||
|
- **FlowDataContext** – `nodes`, `edges` (changes often).
|
||||||
|
- **FlowActionsContext** – `setNodes`, `setEdges`, `isValidConnection`, etc. (stable).
|
||||||
|
|
||||||
|
Components that only need actions (e.g. menus, buttons) subscribe to `FlowActionsContext` and won’t re-render on graph updates.
|
||||||
|
|
||||||
|
### 2. Avoid reading full `nodes`/`edges` where possible
|
||||||
|
|
||||||
|
React Flow’s docs recommend not depending on the full `nodes`/`edges` arrays when you only need a small slice (e.g. “selected ids”). If you add features like selection state, keep that in a separate store or state (e.g. `selectedNodeIds`) and have components depend on that instead of filtering `nodes` everywhere.
|
||||||
|
|
||||||
|
### 3. Lighter node content
|
||||||
|
|
||||||
|
- **CodeMirror** – Config and function nodes use CodeMirror; it’s heavy. Options: lazy-initialize the editor (mount only when the node is focused or visible), or use a plain `<textarea>` for very small/simple flows.
|
||||||
|
- **Resize** – `NodeResizeControl` and `useResizeHeight` add work. If you don’t need resize everywhere, make it optional or only on certain node types.
|
||||||
|
|
||||||
|
### 4. CSS
|
||||||
|
|
||||||
|
- **Containment** – `BaseNode` already uses `contain: layout` where dimensions are set. Keeping layout/paint contained per node helps.
|
||||||
|
- **Simpler styles** – For many nodes, avoid heavy shadows, blur, or complex animations on the node container; they can cost a lot during pan/zoom/drag.
|
||||||
|
|
||||||
|
### 5. RenderingNode pipeline
|
||||||
|
|
||||||
|
The render node runs Nunjucks + Kroki on signatures (config, edges, variables, functions). It already avoids re-running when irrelevant data changes. If you add more inputs, keep them in the signature pattern so the effect only runs when something that affects the result actually changes.
|
||||||
|
|
||||||
|
### 6. Large graphs
|
||||||
|
|
||||||
|
- **Hide/collapse** – For big trees, use the `hidden` property (or similar) so only expanded nodes are rendered.
|
||||||
|
- **Virtualization** – React Flow doesn’t virtualize by default. For 100+ nodes, consider only rendering nodes in view (e.g. with `onlyRenderVisibleElements` if/when available in your version) or a custom viewport-based filter.
|
||||||
|
|
||||||
|
### 7. Build / runtime
|
||||||
|
|
||||||
|
- **Production build** – Use `vite build` and test with the production build; React and Vite are much faster without dev mode and source maps.
|
||||||
|
- **React DevTools Profiler** – Record while dragging or editing to see which components re-render and how often; that will guide where to add more memoization or split context.
|
||||||
68
src/App.tsx
68
src/App.tsx
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import React, { useCallback, useMemo, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
@@ -8,10 +8,12 @@ import {
|
|||||||
addEdge,
|
addEdge,
|
||||||
useNodesState,
|
useNodesState,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
|
applyNodeChanges,
|
||||||
type Node,
|
type Node,
|
||||||
type Edge,
|
type Edge,
|
||||||
type Connection,
|
type Connection,
|
||||||
type ColorMode,
|
type ColorMode,
|
||||||
|
type NodeChange,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import ConfigNode from './components/graph/ConfigNode'
|
import ConfigNode from './components/graph/ConfigNode'
|
||||||
import FunctionNode from './components/graph/FunctionNode'
|
import FunctionNode from './components/graph/FunctionNode'
|
||||||
@@ -64,7 +66,7 @@ const initialEdges: Edge[] = [
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { theme, toggleTheme } = useTheme()
|
const { theme, toggleTheme } = useTheme()
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
|
const [nodes, setNodes, onNodesChangeBase] = useNodesState(initialNodes)
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
|
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
|
||||||
const [rfInstance, setRfInstance] = React.useState<any | null>(null)
|
const [rfInstance, setRfInstance] = React.useState<any | null>(null)
|
||||||
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
|
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
|
||||||
@@ -74,6 +76,36 @@ export default function App() {
|
|||||||
const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null)
|
const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null)
|
||||||
const [contextTarget, setContextTarget] = React.useState<null | { type: 'canvas'; clientX: number; clientY: number }>(null)
|
const [contextTarget, setContextTarget] = React.useState<null | { type: 'canvas'; clientX: number; clientY: number }>(null)
|
||||||
|
|
||||||
|
// Throttle node changes during drag: merge changes and flush at most once per animation frame to reduce re-renders
|
||||||
|
const pendingChangesRef = useRef<NodeChange<Node>[]>([])
|
||||||
|
const rafRef = useRef<number | null>(null)
|
||||||
|
const onNodesChange = useCallback(
|
||||||
|
(changes: NodeChange<Node>[]) => {
|
||||||
|
if (changes.length === 0) return
|
||||||
|
const pending = pendingChangesRef.current
|
||||||
|
for (const c of changes) {
|
||||||
|
const id = (c as { id?: string }).id
|
||||||
|
if (id != null) {
|
||||||
|
const i = pending.findIndex((p) => (p as { id?: string }).id === id)
|
||||||
|
if (i >= 0) pending[i] = c
|
||||||
|
else pending.push(c)
|
||||||
|
} else {
|
||||||
|
pending.push(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rafRef.current === null) {
|
||||||
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
|
rafRef.current = null
|
||||||
|
const toApply = pendingChangesRef.current.splice(0, pendingChangesRef.current.length)
|
||||||
|
if (toApply.length > 0) {
|
||||||
|
setNodes((nds) => applyNodeChanges(toApply, nds))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setNodes]
|
||||||
|
)
|
||||||
|
|
||||||
const nodeTypes = React.useMemo(
|
const nodeTypes = React.useMemo(
|
||||||
() => ({ config: ConfigNode, render: RenderingNode, variable: VariableNode, function: FunctionNode }),
|
() => ({ config: ConfigNode, render: RenderingNode, variable: VariableNode, function: FunctionNode }),
|
||||||
[]
|
[]
|
||||||
@@ -81,6 +113,8 @@ export default function App() {
|
|||||||
|
|
||||||
const edgeTypes = React.useMemo(() => ({ animated: AnimatedEdge }), [])
|
const edgeTypes = React.useMemo(() => ({ animated: AnimatedEdge }), [])
|
||||||
|
|
||||||
|
const defaultEdgeOptions = React.useMemo(() => ({ type: 'animated' as const }), [])
|
||||||
|
|
||||||
const onConnect = React.useCallback(
|
const onConnect = React.useCallback(
|
||||||
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
|
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
|
||||||
[setEdges]
|
[setEdges]
|
||||||
@@ -121,6 +155,31 @@ export default function App() {
|
|||||||
setRfInstance(instance)
|
setRfInstance(instance)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const flowContextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
nodes,
|
||||||
|
setNodes,
|
||||||
|
edges,
|
||||||
|
setEdges,
|
||||||
|
renamingNodeId,
|
||||||
|
setRenamingNodeId,
|
||||||
|
connectionFrom,
|
||||||
|
setConnectionFrom,
|
||||||
|
isValidConnection,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
nodes,
|
||||||
|
setNodes,
|
||||||
|
edges,
|
||||||
|
setEdges,
|
||||||
|
renamingNodeId,
|
||||||
|
setRenamingNodeId,
|
||||||
|
connectionFrom,
|
||||||
|
setConnectionFrom,
|
||||||
|
isValidConnection,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
const onContextMenuCapture = React.useCallback((ev: React.MouseEvent) => {
|
const onContextMenuCapture = React.useCallback((ev: React.MouseEvent) => {
|
||||||
const target = ev.target as HTMLElement
|
const target = ev.target as HTMLElement
|
||||||
const nodeEl = target.closest('.react-flow__node')
|
const nodeEl = target.closest('.react-flow__node')
|
||||||
@@ -236,7 +295,7 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
<FlowContext.Provider value={{ nodes, setNodes, edges, setEdges, renamingNodeId, setRenamingNodeId, connectionFrom, setConnectionFrom, isValidConnection }}>
|
<FlowContext.Provider value={flowContextValue}>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div style={{ width: '100%', height: '100%' }}>
|
<div style={{ width: '100%', height: '100%' }}>
|
||||||
@@ -252,12 +311,13 @@ export default function App() {
|
|||||||
isValidConnection={isValidConnection}
|
isValidConnection={isValidConnection}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
defaultEdgeOptions={{ type: 'animated' }}
|
defaultEdgeOptions={defaultEdgeOptions}
|
||||||
colorMode={theme as ColorMode}
|
colorMode={theme as ColorMode}
|
||||||
snapToGrid
|
snapToGrid
|
||||||
snapGrid={SNAP_GRID}
|
snapGrid={SNAP_GRID}
|
||||||
fitView
|
fitView
|
||||||
onInit={onInit}
|
onInit={onInit}
|
||||||
|
nodeDragThreshold={1}
|
||||||
>
|
>
|
||||||
<Background variant="dots" gap={20} />
|
<Background variant="dots" gap={20} />
|
||||||
<Controls />
|
<Controls />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { memo, useCallback, useContext, useMemo, useRef } from 'react'
|
|||||||
import { autocompletion } from '@codemirror/autocomplete'
|
import { autocompletion } from '@codemirror/autocomplete'
|
||||||
import CodeMirror from '@uiw/react-codemirror'
|
import CodeMirror from '@uiw/react-codemirror'
|
||||||
import FlowContext from '../../lib/flowContext'
|
import FlowContext from '../../lib/flowContext'
|
||||||
|
import { nodePropsAreEqual } from '../../lib/flowUtils'
|
||||||
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
||||||
import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete'
|
import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete'
|
||||||
import { plantumlLanguage } from '../../lib/plantumlLanguage'
|
import { plantumlLanguage } from '../../lib/plantumlLanguage'
|
||||||
@@ -41,11 +42,22 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
|
|||||||
|
|
||||||
const editorRef = useRef<unknown>(null)
|
const editorRef = useRef<unknown>(null)
|
||||||
|
|
||||||
const incomingEdges = ctx?.edges?.filter((e: any) => e.target === id) ?? []
|
const edges = ctx?.edges ?? []
|
||||||
const incomingIds = incomingEdges.map((e: any) => e.source).sort()
|
const nodes = ctx?.nodes ?? []
|
||||||
const connectedConfigNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'config')
|
const incomingEdges = useMemo(() => edges.filter((e: any) => e.target === id), [edges, id])
|
||||||
const connectedVariableNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'variable')
|
const incomingIds = useMemo(() => incomingEdges.map((e: any) => e.source).sort(), [incomingEdges])
|
||||||
const connectedFunctionNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'function')
|
const connectedConfigNodes = useMemo(
|
||||||
|
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'config'),
|
||||||
|
[nodes, incomingIds]
|
||||||
|
)
|
||||||
|
const connectedVariableNodes = useMemo(
|
||||||
|
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'variable'),
|
||||||
|
[nodes, incomingIds]
|
||||||
|
)
|
||||||
|
const connectedFunctionNodes = useMemo(
|
||||||
|
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'function'),
|
||||||
|
[nodes, incomingIds]
|
||||||
|
)
|
||||||
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
|
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
|
||||||
|
|
||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
@@ -273,7 +285,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
|
|||||||
</BaseNodeFooter>
|
</BaseNodeFooter>
|
||||||
</BaseNode>
|
</BaseNode>
|
||||||
)
|
)
|
||||||
})
|
}, nodePropsAreEqual)
|
||||||
|
|
||||||
ConfigNode.displayName = 'ConfigNode'
|
ConfigNode.displayName = 'ConfigNode'
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { memo, useCallback, useContext, useMemo, useRef } from 'react'
|
|||||||
import CodeMirror from '@uiw/react-codemirror'
|
import CodeMirror from '@uiw/react-codemirror'
|
||||||
import { javascript } from '@codemirror/lang-javascript'
|
import { javascript } from '@codemirror/lang-javascript'
|
||||||
import FlowContext from '../../lib/flowContext'
|
import FlowContext from '../../lib/flowContext'
|
||||||
|
import { nodePropsAreEqual } from '../../lib/flowUtils'
|
||||||
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
||||||
import { useTheme } from '../../lib/themeContext'
|
import { useTheme } from '../../lib/themeContext'
|
||||||
import {
|
import {
|
||||||
@@ -171,7 +172,7 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
|
|||||||
</BaseNodeFooter>
|
</BaseNodeFooter>
|
||||||
</BaseNode>
|
</BaseNode>
|
||||||
)
|
)
|
||||||
})
|
}, nodePropsAreEqual)
|
||||||
|
|
||||||
FunctionNode.displayName = 'FunctionNode'
|
FunctionNode.displayName = 'FunctionNode'
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
BaseNodeFooter,
|
BaseNodeFooter,
|
||||||
BaseNodeHeaderRow,
|
BaseNodeHeaderRow,
|
||||||
} from './BaseNode'
|
} from './BaseNode'
|
||||||
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from '../../lib/flowUtils'
|
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId, nodePropsAreEqual } from '../../lib/flowUtils'
|
||||||
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
|
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
|
||||||
import { NodeHeaderTitle } from './NodeHeaderTitle'
|
import { NodeHeaderTitle } from './NodeHeaderTitle'
|
||||||
import { NodeMenubar } from './NodeMenubar'
|
import { NodeMenubar } from './NodeMenubar'
|
||||||
@@ -611,7 +611,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
</BaseNode>
|
</BaseNode>
|
||||||
</NodeStatusIndicator>
|
</NodeStatusIndicator>
|
||||||
)
|
)
|
||||||
})
|
}, nodePropsAreEqual)
|
||||||
|
|
||||||
RenderingNode.displayName = 'RenderingNode'
|
RenderingNode.displayName = 'RenderingNode'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { memo, useCallback, useContext } from 'react'
|
import React, { memo, useCallback, useContext } from 'react'
|
||||||
import FlowContext from '../../lib/flowContext'
|
import FlowContext from '../../lib/flowContext'
|
||||||
|
import { nodePropsAreEqual } from '../../lib/flowUtils'
|
||||||
import {
|
import {
|
||||||
BaseNode,
|
BaseNode,
|
||||||
BaseNodeContent,
|
BaseNodeContent,
|
||||||
@@ -138,7 +139,7 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
|
|||||||
</BaseNodeFooter>
|
</BaseNodeFooter>
|
||||||
</BaseNode>
|
</BaseNode>
|
||||||
)
|
)
|
||||||
})
|
}, nodePropsAreEqual)
|
||||||
|
|
||||||
VariableNode.displayName = 'VariableNode'
|
VariableNode.displayName = 'VariableNode'
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Use as second argument to React.memo() for node components.
|
||||||
|
* Skips re-render when only position (or other unrelated props) changed,
|
||||||
|
* so dragging one node doesn't force other nodes to re-render.
|
||||||
|
*/
|
||||||
|
export function nodePropsAreEqual<P extends { id?: string; data?: any; width?: number; height?: number; selected?: boolean }>(
|
||||||
|
prev: P,
|
||||||
|
next: P
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
prev.id === next.id &&
|
||||||
|
prev.data === next.data &&
|
||||||
|
prev.width === next.width &&
|
||||||
|
prev.height === next.height &&
|
||||||
|
prev.selected === next.selected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const PREFIX_BY_TYPE: Record<string, string> = {
|
export const PREFIX_BY_TYPE: Record<string, string> = {
|
||||||
config: 'cfg_',
|
config: 'cfg_',
|
||||||
render: 'rnd_',
|
render: 'rnd_',
|
||||||
|
|||||||
Reference in New Issue
Block a user