refactor(node-renderer): simplify selectors, remove reselect, cleanup

This commit is contained in:
moklick
2023-12-14 12:32:41 +01:00
parent eb0b09c00e
commit 71f038a319
12 changed files with 185 additions and 199 deletions
@@ -64,7 +64,6 @@ const StressFlow = () => {
minZoom={0.2}
fitView
>
<MiniMap />
<Controls />
<Background />
-1
View File
@@ -57,7 +57,6 @@
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"reselect": "^4.1.8",
"zustand": "^4.4.0"
},
"peerDependencies": {
@@ -1,30 +1,18 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ComponentType, memo, MouseEvent } from 'react';
import { shallow } from 'zustand/shallow';
import { ComponentType, memo } from 'react';
import { NodeOrigin, getNodePositionWithOrigin } from '@xyflow/system';
import { shallow } from 'zustand/shallow';
import { useStore } from '../../hooks/useStore';
import type { ReactFlowState } from '../../types';
import MiniMapNode from './MiniMapNode';
import type { MiniMapNodes as MiniMapNodesProps, GetMiniMapNodeAttribute, MiniMapNodeProps } from './types';
import { createSelector } from 'reselect';
declare const window: any;
const selector = (s: ReactFlowState) => s.nodeOrigin;
const selectorNodeIds = createSelector(
[(s: ReactFlowState) => s.nodes],
(nodes) =>
nodes
.filter((node) => !node.hidden && (node.computed?.width || node.width) && (node.computed?.height || node.height))
.map((node) => node.id),
{
memoizeOptions: {
resultEqualityCheck: shallow,
},
}
);
const selectorNodeIds = (s: ReactFlowState) => s.nodes.map((node) => node.id);
const getAttrFunction = (func: any): GetMiniMapNodeAttribute => (func instanceof Function ? func : () => func);
function MiniMapNodes({
@@ -38,7 +26,7 @@ function MiniMapNodes({
nodeComponent: NodeComponent = MiniMapNode,
onClick,
}: MiniMapNodesProps) {
const nodes = useStore(selectorNodeIds);
const nodeIds = useStore(selectorNodeIds, shallow);
const nodeOrigin = useStore(selector);
const nodeColorFunc = getAttrFunction(nodeColor);
const nodeStrokeColorFunc = getAttrFunction(nodeStrokeColor);
@@ -48,7 +36,7 @@ function MiniMapNodes({
return (
<>
{nodes.map((nodeId) => (
{nodeIds.map((nodeId) => (
// The split of responsibilities between MiniMapNodes and
// NodeComponentWrapper may appear weird. However, its designed to
// minimize the cost of updates when individual nodes change.
@@ -96,16 +84,16 @@ const NodeComponentWrapper = memo(function NodeComponentWrapper({
shapeRendering: string;
}) {
const node = useStore((s) => s.nodeLookup.get(id));
if (!node) {
if (!node || node.hidden || !(node.computed?.width || node.width) || !(node.computed?.height || node.height)) {
return null;
}
const { x, y } = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute;
const positionOrigin = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute;
return (
<NodeComponent
x={x}
y={y}
x={positionOrigin.x}
y={positionOrigin.y}
width={node.computed?.width ?? node.width ?? 0}
height={node.computed?.height ?? node.height ?? 0}
style={node.style}
@@ -22,10 +22,10 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
id,
type,
data,
xPos,
yPos,
xPosOrigin,
yPosOrigin,
positionAbsoluteX,
positionAbsoluteY,
selected,
onClick,
onMouseEnter,
@@ -52,7 +52,6 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
disableKeyboardA11y,
ariaLabel,
rfId,
positionAbsolute,
width,
height,
}: WrapNodeProps) => {
@@ -111,7 +110,7 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
store.setState({
ariaLiveMessage: `Moved selected node ${event.key
.replace('Arrow', '')
.toLowerCase()}. New position, x: ${~~xPos}, y: ${~~yPos}`,
.toLowerCase()}. New position, x: ${~~positionAbsoluteX}, y: ${~~positionAbsoluteY}`,
});
updatePositions({
@@ -212,7 +211,8 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
type={type}
width={width}
height={height}
positionAbsolute={positionAbsolute}
positionAbsoluteX={positionAbsoluteX}
positionAbsoluteY={positionAbsoluteY}
selected={selected}
isConnectable={isConnectable}
sourcePosition={sourcePosition}
@@ -0,0 +1,104 @@
import { memo, type ComponentType } from 'react';
import { internalsSymbol, errorMessages, Position, clampPosition, getPositionWithOrigin } from '@xyflow/system';
import { useStore } from '../../hooks/useStore';
import type { ReactFlowState, WrapNodeProps } from '../../types';
import { NodeRendererProps } from '.';
function NodeComponentWrapper(props: {
id: string;
nodeExtent: NodeRendererProps['nodeExtent'];
nodeTypes: NodeRendererProps['nodeTypes'];
nodeOrigin: NodeRendererProps['nodeOrigin'];
onNodeClick: NodeRendererProps['onNodeClick'];
onNodeMouseEnter: NodeRendererProps['onNodeMouseEnter'];
onNodeMouseMove: NodeRendererProps['onNodeMouseMove'];
onNodeMouseLeave: NodeRendererProps['onNodeMouseLeave'];
onNodeContextMenu: NodeRendererProps['onNodeContextMenu'];
onNodeDoubleClick: NodeRendererProps['onNodeDoubleClick'];
noDragClassName: NodeRendererProps['noDragClassName'];
noPanClassName: NodeRendererProps['noPanClassName'];
rfId: NodeRendererProps['rfId'];
disableKeyboardA11y: NodeRendererProps['disableKeyboardA11y'];
resizeObserver: ResizeObserver | null;
nodesDraggable: boolean;
nodesConnectable: boolean;
nodesFocusable: boolean;
elementsSelectable: boolean;
onError: ReactFlowState['onError'];
}) {
const node = useStore((s) => s.nodeLookup.get(props.id));
if (!node) return null;
let nodeType = node.type || 'default';
if (!props.nodeTypes[nodeType]) {
props.onError?.('003', errorMessages['error003'](nodeType));
nodeType = 'default';
}
const NodeComponent = (props.nodeTypes[nodeType] || props.nodeTypes.default) as ComponentType<WrapNodeProps>;
const isDraggable = !!(node.draggable || (props.nodesDraggable && typeof node.draggable === 'undefined'));
const isSelectable = !!(node.selectable || (props.elementsSelectable && typeof node.selectable === 'undefined'));
const isConnectable = !!(node.connectable || (props.nodesConnectable && typeof node.connectable === 'undefined'));
const isFocusable = !!(node.focusable || (props.nodesFocusable && typeof node.focusable === 'undefined'));
const absolutePositionClamped = props.nodeExtent
? clampPosition(node.computed?.positionAbsolute, props.nodeExtent)
: node.computed?.positionAbsolute || { x: 0, y: 0 };
const posOrigin = getPositionWithOrigin({
x: absolutePositionClamped.x,
y: absolutePositionClamped.y,
width: node.computed?.width ?? node.width ?? 0,
height: node.computed?.height ?? node.height ?? 0,
origin: node.origin || props.nodeOrigin,
});
const initialized = (!!node.computed?.width && !!node.computed?.height) || (!!node.width && !!node.height);
return (
<NodeComponent
key={node.id}
id={node.id}
className={node.className}
style={node.style}
width={node.width ?? undefined}
height={node.height ?? undefined}
type={nodeType}
data={node.data}
sourcePosition={node.sourcePosition || Position.Bottom}
targetPosition={node.targetPosition || Position.Top}
hidden={node.hidden}
xPosOrigin={posOrigin.x}
yPosOrigin={posOrigin.y}
positionAbsoluteX={absolutePositionClamped.x}
positionAbsoluteY={absolutePositionClamped.y}
onClick={props.onNodeClick}
onMouseEnter={props.onNodeMouseEnter}
onMouseMove={props.onNodeMouseMove}
onMouseLeave={props.onNodeMouseLeave}
onContextMenu={props.onNodeContextMenu}
onDoubleClick={props.onNodeDoubleClick}
selected={!!node.selected}
isDraggable={isDraggable}
isSelectable={isSelectable}
isConnectable={isConnectable}
isFocusable={isFocusable}
resizeObserver={props.resizeObserver}
dragHandle={node.dragHandle}
zIndex={node[internalsSymbol]?.z ?? 0}
isParent={!!node[internalsSymbol]?.isParent}
noDragClassName={props.noDragClassName}
noPanClassName={props.noPanClassName}
initialized={initialized}
rfId={props.rfId}
disableKeyboardA11y={props.disableKeyboardA11y}
ariaLabel={node.ariaLabel}
/>
);
}
NodeComponentWrapper.displayName = 'NodeComponentWrapper';
export default memo(NodeComponentWrapper);
@@ -1,14 +1,15 @@
import { memo, useMemo, useEffect, useRef, type ComponentType } from 'react';
import { memo } from 'react';
import { shallow } from 'zustand/shallow';
import { internalsSymbol, errorMessages, Position, clampPosition, getPositionWithOrigin } from '@xyflow/system';
import useVisibleNodesIds from '../../hooks/useVisibleNodes';
import { useStore } from '../../hooks/useStore';
import { containerStyle } from '../../styles/utils';
import { GraphViewProps } from '../GraphView';
import type { NodeTypesWrapped, ReactFlowState, WrapNodeProps } from '../../types';
import type { NodeTypesWrapped, ReactFlowState } from '../../types';
import NodeComponentWrapper from './NodeComponentWrapper';
import useResizeObserver from './useResizeObserver';
type NodeRendererProps = Pick<
export type NodeRendererProps = Pick<
GraphViewProps,
| 'onNodeClick'
| 'onNodeDoubleClick'
@@ -32,46 +33,13 @@ const selector = (s: ReactFlowState) => ({
nodesConnectable: s.nodesConnectable,
nodesFocusable: s.nodesFocusable,
elementsSelectable: s.elementsSelectable,
updateNodeDimensions: s.updateNodeDimensions,
onError: s.onError,
});
const NodeRenderer = (props: NodeRendererProps) => {
const { nodesDraggable, nodesConnectable, nodesFocusable, elementsSelectable, updateNodeDimensions, onError } =
useStore(selector, shallow);
const { nodesDraggable, nodesConnectable, nodesFocusable, elementsSelectable, onError } = useStore(selector, shallow);
const nodeIds = useVisibleNodesIds(props.onlyRenderVisibleElements);
const resizeObserverRef = useRef<ResizeObserver>();
const resizeObserver = useMemo(() => {
if (typeof ResizeObserver === 'undefined') {
return null;
}
const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
const updates = new Map();
entries.forEach((entry: ResizeObserverEntry) => {
const id = entry.target.getAttribute('data-id') as string;
updates.set(id, {
id,
nodeElement: entry.target as HTMLDivElement,
forceUpdate: true,
});
});
updateNodeDimensions(updates);
});
resizeObserverRef.current = observer;
return observer;
}, []);
useEffect(() => {
return () => {
resizeObserverRef?.current?.disconnect();
};
}, []);
const resizeObserver = useResizeObserver();
return (
<div className="react-flow__nodes" style={containerStyle}>
@@ -129,103 +97,6 @@ const NodeRenderer = (props: NodeRendererProps) => {
);
};
const NodeComponentWrapper = memo(function NodeComponentWrapper(props: {
id: string;
nodeExtent: NodeRendererProps['nodeExtent'];
nodeTypes: NodeRendererProps['nodeTypes'];
nodeOrigin: NodeRendererProps['nodeOrigin'];
onNodeClick: NodeRendererProps['onNodeClick'];
onNodeMouseEnter: NodeRendererProps['onNodeMouseEnter'];
onNodeMouseMove: NodeRendererProps['onNodeMouseMove'];
onNodeMouseLeave: NodeRendererProps['onNodeMouseLeave'];
onNodeContextMenu: NodeRendererProps['onNodeContextMenu'];
onNodeDoubleClick: NodeRendererProps['onNodeDoubleClick'];
noDragClassName: NodeRendererProps['noDragClassName'];
noPanClassName: NodeRendererProps['noPanClassName'];
rfId: NodeRendererProps['rfId'];
disableKeyboardA11y: NodeRendererProps['disableKeyboardA11y'];
resizeObserver: ResizeObserver | null;
nodesDraggable: boolean;
nodesConnectable: boolean;
nodesFocusable: boolean;
elementsSelectable: boolean;
onError: ReactFlowState['onError'];
}) {
const node = useStore((s) => s.nodeLookup.get(props.id));
if (!node) return null;
let nodeType = node.type || 'default';
if (!props.nodeTypes[nodeType]) {
props.onError?.('003', errorMessages['error003'](nodeType));
nodeType = 'default';
}
const NodeComponent = (props.nodeTypes[nodeType] || props.nodeTypes.default) as ComponentType<WrapNodeProps>;
const isDraggable = !!(node.draggable || (props.nodesDraggable && typeof node.draggable === 'undefined'));
const isSelectable = !!(node.selectable || (props.elementsSelectable && typeof node.selectable === 'undefined'));
const isConnectable = !!(node.connectable || (props.nodesConnectable && typeof node.connectable === 'undefined'));
const isFocusable = !!(node.focusable || (props.nodesFocusable && typeof node.focusable === 'undefined'));
const clampedPosition = props.nodeExtent
? clampPosition(node.computed?.positionAbsolute, props.nodeExtent)
: node.computed?.positionAbsolute;
const posX = clampedPosition?.x ?? 0;
const posY = clampedPosition?.y ?? 0;
const posOrigin = getPositionWithOrigin({
x: posX,
y: posY,
width: node.computed?.width ?? node.width ?? 0,
height: node.computed?.height ?? node.height ?? 0,
origin: node.origin || props.nodeOrigin,
});
const initialized = (!!node.computed?.width && !!node.computed?.height) || (!!node.width && !!node.height);
return (
<NodeComponent
key={node.id}
id={node.id}
className={node.className}
style={node.style}
width={node.width ?? undefined}
height={node.height ?? undefined}
type={nodeType}
data={node.data}
sourcePosition={node.sourcePosition || Position.Bottom}
targetPosition={node.targetPosition || Position.Top}
hidden={node.hidden}
xPos={posX}
yPos={posY}
xPosOrigin={posOrigin.x}
yPosOrigin={posOrigin.y}
positionAbsolute={clampedPosition || { x: 0, y: 0 }}
onClick={props.onNodeClick}
onMouseEnter={props.onNodeMouseEnter}
onMouseMove={props.onNodeMouseMove}
onMouseLeave={props.onNodeMouseLeave}
onContextMenu={props.onNodeContextMenu}
onDoubleClick={props.onNodeDoubleClick}
selected={!!node.selected}
isDraggable={isDraggable}
isSelectable={isSelectable}
isConnectable={isConnectable}
isFocusable={isFocusable}
resizeObserver={props.resizeObserver}
dragHandle={node.dragHandle}
zIndex={node[internalsSymbol]?.z ?? 0}
isParent={!!node[internalsSymbol]?.isParent}
noDragClassName={props.noDragClassName}
noPanClassName={props.noPanClassName}
initialized={initialized}
rfId={props.rfId}
disableKeyboardA11y={props.disableKeyboardA11y}
ariaLabel={node.ariaLabel}
/>
);
});
NodeRenderer.displayName = 'NodeRenderer';
export default memo(NodeRenderer);
@@ -0,0 +1,44 @@
import { useEffect, useMemo, useRef } from 'react';
import { ReactFlowState } from '../../types';
import { useStore } from '../../hooks/useStore';
const selector = (s: ReactFlowState) => s.updateNodeDimensions;
export default function useResizeObserver() {
const updateNodeDimensions = useStore(selector);
const resizeObserverRef = useRef<ResizeObserver>();
const resizeObserver = useMemo(() => {
if (typeof ResizeObserver === 'undefined') {
return null;
}
const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
const updates = new Map();
entries.forEach((entry: ResizeObserverEntry) => {
const id = entry.target.getAttribute('data-id') as string;
updates.set(id, {
id,
nodeElement: entry.target as HTMLDivElement,
forceUpdate: true,
});
});
updateNodeDimensions(updates);
});
resizeObserverRef.current = observer;
return observer;
}, []);
useEffect(() => {
return () => {
resizeObserverRef?.current?.disconnect();
};
}, []);
return resizeObserver;
}
+9 -20
View File
@@ -1,31 +1,20 @@
import { getNodesInside } from '@xyflow/system';
import { createSelector } from 'reselect';
import { shallow } from 'zustand/shallow';
import { useStore } from '../hooks/useStore';
import type { Node, ReactFlowState } from '../types';
import { useCallback } from 'react';
const visibleNodeIdsSelector = createSelector(
[
(s: ReactFlowState) => s.nodes,
(s: ReactFlowState) => s.width,
(s: ReactFlowState) => s.height,
(s: ReactFlowState) => s.transform,
(_s: ReactFlowState, onlyRenderVisibleNodes: boolean) => onlyRenderVisibleNodes,
],
(nodes, width, height, transform, onlyRenderVisible) =>
onlyRenderVisible
? getNodesInside<Node>(nodes, { x: 0, y: 0, width, height }, transform, true).map((node) => node.id)
: nodes.map((node) => node.id),
{
memoizeOptions: {
resultEqualityCheck: shallow,
},
}
);
const selector = (onlyRenderVisible: boolean) => (s: ReactFlowState) => {
return onlyRenderVisible
? getNodesInside<Node>(s.nodes, { x: 0, y: 0, width: s.width, height: s.height }, s.transform, true).map(
(node) => node.id
)
: Array.from(s.nodeLookup.keys());
};
function useVisibleNodeIds(onlyRenderVisible: boolean) {
const nodeIds = useStore((s: ReactFlowState) => visibleNodeIdsSelector(s, onlyRenderVisible));
const nodeIds = useStore(useCallback(selector(onlyRenderVisible), [onlyRenderVisible]), shallow);
return nodeIds;
}
+3 -4
View File
@@ -1,5 +1,5 @@
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import type { NodeBase, XYPosition } from '@xyflow/system';
import type { NodeBase } from '@xyflow/system';
export type Node<NodeData = any, NodeType extends string | undefined = string | undefined> = NodeBase<
NodeData,
@@ -20,11 +20,10 @@ export type WrapNodeProps<NodeData = any> = Pick<
> &
Required<Pick<Node<NodeData>, 'selected' | 'type' | 'zIndex'>> & {
isConnectable: boolean;
xPos: number;
yPos: number;
xPosOrigin: number;
yPosOrigin: number;
positionAbsolute: XYPosition;
positionAbsoluteX: number;
positionAbsoluteY: number;
initialized: boolean;
isSelectable: boolean;
isDraggable: boolean;
@@ -191,7 +191,8 @@
{dragging}
{dragHandle}
isConnectable={connectable}
positionAbsolute={{ x: positionX, y: positionY }}
positionAbsoluteX={positionX}
positionAbsoluteY={positionY}
{width}
{height}
/>
+2 -1
View File
@@ -56,7 +56,8 @@ export type NodeProps<T = any> = {
selected: NodeBase['selected'];
isConnectable: NodeBase['connectable'];
zIndex: NodeBase['zIndex'];
positionAbsolute: XYPosition;
positionAbsoluteX: number;
positionAbsoluteY: number;
width?: number;
height?: number;
dragging: boolean;
-9
View File
@@ -235,9 +235,6 @@ importers:
react-dom:
specifier: '>=17'
version: registry.npmjs.org/react-dom@18.2.0(react@18.2.0)
reselect:
specifier: ^4.1.8
version: registry.npmjs.org/reselect@4.1.8
zustand:
specifier: ^4.4.0
version: registry.npmjs.org/zustand@4.4.1(@types/react@18.2.24)(react@18.2.0)
@@ -10036,12 +10033,6 @@ packages:
version: 1.0.0
dev: true
registry.npmjs.org/reselect@4.1.8:
resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==, registry: https://registry.npmjs.com/, tarball: https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz}
name: reselect
version: 4.1.8
dev: false
registry.npmjs.org/resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==, registry: https://registry.npmjs.com/, tarball: https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz}
name: resolve-from