Redoing a transaction that moved a Part between Layers can insert the Part into the Layer twice

GoJS version: 3.1.9

Open the code below and click Run all steps. The steps it performs:

  1. A diagram with a Node template that has new go.Binding('layerName'), a Layer named L added after the default Layer, and one Node other already in Layer L. The UndoManager is enabled only after InitialLayoutCompleted, then cleared.
  2. diagram.commit(d => d.model.addNodeData({ key: 'dropped', layerName: 'L' }), 'drop'). The Node is created in the default Layer and moved to L by its Binding, so the transaction records: Insert nodeDataArray, Insert parts on the default Layer, Property data, Property layerName, Remove parts on the default Layer, Insert parts on Layer L at index 1.
  3. Remove other with diagram.skipsUndoManager = true (and diagram.model.skipsUndoManager = true). Layer L now holds one Part.
  4. 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>

Thank you for reporting. We’ll investigate and get back to you.

This looks like an issue in 4.0 also, and we may only fix it in 4.0. Are you OK upgrading versions if that’s the case?

@simon yes, it sounds good. We will update to the latest soon anyway. Thank you!

Thank you. We found the issue, and this fix will be out with the next 4.0 release, probably tomorrow.

4.0.5 is live now, and should fix this. Thanks again for reporting.