GoJS version: 3.1.9
Open the code below and click Run all steps. The steps it performs:
- A diagram with a Node template that has
new go.Binding('layerName'), a Layer namedLadded after the default Layer, and one Nodeotheralready in LayerL. The UndoManager is enabled only afterInitialLayoutCompleted, then cleared. diagram.commit(d => d.model.addNodeData({ key: 'dropped', layerName: 'L' }), 'drop'). The Node is created in the default Layer and moved toLby its Binding, so the transaction records:Insert nodeDataArray,Insert partson the default Layer,Property data,Property layerName,Remove partson the default Layer,Insert partson LayerLat index 1.- Remove
otherwithdiagram.skipsUndoManager = true(anddiagram.model.skipsUndoManager = true). LayerLnow holds one Part. commandHandler.undo(),commandHandler.redo(),commandHandler.undo().
Observed
initial model nodes: 1 layer L parts: [other]
after "drop" transaction model nodes: 2 layer L parts: [other, dropped]
after removing "other" (no history) model nodes: 1 layer L parts: [dropped]
after undo model nodes: 1 layer L parts: []
after redo model nodes: 2 layer L parts: [dropped, dropped] <-- same Part listed twice
after undo again model nodes: 1 layer L parts: [undefined (data null!)]
After the redo, iterating layer.parts yields the same Node object twice. After the second undo the model no longer contains the Node, diagram.nodes.count is 0, but a Node with data === null and key === undefined is still in Layer L, is drawn, and reacts to the mouse.
Expected
After the redo the Node is listed once in Layer L; after the second undo Layer L is empty and no Part with data === null remains.
Code example:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>GoJS: redo of a layer move inserts the part twice</title>
<script src="https://unpkg.com/[email protected]/release/go-debug.js"></script>
<style>
body { font: 14px/1.4 system-ui, sans-serif; margin: 16px; color: #222; }
#diagram { width: 480px; height: 220px; border: 1px solid #999; background: #fafafa; }
button { margin: 8px 8px 8px 0; padding: 6px 12px; }
pre { background: #f2f2f2; padding: 10px; white-space: pre-wrap; }
.bad { color: #b00020; font-weight: 600; }
</style>
</head>
<body>
<h2>Redo of a transaction that moved a part between layers inserts the part twice</h2>
<p>
GoJS 3.1.9. A node is created by <code>addNodeData</code> and moved from the default layer to layer <b>L</b>
by a <code>layerName</code> Binding inside the same transaction. GoJS records the <code>layerName</code>
property change plus the two layer membership events (Remove from default, Insert into L) at their
indices. If L holds fewer parts when the transaction is redone than it did when it was recorded
(here: another part was removed from L with <code>skipsUndoManager</code>, as a remote update or a
non-undoable operation would), the replayed Insert index is past the end of the layer. The replayed
<code>layerName</code> property has already moved the part into L, but the replayed Insert appends it
again, so <code>layer.parts</code> lists the same Part twice. The following undo removes only one of
the entries and leaves a Part with <code>data === null</code> in the layer.
</p>
<div id="diagram"></div>
<div>
<button id="run">Run all steps</button>
<button id="reset">Reset</button>
</div>
<pre id="log"></pre>
<script>
const $ = go.GraphObject.make;
const logEl = document.getElementById('log');
const log = (msg, bad) => { const line = document.createElement('div'); if (bad) line.className = 'bad'; line.textContent = msg; logEl.appendChild(line); };
let diagram;
function setup() {
if (diagram) diagram.div = null;
logEl.textContent = '';
diagram = new go.Diagram('diagram');
diagram.animationManager.isEnabled = false;
diagram.addLayerAfter(new go.Layer({ name: 'L' }), diagram.findLayer(''));
diagram.nodeTemplate = $(go.Node, 'Auto',
new go.Binding('layerName'),
new go.Binding('location', 'loc', go.Point.parse),
$(go.Shape, 'RoundedRectangle', { fill: 'white', width: 80, height: 40 }),
$(go.TextBlock, new go.Binding('text', 'key')));
diagram.model = new go.GraphLinksModel([{ key: 'other', layerName: 'L', loc: '20 20' }]);
// GoJS clears the undo history once a new diagram has finished initializing,
// so the recorded steps must start after that.
return new Promise((resolve) => {
diagram.addDiagramListener('InitialLayoutCompleted', () => {
diagram.undoManager.isEnabled = true;
diagram.undoManager.clear();
resolve();
});
});
}
function report(step) {
const L = diagram.findLayer('L');
const entries = []; L.parts.each((p) => entries.push(`${p.key}${p.data === null ? ' (data null!)' : ''}`));
const seen = new Set(); let duplicates = 0; L.parts.each((p) => { if (seen.has(p)) duplicates++; seen.add(p); });
const bad = duplicates > 0 || entries.some((e) => e.includes('data null'));
log(`${step.padEnd(34)} model nodes: ${diagram.model.nodeDataArray.length} layer L parts: [${entries.join(', ')}]${duplicates ? ` <-- same Part listed ${duplicates + 1} times` : ''}`, bad);
}
async function runAll() {
await setup();
report('initial');
// 1. the recorded transaction: node created in the default layer, moved into L by its binding
diagram.commit((d) => d.model.addNodeData({ key: 'dropped', layerName: 'L', loc: '20 100' }), 'drop');
report('after "drop" transaction');
// 2. L loses a part outside the undo history (remote change, non-undoable operation, ...)
diagram.skipsUndoManager = true;
diagram.model.skipsUndoManager = true;
diagram.remove(diagram.findNodeForKey('other'));
diagram.model.skipsUndoManager = false;
diagram.skipsUndoManager = false;
report('after removing "other" (no history)');
// 3. undo and redo the drop
diagram.commandHandler.undo();
report('after undo');
diagram.commandHandler.redo();
report('after redo');
diagram.commandHandler.undo();
report('after undo again');
log(`history: [${diagram.undoManager.history.toArray().map((t) => t.name).join(', ')}] index: ${diagram.undoManager.historyIndex}`);
log('');
log('Expected: after redo, "dropped" listed once; after the second undo, layer L empty and no Part with data === null.');
}
document.getElementById('run').addEventListener('click', runAll);
document.getElementById('reset').addEventListener('click', () => { setup().then(() => log('reset')); });
setup();
</script>
</body>
</html>