Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions crates/loro-internal/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,13 @@ impl Handler {
// So when we redo the delete operation, we should check if the target is still alive.
// If it's alive, we should move it back instead of creating new one.
x.move_at_with_target_for_apply_diff(parent, position, target)?;
} else if !x.is_node_unexist(&target) {
// Node exists but is deleted — resurrect it with the same
// TreeID so that all references to the original node remain
// valid (e.g. after undoing a tree.delete()).
x.create_at_with_target_for_apply_diff(
parent, position, target,
)?;
} else {
let new_target = x.__internal__next_tree_id();
if x.create_at_with_target_for_apply_diff(
Expand All @@ -1246,8 +1253,8 @@ impl Handler {
}
remap_tree_id(&mut target, container_remap);
// determine if the target is deleted
if x.is_node_unexist(&target) || x.is_node_deleted(&target).unwrap() {
// create the target node, we should use the new target id
if x.is_node_unexist(&target) {
// Node truly doesn't exist — create with a new target id
let new_target = x.__internal__next_tree_id();
if x.create_at_with_target_for_apply_diff(
parent, position, new_target,
Expand All @@ -1257,6 +1264,11 @@ impl Handler {
new_target.associated_meta_container(),
);
}
} else if x.is_node_deleted(&target).unwrap() {
// Node exists but is deleted — resurrect with same TreeID
x.create_at_with_target_for_apply_diff(
parent, position, target,
)?;
} else {
x.move_at_with_target_for_apply_diff(parent, position, target)?;
}
Expand Down
85 changes: 85 additions & 0 deletions crates/loro-wasm/tests/undo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,91 @@ describe("undo", () => {
expect(docJson).toStrictEqual(docJson1);
});

test("undo tree delete restores node to original parent", () => {
const doc = new LoroDoc();
doc.setPeerId(1);
const undo = new UndoManager(doc, {
mergeInterval: 0,
maxUndoSteps: 100,
});

const tree = doc.getTree("1");
tree.enableFractionalIndex(3);

// Create parent and child
const parent = tree.createNode(undefined, 0);
parent.data.set("title", "parent");
const child = tree.createNode(parent.id, 0);
child.data.set("title", "child");
doc.commit();

const childId = child.id;
const snapshotBefore = doc.toJSON();

// Delete the child
tree.delete(childId);
doc.commit();

// Undo the delete
undo.undo();

// The node should be restored with same identity and correct parent
const restored = tree.getNodeByID(childId);
expect(restored).toBeDefined();
expect(restored!.parent()!.id).toStrictEqual(parent.id); // Should have original parent
expect(restored!.data.get("title")).toBe("child"); // Data should be intact

// Full doc state should match pre-delete state
expect(doc.toJSON()).toStrictEqual(snapshotBefore);
});

test("undo tree delete restores subtree parent relationships", () => {
const doc = new LoroDoc();
doc.setPeerId(1);
const undo = new UndoManager(doc, {
mergeInterval: 0,
maxUndoSteps: 100,
});

const tree = doc.getTree("1");
tree.enableFractionalIndex(3);

// Create a subtree: root -> parent -> child
const root = tree.createNode(undefined, 0);
root.data.set("title", "root");
const parent = tree.createNode(root.id, 0);
parent.data.set("title", "parent");
const child = tree.createNode(parent.id, 0);
child.data.set("title", "child");
doc.commit();

const parentId = parent.id;
const childId = child.id;
const snapshotBefore = doc.toJSON();

// Delete the parent (which cascades to child)
tree.delete(parentId);
doc.commit();

// Undo the delete
undo.undo();

// Parent should be restored under root
const restoredParent = tree.getNodeByID(parentId);
expect(restoredParent).toBeDefined();
expect(restoredParent!.parent()!.id).toStrictEqual(root.id);
expect(restoredParent!.data.get("title")).toBe("parent");

// Child should be restored under parent
const restoredChild = tree.getNodeByID(childId);
expect(restoredChild).toBeDefined();
expect(restoredChild!.parent()!.id).toStrictEqual(parentId);
expect(restoredChild!.data.get("title")).toBe("child");

// Full doc state should match pre-delete state
expect(doc.toJSON()).toStrictEqual(snapshotBefore);
});

test("avoid rust recursive use error", () => {
const doc = new LoroDoc();
const undoManager = new UndoManager(doc, {});
Expand Down