How to correctly undo data exchange between arrays?

This problem very similar to this one, but I’m using only Y.Array’s and primitive data.

I have an application which has containers that contain elements. These containers are implemented as Y.Array’s. Elements are primitive strings.
You can drag an element from a container to another. This is currently implemented as a splice() in the first Y.Array and an insert() in the second Y.Array.
This works well until I introduce undo-redo functionality. That’s when duplications start occurring.

This is the output of some test I implemented:

Before user 1 moves data from A to B: (A: ['data-being-moved'] , B: [] )
After user 1 moves data from A to B: (A: [] , B: ['data-being-moved'] )
After user 2 moves data from B to A: (A: ['data-being-moved'] , B: [] )
After user 2 undoes B-to-A move: (A: [] , B: ['data-being-moved'] )
After user 1 undoes A-to-B move: (A: ['data-being-moved'] , B: ['data-being-moved'] )

How would this functionality be implemented in Yjs?

Thanks.

Full test implementation:

const yDoc1 = new Y.Doc();
const yDoc2 = new Y.Doc();

yDoc1.on('update', (update) => Y.applyUpdate(yDoc2, update));
yDoc2.on('update', (update) => Y.applyUpdate(yDoc1, update));

const yArrayA1 = yDoc1.getArray<string>('arrayA');
const yArrayA2 = yDoc2.getArray<string>('arrayA');

const yArrayB1 = yDoc1.getArray<string>('arrayB');
const yArrayB2 = yDoc2.getArray<string>('arrayB');

function logArrays(message: string) {
  console.log(
    `${message}:`,
    '(A:',
    yArrayA1.toArray(),
    ', B:',
    yArrayB1.toArray(),
    ')'
  );
}

// Initialize Y.Doc data

Y.transact(
  yDoc1,
  () => {
    yArrayA1.push(['data-being-moved']);
  },
  yDoc1.clientID
);

const undoManager1 = new Y.UndoManager([yArrayA1, yArrayB1], {
  trackedOrigins: new Set([yDoc1.clientID]),
});
const undoManager2 = new Y.UndoManager([yArrayA2, yArrayB2], {
  trackedOrigins: new Set([yDoc2.clientID]),
});

logArrays('Before user 1 moves data from A to B');

// User 1 moves data from yArrayA to yArrayB

Y.transact(
  yDoc1,
  () => {
    yArrayA1.delete(0);
    yArrayB1.push(['data-being-moved']);
  },
  yDoc1.clientID
);

logArrays('After user 1 moves data from A to B');

// User 2 moves data from yArrayB to yArrayA

Y.transact(
  yDoc2,
  () => {
    yArrayB2.delete(0);
    yArrayA2.push(['data-being-moved']);
  },
  yDoc2.clientID
);

logArrays('After user 2 moves data from B to A');

// User 2 undoes move from B to A

undoManager2.undo();

logArrays('After user 2 undoes B-to-A move');

// User 1 undoes move from A to B

undoManager1.undo();

logArrays('After user 1 undoes A-to-B move');

These are still separate actions (there is no move supported yet between two data types).

The last undo “user 1 undoes A-to-B move” is able to revert the deletion of the string (inserting the string into A), but it cannot delete the insertion of the old string in B (as it was inserted by another user). User A has no knowledge that these are identical strings that another user simply moved.

Thanks for the response, @dmonad . Do you think it’s possible to enforce some kind of selective no-duplication constraint in a document?

I’ve been experimenting with observing my container-arrays for element insertions to track all occurrences of a certain element. If a new element already existed in one of the arrays then I delete the previous occurrence(s) of the element and keep only the last one.

It kind of worked, but I’m afraid I might be introducing some obscure collaborative bug.

That particular approach is prone to issues because “before” is relative to each user. Two concurrent insertions of the same element will result in both elements being deleted.

However, yes, you can clean up the document from duplicates. But you need to decide on a measurement for priority that doesn’t depend on time or before-relations (again, these things are relative). For example, you could always retain the first occurrence (as in position from left to right) of an element and delete the rest.