UndoManager with external updates

Hi! I got an issue using Y.UndoManager. Can anyone tell me if I am missing anything? Look at the last line and its comment.

const doc = new Y.Doc();
const doc2 = new Y.Doc();
doc.on('update', update => Y.applyUpdate(doc2, update));
doc2.on('update', update => Y.applyUpdate(doc, update));

const yArray = doc.getArray<Y.Map<string>>('array');
const yArray2 = doc2.getArray<Y.Map<string>>('array');
const yMap = new Y.Map<string>();
yMap.set('hello', 'world');
yArray.push([yMap]);

const undoManager = new Y.UndoManager([yArray], {trackedOrigins: new Set([doc.clientID])});
const undoManager2 = new Y.UndoManager([doc2.get('array')], {trackedOrigins: new Set([doc2.clientID])});

Y.transact(doc, () => yMap.set('hello', 'world modified'), doc.clientID);
Y.transact(doc2, () => yArray2.delete(0), doc2.clientID);
undoManager2.undo();
undoManager.undo();
console.log(yArray.get(0).get('hello')); // the currently value is "world modified" but I'd expect to be "world"

If it is not possible to do what I’m trying to do, how can I remove the invalid items from the undoManager stack?

Hi @csbenjamin,

Restoring deleted content looks like a fresh insertion for remote clients.

// The state of ydoc and ydoc2 is immediately synchronized
// because of how you implemented the update-event listeners.
Y.transact(doc, () => yMap.set('hello', 'world modified'), doc.clientID);
// shared content is: [{ hello: "world modified" }]
Y.transact(doc2, () => yArray2.delete(0), doc2.clientID);
// shared content is: []
undoManager2.undo();
// shared content is: [{ hello: "world modified" }] - the insertion `{hello: "world modified"}` is now owned by client 2. There is no way for user1 to know that user2 restored old content.
undoManager.undo();
// Nothing to do here because the insertion `hello: "world modified"` is fresh and has no editing history
console.log(yArray.get(0).get('hello')); // the currently value is "world modified" but I'd expect to be "world"

However, if you had two undo-managers pointed to the same ydoc you would retain the information to restore the previous content after the parent has been restored by a different undo manager. So if you did: doc2 = doc at the beginning, it would work.

If it is not possible to do what I’m trying to do, how can I remove the invalid items from the undoManager stack?

in this case, what would be the “invalid” item?

Hi @dmonad

Thanks for the reply. That makes sense.

In this is case, what would be the “invalid” item?

Client 1 edit or insert some content, so that modification/insertion goes to the undoStack. If a client 2 delete that content, it becomes invalid in the undoStack, because it cannot be restored. So, I want to remove that item from undoStack.

That makes sense?

By default it is already being ignored. It is not removed from the undo stack, but the undo manager notices that it wasn’t able to undo the previous change and tries to undo the next item on the stack (if there is any).

Is it necessary for your application to remove the item from the stack?

the undo manager notices that it wasn’t able to undo the previous change and tries to undo the next item on the stack

it is not the case. Have a look at the following example:

const doc = new Y.Doc();
const doc2 = new Y.Doc();
doc.on('update', update => Y.applyUpdate(doc2, update));
doc2.on('update', update => Y.applyUpdate(doc, update));

const yArray = doc.getArray<Y.Map<string>>('array');
const yArray2 = doc2.getArray<Y.Map<string>>('array');
const yMap = new Y.Map<string>();
yMap.set('hello', 'world');
yArray.push([yMap]);
const yMap2 = new Y.Map<string>();
yMap2.set('key', 'value');
yArray.push([yMap2]);

const undoManager = new Y.UndoManager([yArray], {trackedOrigins: new Set([doc.clientID])});
const undoManager2 = new Y.UndoManager([doc2.get('array')], {trackedOrigins: new Set([doc2.clientID])});

Y.transact(doc, () => yMap2.set('key', 'value modified'), doc.clientID);
undoManager.stopCapturing();
Y.transact(doc, () => yMap.set('hello', 'world modified'), doc.clientID);
Y.transact(doc2, () => yArray2.delete(0), doc2.clientID);
undoManager2.undo();
undoManager.undo();
console.log(yMap2.get('key')); // this should be "value", but the currently value is "value modified"
undoManager.undo();
console.log(yMap2.get('key')); // now the it is "value"

As you can see, it is not going to the next item automatically. I need to call .undo() one more time. Beside that, I need to inform to the user that the undoStack is empty or not. If the “invalid” items is the only items on the stack, I should show to the user that there is not any item in the stack showing an disabled undo button.

It is correct that the undo-stack is not automatically cleaned up in case there are operations on the stack that can no longer be undone (e.g. your first example). If you want, you can open a separate issue and we can discuss a solution for that. I think we could implement something like canUndo() that performs the check.

Your previous examples is pretty complicated.

Y.transact(doc, () => yMap2.set('key', 'value modified'), doc.clientID);
undoManager.stopCapturing();
Y.transact(doc, () => yMap.set('hello', 'world modified'), doc.clientID);

Is both modified while in scope of undoManager. These are two separate operations, so it makes sense that after calling undoManager.undo() twice, you end up with "value".

What I wanted to show with my example was that in the line

console.log(yMap2.get('key')); // this should be "value", but the currently value is "value modified"

should log “value”, because when I call undo above, the first element in the undoStack cannot be restored, so as I understood, the undoManage should ignore that item and go to the next item in the stack automatically, but it doesn’t. I have to call undo again in the line bellow to have what I wanted.

I will think about it. For now, I am storing the items that has being modified in the meta property of the StackItem and when that object becomes “invalid” in my application, I search if some StackItem points to it and if so, I remove from the stack. It is working for me.

Thanks for your attention and thanks again for your amazing work with yjs.

In the following example, both transactions are executed on doc with origin = doc.clientID.

// 1. state is { key: 'value' }
Y.transact(doc, () => yMap2.set('key', 'value modified'), doc.clientID);
// 2. state is { key: 'value modified' }
undoManager.stopCapturing();
Y.transact(doc, () => yMap.set('hello', 'world modified'), doc.clientID);
// 3. state is { key: 'world modified' }

undoManager.undo()
// back to state 2. state is { key: 'value modified' }
undoManager.undo()
// back to state 1. state is { key: 'value' }

The code is pretty complicated because it wraps an object from doc2 in an transaction of doc. This will still work because the updates are applied synchronously. But I’m having some trouble running the code in my head. It might be easier for me to see what you mean if you simplify the example. So far, everything makes sense to me.

Great :+1:

Actually yMap2 is an object from doc, not from doc2. Was that the confusing part?

Gotit. I fixed the issue in https://github.com/yjs/yjs/commit/9e98fec5042fd348304fd70b12172cc84409ec37

I will publish a new Yjs release with the fix later today (wait for Yjs@v13.5.5).

2 Likes