How to keep undo/redo history after doc disappears?

I don’t know how to describe the problem precisely, let me directly show an example:

Suppose there is a Doc, which is a folder, then

  • step 1, create a subDoc (a file)

  • step 2, write some characters in the file

  • step 3, write some characters in the file

Expected behavior, Since there are three steps, then by undo 3 times, the folder becomes empty again. By redo 3 times, we go back to current status.

Now an undoManager can only observe one doc, so for the case, at least two undoManagers should exist.

However, the problem is, a doc’s lifetime MUST be longer than its undoManager’s lifetime. That’s because the undoManager internally holds the doc.

So here is the problem, when the undo is applied the 3rd time, the file disappears, which means the undoManager of the file is useless. When redo is applied for the first time, a new file is created again, we cannot link the original undoManager to the new subDoc.

A possible workaround would be implementing a customized version of UndoManager, whose stack item is a map of subDoc uuid to DeleteSet. Therefore, the UndoManager can track all the subDocs and maintain their history even when they are destroyed.

Do you have any better suggestions?

Sample NodeJS script:
(it does not work now, but I want to achieve something like that. At least another UndoManager of the subDoc should be created, but the lifecycle problem also needs to be solved)

const Y = require("yjs");
async function delay(waitMs) {
  await new Promise((resolve) => {
    setTimeout(resolve, waitMs);
  });
}

const doc = new Y.Doc();
const root = doc.getMap();
const manager = new Y.UndoManager(root, {
  captureTimeout: 50,
});
console.log(JSON.stringify(root.toJSON()));
await delay(100);

// ---------- User actions in 3 steps ----------

const subDoc = new Y.Doc();
root.set("subDoc", subDoc);
console.log(subDoc.guid);
console.log(JSON.stringify(root.toJSON()));
await delay(100);
const text = subDoc.getText("text");
text.insert(0, "abc");
console.log(JSON.stringify(root.toJSON()));
await delay(100);
text.insert(3, "def");
console.log(JSON.stringify(root.toJSON()));
await delay(100);

// ---------- Undo 3 times ----------

manager.undo();
console.log(JSON.stringify(root.toJSON()));

manager.undo();
console.log(JSON.stringify(root.toJSON()));

manager.undo();
console.log(JSON.stringify(root.toJSON()));

// ---------- Redo 3 times ----------

manager.redo();
console.log(root.get('subDoc').guid);
console.log(JSON.stringify(root.toJSON()));

manager.redo();
console.log(JSON.stringify(root.toJSON()));

manager.redo();
console.log(JSON.stringify(root.toJSON()));

Hmmm, that’s a really good question.

I played around with your example, and could not get the new subdoc to work. I’m not even sure if UndoManager supports subdocs properly. I tried setting { gc: false }, calling subDoc.load() manually, creating a new subdoc with the subdoc guid, and calling the top-level getters again after the redo, but nothing worked.

The one notable thing is that setting the root’s subDoc back to the original subDoc reference after the redo does restore it:

root.set('subDoc', subDoc)
console.log(JSON.stringify(root.toJSON()))

If you keep the original subDoc in memory, such as in a Map like you suggested, then maybe you can handle this automatically by subscribing to doc.on('subdocs', ...).

Still, this certainly seems like a major shortcoming, if not outright bug, in the UndoManager when it comes to subdocs.

The example does not work, I’am sorry that I forget to mention it. Actually, I need to create an undoManager for the subDoc, but there exists the lifecycle problem, so I do not try more.

Conclusion:

Use subdoc wisely. If docs are expected to have independent histories, then use subdoc. Otherwise, maybe a nested Y.map should be used.

1 Like