fix: ensure only newly created items are selected

Fixed selection state inconsistency when adding nodes or edges. Previously,
both the new item and previously selected items would remain selected due to
a race condition between Zustand store updates and ReactFlow state syncing.

Changes:
- Added pendingSelectionRef to track items that should be selected after
  Zustand sync completes
- Modified useEffect sync logic to preserve selection state normally, but
  apply pending selection when new items are added
- Unified node creation logic between context menu and left panel to ensure
  consistent behavior
- When adding nodes/edges, all other items are now properly deselected

The fix ensures selection state lives only in ReactFlow (not Zustand) and
is properly coordinated during store updates.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-10 22:12:12 +02:00
parent 8d8ff2d200
commit 5aeb187efe
3 changed files with 113 additions and 49 deletions

View file

@ -42,6 +42,7 @@ function AppContent() {
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false); const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
const [selectedNode, setSelectedNode] = useState<Actor | null>(null); const [selectedNode, setSelectedNode] = useState<Actor | null>(null);
const [selectedEdge, setSelectedEdge] = useState<Relation | null>(null); const [selectedEdge, setSelectedEdge] = useState<Relation | null>(null);
const [addNodeCallback, setAddNodeCallback] = useState<((nodeTypeId: string, position?: { x: number; y: number }) => void) | null>(null);
const { fitView } = useReactFlow(); const { fitView } = useReactFlow();
@ -145,6 +146,7 @@ function AppContent() {
setSelectedNode(null); setSelectedNode(null);
setSelectedEdge(null); setSelectedEdge(null);
}} }}
onAddNode={addNodeCallback || undefined}
/> />
)} )}
@ -167,6 +169,7 @@ function AppContent() {
setSelectedNode(null); setSelectedNode(null);
} }
}} }}
onAddNodeRequest={(callback: any) => setAddNodeCallback(() => callback)}
/> />
</div> </div>

View file

