performance

This commit is contained in:
2026-03-08 20:53:34 +01:00
parent 0baad46ff0
commit 53106cd051
7 changed files with 154 additions and 14 deletions

View File

@@ -1,4 +1,4 @@
import React from 'react'
import React, { useCallback, useMemo, useRef } from 'react'
import {
ReactFlow,
ReactFlowProvider,
@@ -8,10 +8,12 @@ import {
addEdge,
useNodesState,
useEdgesState,
applyNodeChanges,
type Node,
type Edge,
type Connection,
type ColorMode,
type NodeChange,
} from '@xyflow/react'
import ConfigNode from './components/graph/ConfigNode'
import FunctionNode from './components/graph/FunctionNode'
@@ -64,7 +66,7 @@ const initialEdges: Edge[] = [
export default function App() {
const { theme, toggleTheme } = useTheme()
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
const [nodes, setNodes, onNodesChangeBase] = useNodesState(initialNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
const [rfInstance, setRfInstance] = React.useState<any | 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 [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(
() => ({ config: ConfigNode, render: RenderingNode, variable: VariableNode, function: FunctionNode }),
[]
@@ -81,6 +113,8 @@ export default function App() {
const edgeTypes = React.useMemo(() => ({ animated: AnimatedEdge }), [])
const defaultEdgeOptions = React.useMemo(() => ({ type: 'animated' as const }), [])
const onConnect = React.useCallback(
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
@@ -121,6 +155,31 @@ export default function App() {
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 target = ev.target as HTMLElement
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" />}
</Button>
<FlowContext.Provider value={{ nodes, setNodes, edges, setEdges, renamingNodeId, setRenamingNodeId, connectionFrom, setConnectionFrom, isValidConnection }}>
<FlowContext.Provider value={flowContextValue}>
<ContextMenu>
<ContextMenuTrigger asChild>
<div style={{ width: '100%', height: '100%' }}>
@@ -252,12 +311,13 @@ export default function App() {
isValidConnection={isValidConnection}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={{ type: 'animated' }}
defaultEdgeOptions={defaultEdgeOptions}
colorMode={theme as ColorMode}
snapToGrid
snapGrid={SNAP_GRID}
fitView
onInit={onInit}
nodeDragThreshold={1}
>
<Background variant="dots" gap={20} />
<Controls />

View File

@@ -2,6 +2,7 @@ import React, { memo, useCallback, useContext, useMemo, useRef } from 'react'
import { autocompletion } from '@codemirror/autocomplete'
import CodeMirror from '@uiw/react-codemirror'
import FlowContext from '../../lib/flowContext'
import { nodePropsAreEqual } from '../../lib/flowUtils'
import { useResizeHeight } from '../../hooks/useResizeHeight'
import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete'
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 incomingEdges = ctx?.edges?.filter((e: any) => e.target === id) ?? []
const incomingIds = incomingEdges.map((e: any) => e.source).sort()
const connectedConfigNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'config')
const connectedVariableNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'variable')
const connectedFunctionNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'function')
const edges = ctx?.edges ?? []
const nodes = ctx?.nodes ?? []
const incomingEdges = useMemo(() => edges.filter((e: any) => e.target === id), [edges, id])
const incomingIds = useMemo(() => incomingEdges.map((e: any) => e.source).sort(), [incomingEdges])
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 onChange = useCallback(
@@ -273,7 +285,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
</BaseNodeFooter>
</BaseNode>
)
})
}, nodePropsAreEqual)
ConfigNode.displayName = 'ConfigNode'

View File

@@ -2,6 +2,7 @@ import React, { memo, useCallback, useContext, useMemo, useRef } from 'react'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import FlowContext from '../../lib/flowContext'
import { nodePropsAreEqual } from '../../lib/flowUtils'
import { useResizeHeight } from '../../hooks/useResizeHeight'
import { useTheme } from '../../lib/themeContext'
import {
@@ -171,7 +172,7 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
</BaseNodeFooter>
</BaseNode>
)
})
}, nodePropsAreEqual)
FunctionNode.displayName = 'FunctionNode'

View File

@@ -8,7 +8,7 @@ import {
BaseNodeFooter,
BaseNodeHeaderRow,
} 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 { NodeHeaderTitle } from './NodeHeaderTitle'
import { NodeMenubar } from './NodeMenubar'
@@ -611,7 +611,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
</BaseNode>
</NodeStatusIndicator>
)
})
}, nodePropsAreEqual)
RenderingNode.displayName = 'RenderingNode'

View File

@@ -1,5 +1,6 @@
import React, { memo, useCallback, useContext } from 'react'
import FlowContext from '../../lib/flowContext'
import { nodePropsAreEqual } from '../../lib/flowUtils'
import {
BaseNode,
BaseNodeContent,
@@ -138,7 +139,7 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
</BaseNodeFooter>
</BaseNode>
)
})
}, nodePropsAreEqual)
VariableNode.displayName = 'VariableNode'

View File

@@ -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> = {
config: 'cfg_',
render: 'rnd_',