fix: per-tab undo/redo history, stale closure in node ops, zoom animation

1. Per-tab history: Changed reducer from single HistoryState to
   Record<string, HistoryState> keyed by tab ID. Undo/redo now
   only affects the active tab. Creating a new tab initializes
   its own history; closing a tab cleans up its history.

2. Stale closure fix: onDropComponent, onConnect, deleteSelectedNodes,
   and duplicateSelectedNodes now use setTransactionTabs directly to
   read both nodes and edges from the same state snapshot, preventing
   inconsistent history entries.

3. Zoom animation: handleZoomIn/handleZoomOut now use .then() on the
   zoomIn()/zoomOut() promises to read the zoom level after animation
   completes, preventing stale zoom display.

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
This commit is contained in:
Devin AI
2026-04-18 17:55:19 +00:00
parent 81506c63ec
commit 1a459c695b
2 changed files with 84 additions and 63 deletions

View File

@@ -66,18 +66,23 @@ export default function App() {
}));
}, [activeTransactionId]);
// Undo/redo — combined into single state to prevent index/entries divergence
// Undo/redo — per-tab history to prevent cross-tab state corruption
type HistoryState = { entries: HistoryEntry[]; index: number };
type PerTabHistoryState = Record<string, HistoryState>;
type HistoryAction =
| { type: 'push'; nodes: Node[]; edges: Edge[] }
| { type: 'undo' }
| { type: 'redo' }
| { type: 'reset' };
| { type: 'push'; tabId: string; nodes: Node[]; edges: Edge[] }
| { type: 'undo'; tabId: string }
| { type: 'redo'; tabId: string }
| { type: 'reset'; tabId: string }
| { type: 'remove'; tabId: string };
const historyReducer = useCallback((state: HistoryState, action: HistoryAction): HistoryState => {
const defaultHistory: HistoryState = { entries: [{ nodes: [], edges: [] }], index: 0 };
const historyReducer = useCallback((state: PerTabHistoryState, action: HistoryAction): PerTabHistoryState => {
const tabState = state[action.tabId] || defaultHistory;
switch (action.type) {
case 'push': {
const trimmed = state.entries.slice(0, state.index + 1);
const trimmed = tabState.entries.slice(0, tabState.index + 1);
const entry = { nodes: JSON.parse(JSON.stringify(action.nodes)), edges: JSON.parse(JSON.stringify(action.edges)) };
const next = [...trimmed, entry];
let newIndex = trimmed.length;
@@ -85,44 +90,51 @@ export default function App() {
next.shift();
newIndex = next.length - 1;
}
return { entries: next, index: newIndex };
return { ...state, [action.tabId]: { entries: next, index: newIndex } };
}
case 'undo': {
if (state.index <= 0) return state;
return { ...state, index: state.index - 1 };
if (tabState.index <= 0) return state;
return { ...state, [action.tabId]: { ...tabState, index: tabState.index - 1 } };
}
case 'redo': {
if (state.index >= state.entries.length - 1) return state;
return { ...state, index: state.index + 1 };
if (tabState.index >= tabState.entries.length - 1) return state;
return { ...state, [action.tabId]: { ...tabState, index: tabState.index + 1 } };
}
case 'reset': {
return { entries: [{ nodes: [], edges: [] }], index: 0 };
return { ...state, [action.tabId]: { entries: [{ nodes: [], edges: [] }], index: 0 } };
}
case 'remove': {
const { [action.tabId]: _, ...rest } = state;
return rest;
}
}
}, []);
const [historyState, dispatchHistory] = useReducer(historyReducer, { entries: [{ nodes: [], edges: [] }], index: 0 });
const history = historyState.entries;
const historyIndex = historyState.index;
const pushHistory = useCallback((n: Node[], e: Edge[]) => {
dispatchHistory({ type: 'push', nodes: n, edges: e });
const [historyState, dispatchHistory] = useReducer(historyReducer, { 'tx-1': { entries: [{ nodes: [], edges: [] }], index: 0 } });
const activeHistory = historyState[activeTransactionId] || defaultHistory;
const history = activeHistory.entries;
const historyIndex = activeHistory.index;
const pushHistory = useCallback((tabId: string, n: Node[], e: Edge[]) => {
dispatchHistory({ type: 'push', tabId, nodes: n, edges: e });
}, []);
const undo = useCallback(() => {
const entry = historyState.entries[historyState.index - 1];
if (!entry || historyState.index <= 0) return;
dispatchHistory({ type: 'undo' });
const tabHistory = historyState[activeTransactionId] || defaultHistory;
const entry = tabHistory.entries[tabHistory.index - 1];
if (!entry || tabHistory.index <= 0) return;
dispatchHistory({ type: 'undo', tabId: activeTransactionId });
setNodes(JSON.parse(JSON.stringify(entry.nodes)));
setEdges(JSON.parse(JSON.stringify(entry.edges)));
}, [historyState, setNodes, setEdges]);
}, [historyState, activeTransactionId, setNodes, setEdges]);
const redo = useCallback(() => {
const entry = historyState.entries[historyState.index + 1];
if (!entry || historyState.index >= historyState.entries.length - 1) return;
dispatchHistory({ type: 'redo' });
const tabHistory = historyState[activeTransactionId] || defaultHistory;
const entry = tabHistory.entries[tabHistory.index + 1];
if (!entry || tabHistory.index >= tabHistory.entries.length - 1) return;
dispatchHistory({ type: 'redo', tabId: activeTransactionId });
setNodes(JSON.parse(JSON.stringify(entry.nodes)));
setEdges(JSON.parse(JSON.stringify(entry.edges)));
}, [historyState, setNodes, setEdges]);
}, [historyState, activeTransactionId, setNodes, setEdges]);
// Selected nodes
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
@@ -224,25 +236,29 @@ export default function App() {
position,
data: { label: item.label, category: item.category, icon: item.icon, color: item.color, status: undefined },
};
setNodes(prev => {
const next = [...prev, newNode];
pushHistory(next, edges);
return next;
});
const tabId = activeTransactionId;
setTransactionTabs(prev => prev.map(t => {
if (t.id !== tabId) return t;
const nextNodes = [...t.nodes, newNode];
pushHistory(tabId, nextNodes, t.edges);
return { ...t, nodes: nextNodes };
}));
addRecentComponent(item.id);
addTerminalEntry('info', 'canvas', `Node added: ${item.label}`);
addAuditEntry('NODE_ADD', `Added ${item.label} node to canvas`);
}, [setNodes, edges, pushHistory, addRecentComponent, addTerminalEntry, addAuditEntry]);
}, [activeTransactionId, pushHistory, addRecentComponent, addTerminalEntry, addAuditEntry]);
const onConnect = useCallback((params: Connection) => {
setEdges(prev => {
const next = addEdge({ ...params, animated: true, style: { stroke: '#3b82f6', strokeWidth: 2 } }, prev);
pushHistory(nodes, next);
return next;
});
const tabId = activeTransactionId;
setTransactionTabs(prev => prev.map(t => {
if (t.id !== tabId) return t;
const nextEdges = addEdge({ ...params, animated: true, style: { stroke: '#3b82f6', strokeWidth: 2 } }, t.edges);
pushHistory(tabId, t.nodes, nextEdges);
return { ...t, edges: nextEdges };
}));
addTerminalEntry('info', 'canvas', `Edge connected: ${params.source}${params.target}`);
addAuditEntry('EDGE_CREATE', `Connection created`);
}, [setEdges, nodes, pushHistory, addTerminalEntry, addAuditEntry]);
}, [activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
const onNodesChange = useCallback((changes: NodeChange[]) => {
setNodes((prev: Node[]) => applyNodeChanges(changes, prev));
@@ -258,34 +274,38 @@ export default function App() {
const deleteSelectedNodes = useCallback(() => {
if (selectedNodeIds.size === 0) return;
setNodes(prev => {
const next = prev.filter(n => !selectedNodeIds.has(n.id));
pushHistory(next, edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target)));
return next;
});
setEdges(prev => prev.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target)));
const tabId = activeTransactionId;
setTransactionTabs(prev => prev.map(t => {
if (t.id !== tabId) return t;
const nextNodes = t.nodes.filter(n => !selectedNodeIds.has(n.id));
const nextEdges = t.edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target));
pushHistory(tabId, nextNodes, nextEdges);
return { ...t, nodes: nextNodes, edges: nextEdges };
}));
addTerminalEntry('info', 'canvas', `Deleted ${selectedNodeIds.size} node(s)`);
addAuditEntry('NODE_DELETE', `Removed ${selectedNodeIds.size} node(s)`);
setSelectedNodeIds(new Set());
}, [selectedNodeIds, setNodes, setEdges, edges, pushHistory, addTerminalEntry, addAuditEntry]);
}, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
const duplicateSelectedNodes = useCallback(() => {
if (selectedNodeIds.size === 0) return;
const selected = nodes.filter(n => selectedNodeIds.has(n.id));
const newNodes = selected.map(n => ({
...n,
id: `node_${nodeIdCounter.current++}`,
position: { x: n.position.x + 40, y: n.position.y + 40 },
selected: false,
const tabId = activeTransactionId;
setTransactionTabs(prev => prev.map(t => {
if (t.id !== tabId) return t;
const selected = t.nodes.filter(n => selectedNodeIds.has(n.id));
const newNodes = selected.map(n => ({
...n,
id: `node_${nodeIdCounter.current++}`,
position: { x: n.position.x + 40, y: n.position.y + 40 },
selected: false,
}));
const nextNodes = [...t.nodes, ...newNodes];
pushHistory(tabId, nextNodes, t.edges);
return { ...t, nodes: nextNodes };
}));
setNodes(prev => {
const next = [...prev, ...newNodes];
pushHistory(next, edges);
return next;
});
addTerminalEntry('info', 'canvas', `Duplicated ${selected.length} node(s)`);
addAuditEntry('NODE_DUPLICATE', `Duplicated ${selected.length} node(s)`);
}, [selectedNodeIds, nodes, setNodes, edges, pushHistory, addTerminalEntry, addAuditEntry]);
addTerminalEntry('info', 'canvas', `Duplicated node(s)`);
addAuditEntry('NODE_DUPLICATE', `Duplicated node(s)`);
}, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
// Validate
const runValidation = useCallback(() => {
@@ -378,13 +398,14 @@ export default function App() {
const id = `tx-${Date.now()}`;
setTransactionTabs(prev => [...prev, { id, name: `Transaction ${prev.length + 1}`, nodes: [], edges: [] }]);
setActiveTransactionId(id);
dispatchHistory({ type: 'reset' });
dispatchHistory({ type: 'reset', tabId: id });
addTerminalEntry('info', 'system', 'New transaction tab created');
}, [addTerminalEntry]);
const closeTransactionTab = useCallback((id: string) => {
if (transactionTabs.length <= 1) return;
setTransactionTabs(prev => prev.filter(t => t.id !== id));
dispatchHistory({ type: 'remove', tabId: id });
if (activeTransactionId === id) {
const remaining = transactionTabs.filter(t => t.id !== id);
setActiveTransactionId(remaining[0].id);

View File

@@ -58,7 +58,7 @@ interface CanvasProps {
onCloseTab: (id: string) => void;
splitView: boolean;
onToggleSplitView: () => void;
pushHistory: (nodes: Node[], edges: Edge[]) => void;
pushHistory: (tabId: string, nodes: Node[], edges: Edge[]) => void;
}
function CanvasInner({
@@ -105,8 +105,8 @@ function CanvasInner({
setZoomLevel(Math.round(zoom * 100));
}, [reactFlowInstance]);
const handleZoomIn = () => { reactFlowInstance.zoomIn(); setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)); };
const handleZoomOut = () => { reactFlowInstance.zoomOut(); setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)); };
const handleZoomIn = () => { reactFlowInstance.zoomIn().then(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100))); };
const handleZoomOut = () => { reactFlowInstance.zoomOut().then(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100))); };
const handleFitView = () => { reactFlowInstance.fitView(); setTimeout(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)), 100); };
const errorCount = nodes.filter(n => (n.data as Record<string, unknown>).status === 'error').length;