@ -42,6 +42,7 @@ interface GraphEditorProps {
selectedEdge: Relation | null; selectedEdge: Relation | null;
onNodeSelect: (node: Actor | null) => void; onNodeSelect: (node: Actor | null) => void;
onEdgeSelect: (edge: Relation | null) => void; onEdgeSelect: (edge: Relation | null) => void;
onAddNodeRequest?: (nodeTypeId: string, position?: { x: number; y: number }) => void;
} }
/** /**
@ -57,7 +58,7 @@ interface GraphEditorProps {
* *
* Usage: Core component that wraps React Flow with custom nodes and edges * Usage: Core component that wraps React Flow with custom nodes and edges
*/ */
const GraphEditor = ({ onNodeSelect, onEdgeSelect }: GraphEditorProps) => { const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest }: GraphEditorProps) => {
// Sync with workspace active document // Sync with workspace active document
const { activeDocumentId } = useActiveDocument(); const { activeDocumentId } = useActiveDocument();
const { saveViewport, getViewport, createDocument } = useWorkspaceStore(); const { saveViewport, getViewport, createDocument } = useWorkspaceStore();
@ -110,6 +111,9 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect }: GraphEditorProps) => {
// Track if a drag is in progress to capture state before drag // Track if a drag is in progress to capture state before drag
const dragInProgressRef = useRef(false); const dragInProgressRef = useRef(false);
// Track pending selection (ID of item to select after next sync)
const pendingSelectionRef = useRef<{ type: 'node' | 'edge', id: string } | null>(null);
// Context menu state // Context menu state
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
x: number; x: number;
@ -119,13 +123,61 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect }: GraphEditorProps) => {
} | null>(null); } | null>(null);
// Sync store changes to React Flow state // Sync store changes to React Flow state
// IMPORTANT: Preserve selection state, unless we have a pending selection (new item added)
useEffect(() => { useEffect(() => {
setNodesState(storeNodes as Node[]); const hasPendingSelection = pendingSelectionRef.current !== null;
}, [storeNodes, setNodesState]); const pendingType = pendingSelectionRef.current?.type;
const pendingId = pendingSelectionRef.current?.id;
useEffect(() => { setNodesState((currentNodes) => {
setEdgesState(storeEdges as Edge[]); // If we have a pending selection, deselect all nodes (or select the new node)
}, [storeEdges, setEdgesState]); if (hasPendingSelection) {
const pendingNodeId = pendingType === 'node' ? pendingId : null;
return (storeNodes as Node[]).map((node) => ({
...node,
selected: node.id === pendingNodeId,
}));
}
// Otherwise, preserve existing selection state
const selectionMap = new Map(
currentNodes.map((node) => [node.id, node.selected])
);
return (storeNodes as Node[]).map((node) => ({
...node,
selected: selectionMap.get(node.id) || false,
}));
});
setEdgesState((currentEdges) => {
// If we have a pending selection, deselect all edges (or select the new edge)
if (hasPendingSelection) {
const pendingEdgeId = pendingType === 'edge' ? pendingId : null;
const newEdges = (storeEdges as Edge[]).map((edge) => ({
...edge,
selected: edge.id === pendingEdgeId,
}));
// Clear pending selection after applying it to both nodes and edges
pendingSelectionRef.current = null;
return newEdges;
}
// Otherwise, preserve existing selection state
const selectionMap = new Map(
currentEdges.map((edge) => [edge.id, edge.selected])
);
return (storeEdges as Edge[]).map((edge) => ({
...edge,
selected: selectionMap.get(edge.id) || false,
}));
});
}, [storeNodes, storeEdges, setNodesState, setEdgesState]);
// Save viewport when switching documents and restore viewport for new document // Save viewport when switching documents and restore viewport for new document
useEffect(() => { useEffect(() => {
@ -315,20 +367,18 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect }: GraphEditorProps) => {
type: edgeType, type: edgeType,
// Don't set label - will use type's label as default // Don't set label - will use type's label as default
}, },
selected: true, // Auto-select the new edge in ReactFlow
}; };
// Use React Flow's addEdge helper to properly format the edge // Use React Flow's addEdge helper to properly format the edge
const updatedEdges = addEdge(edgeWithData, storeEdges as Edge[]); const updatedEdges = addEdge(edgeWithData, storeEdges as Edge[]);
// Deselect all nodes
const updatedNodes = nodes.map((node) => ({ ...node, selected: false }));
setNodesState(updatedNodes as Node[]);
// Find the newly added edge (it will be the last one) // Find the newly added edge (it will be the last one)
const newEdge = updatedEdges[updatedEdges.length - 1] as Relation; const newEdge = updatedEdges[updatedEdges.length - 1] as Relation;
// Use the history-tracked addEdge function // Set pending selection - will be applied after Zustand sync
pendingSelectionRef.current = { type: 'edge', id: newEdge.id };
// Use the history-tracked addEdge function (triggers sync which will apply selection)
addEdgeWithHistory(newEdge); addEdgeWithHistory(newEdge);
}, },
[ [
@ -336,8 +386,6 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect }: GraphEditorProps) => {
edgeTypeConfigs, edgeTypeConfigs,
addEdgeWithHistory, addEdgeWithHistory,
selectedRelationType, selectedRelationType,
nodes,
setNodesState,
], ],
); );
@ -432,6 +480,34 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect }: GraphEditorProps) => {
} }
}, [contextMenu]); }, [contextMenu]);
// Shared node creation logic (used by context menu and left panel)
const handleAddNode = useCallback(
(nodeTypeId: string, position?: { x: number; y: number }) => {
// Use provided position or random position for toolbar/panel
const nodePosition = position || {
x: Math.random() * 400 + 100,
y: Math.random() * 300 + 100,
};
const nodeTypeConfig = nodeTypeConfigs.find((nt) => nt.id === nodeTypeId);
const newNode = createNode(nodeTypeId, nodePosition, nodeTypeConfig);
// Set pending selection - will be applied after Zustand sync
pendingSelectionRef.current = { type: 'node', id: newNode.id };
// Use history-tracked addNode (triggers sync which will apply selection)
addNodeWithHistory(newNode);
},
[nodeTypeConfigs, addNodeWithHistory],
);
// Call the onAddNodeRequest callback if provided
useEffect(() => {
if (onAddNodeRequest) {
onAddNodeRequest(handleAddNode as any);
}
}, [onAddNodeRequest, handleAddNode]);
// Add new actor at context menu position // Add new actor at context menu position
const handleAddActorFromContextMenu = useCallback( const handleAddActorFromContextMenu = useCallback(
(nodeTypeId: string) => { (nodeTypeId: string) => {
@ -442,30 +518,10 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect }: GraphEditorProps) => {
y: contextMenu.y, y: contextMenu.y,
}); });
const nodeTypeConfig = nodeTypeConfigs.find((nt) => nt.id === nodeTypeId); handleAddNode(nodeTypeId, position);
const newNode = createNode(nodeTypeId, position, nodeTypeConfig);
newNode.selected = true; // Auto-select the new node in ReactFlow
// Deselect all existing nodes and edges BEFORE adding the new one
const updatedNodes = nodes.map((node) => ({ ...node, selected: false }));
const updatedEdges = edges.map((edge) => ({ ...edge, selected: false }));
setNodesState(updatedNodes as Node[]);
setEdgesState(updatedEdges as Edge[]);
// Use history-tracked addNode instead of setNodes
addNodeWithHistory(newNode);
setContextMenu(null); setContextMenu(null);
}, },
[ [contextMenu, screenToFlowPosition, handleAddNode],
contextMenu,
screenToFlowPosition,
nodeTypeConfigs,
addNodeWithHistory,
nodes,
edges,
setNodesState,
setEdgesState,
],
); );
// Show empty state when no document is active // Show empty state when no document is active

