From 5b96263a45ff9ef8eeda733e1ee23d3e5db419ea Mon Sep 17 00:00:00 2001 From: aamanrebello Date: Sun, 20 Sep 2026 11:02:37 +0100 Subject: [PATCH] perf(calm-hub-ui): memoize visualiser node/edge components and cut hover re-allocations Wraps CustomNode, SystemGroupNode, and FloatingEdge in React.memo, and changes the node hover handlers to only allocate a new node object when its zIndex actually changes, instead of remapping every node on every mouseenter/mouseleave. No behavior change, fewer re-renders on large diagrams. Co-Authored-By: Claude Sonnet 5 --- .../components/reactflow/CustomNode.tsx | 8 +++-- .../components/reactflow/FloatingEdge.tsx | 8 +++-- .../components/reactflow/SystemGroupNode.tsx | 7 +++-- .../reactflow/hooks/useGraphInteractions.ts | 31 +++++++++---------- 4 files changed, 30 insertions(+), 24 deletions(-) diff --git a/calm-hub-ui/src/visualizer/components/reactflow/CustomNode.tsx b/calm-hub-ui/src/visualizer/components/reactflow/CustomNode.tsx index ae2324f0d8..f175d47b2c 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/CustomNode.tsx +++ b/calm-hub-ui/src/visualizer/components/reactflow/CustomNode.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from 'react'; +import { memo, useState, type ReactNode } from 'react'; import { Handle, Position, NodeProps } from 'reactflow'; import { Shield, @@ -72,7 +72,7 @@ function PanelButton({ ); } -export function CustomNode({ data }: NodeProps) { +function CustomNodeComponent({ data }: NodeProps) { const [isHovered, setIsHovered] = useState(false); const { onNavigateToDetailedArch } = useDiagramActions(); @@ -400,4 +400,6 @@ export function CustomNode({ data }: NodeProps) { )} ); -}; +} + +export const CustomNode = memo(CustomNodeComponent); diff --git a/calm-hub-ui/src/visualizer/components/reactflow/FloatingEdge.tsx b/calm-hub-ui/src/visualizer/components/reactflow/FloatingEdge.tsx index fad6cc2739..7aa22c3353 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/FloatingEdge.tsx +++ b/calm-hub-ui/src/visualizer/components/reactflow/FloatingEdge.tsx @@ -1,10 +1,10 @@ -import { useState, useCallback } from 'react'; +import { memo, useState, useCallback } from 'react'; import { EdgeProps, getBezierPath, EdgeLabelRenderer, useStore } from 'reactflow'; import { getEdgeParams } from './utils/floatingEdges.js'; import { EdgeBadge, EdgeTooltip, getBadgeStyle } from './edge-components/index.js'; import type { EdgeData } from '../../contracts/contracts.js'; -export function FloatingEdge({ +function FloatingEdgeComponent({ id, source, target, @@ -116,7 +116,9 @@ export function FloatingEdge({ )} ); -}; +} + +export const FloatingEdge = memo(FloatingEdgeComponent); /** * Calculate offset positions for bidirectional edges diff --git a/calm-hub-ui/src/visualizer/components/reactflow/SystemGroupNode.tsx b/calm-hub-ui/src/visualizer/components/reactflow/SystemGroupNode.tsx index bbc94aa995..091d02a1e7 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/SystemGroupNode.tsx +++ b/calm-hub-ui/src/visualizer/components/reactflow/SystemGroupNode.tsx @@ -1,7 +1,8 @@ +import { memo } from 'react'; import { NodeProps, Handle, Position } from 'reactflow'; import { THEME } from './theme'; -export function SystemGroupNode({ data }: NodeProps) { +function SystemGroupNodeComponent({ data }: NodeProps) { return (
); -}; +} + +export const SystemGroupNode = memo(SystemGroupNodeComponent); diff --git a/calm-hub-ui/src/visualizer/components/reactflow/hooks/useGraphInteractions.ts b/calm-hub-ui/src/visualizer/components/reactflow/hooks/useGraphInteractions.ts index ed9c368e17..c06c2b8167 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/hooks/useGraphInteractions.ts +++ b/calm-hub-ui/src/visualizer/components/reactflow/hooks/useGraphInteractions.ts @@ -88,15 +88,16 @@ export function useGraphInteractions({ const handleNodeMouseEnter = useCallback( (_event: React.MouseEvent, node: Node) => { setNodes((nds) => - nds.map((n) => ({ - ...n, - style: { - ...n.style, - zIndex: n.id === node.id && !isGroupType(n.type) ? 1000 - : isGroupType(n.type) ? -1 - : 1, - }, - })) + nds.map((n) => { + const zIndex = n.id === node.id && !isGroupType(n.type) ? 1000 + : isGroupType(n.type) ? -1 + : 1; + // Only allocate a new node object when the value actually + // changes, so memoized node components (CustomNode, + // SystemGroupNode) don't re-render for untouched nodes. + if (n.style?.zIndex === zIndex) return n; + return { ...n, style: { ...n.style, zIndex } }; + }) ); }, [setNodes, isGroupType] @@ -104,13 +105,11 @@ export function useGraphInteractions({ const handleNodeMouseLeave = useCallback(() => { setNodes((nds) => - nds.map((n) => ({ - ...n, - style: { - ...n.style, - zIndex: isGroupType(n.type) ? -1 : 1, - }, - })) + nds.map((n) => { + const zIndex = isGroupType(n.type) ? -1 : 1; + if (n.style?.zIndex === zIndex) return n; + return { ...n, style: { ...n.style, zIndex } }; + }) ); }, [setNodes, isGroupType]);