View file

@ -26,9 +26,10 @@ import { createNode } from '../../utils/nodeUtils';
interface LeftPanelProps { interface LeftPanelProps {
onDeselectAll: () => void; onDeselectAll: () => void;
onAddNode?: (nodeTypeId: string, position?: { x: number; y: number }) => void;
} }
const LeftPanel = ({ onDeselectAll }: LeftPanelProps) => { const LeftPanel = ({ onDeselectAll, onAddNode }: LeftPanelProps) => {
const { const {
leftPanelCollapsed, leftPanelCollapsed,
leftPanelWidth, leftPanelWidth,
@ -44,22 +45,26 @@ const LeftPanel = ({ onDeselectAll }: LeftPanelProps) => {
const handleAddNode = useCallback( const handleAddNode = useCallback(
(nodeTypeId: string) => { (nodeTypeId: string) => {
// Deselect all other nodes/edges first // Use the shared callback from GraphEditor if available
onDeselectAll(); if (onAddNode) {
onAddNode(nodeTypeId);
} else {
// Fallback to old behavior (for backwards compatibility)
onDeselectAll();
// Create node at center of viewport (approximate) const position = {
const position = { x: Math.random() * 400 + 100,
x: Math.random() * 400 + 100, y: Math.random() * 300 + 100,
y: Math.random() * 300 + 100, };
};
const nodeTypeConfig = nodeTypes.find((nt) => nt.id === nodeTypeId); const nodeTypeConfig = nodeTypes.find((nt) => nt.id === nodeTypeId);
const newNode = createNode(nodeTypeId, position, nodeTypeConfig); const newNode = createNode(nodeTypeId, position, nodeTypeConfig);
newNode.selected = true; // Auto-select in ReactFlow newNode.selected = true;
addNode(newNode); addNode(newNode);
}
}, },
[addNode, nodeTypes, onDeselectAll] [onAddNode, onDeselectAll, addNode, nodeTypes]
); );
const selectedEdgeTypeConfig = edgeTypes.find(et => et.id === selectedRelationType); const selectedEdgeTypeConfig = edgeTypes.find(et => et.id === selectedRelationType);