I'm seeing a selection range expand when Undo removes the last item from the history. This reproduces with the unmodified Rust crate loro = "=1.16.0". When an older local history item remains, the selection comes back as expected.
The text is restored correctly, including the remote insertions. The difference shows up in both the positions attached to the popped cursors and the positions returned by doc.get_cursor_pos() after undo() returns.
In the fresh-session case, I load an existing snapshot before creating UndoManager. There is no call to clear().
Environment
- Published Rust crate:
loro 1.16.0, without patches or a fork.
- macOS 26.6, arm64.
rustc 1.94.0 (4a4ef493e 2026-03-02).
- Native Rust only, with no editor framework, FFI binding, or GUI.
- The release profile is in the manifest below. I ran the tests with one test thread and re-ran this exact source just before the original submission.
Steps to reproduce
- Open a saved document containing
Hello world!.
- Select
ello: offsets [1,5), start cursor Side::Left, end cursor Side::Right.
- Delete those four characters, leaving
H world!. The fresh manager now has one Undo item.
- A remote peer receives this state and inserts
Hi at offset 0, then ii at offset 4. Import its update locally. The text is now Hi Hii world!, and there is still one local Undo item.
- Undo the deletion. The text correctly returns to
Hi Helloii world!.
- Compare the restored selection: with an older local history item, the offsets are
[4,8], selecting ello. In the fresh session, they are [4,10], selecting elloii.
The remote peer edits after receiving the deletion, so this is not a case of independent concurrent deletions. Each case calls Undo once. Redo is not tested.
Results
I used Restoring Selections and the older-history control to determine the expected offsets. The expectations do not assume another CRDT's merge ordering.
| Case |
Observed resolved cursor offsets |
Aligned selection [1,5], delete [1,5), remote edits, older history retained |
[4,8] |
| Same selection/deletion/remote edits, fresh manager after loading snapshot |
[4,10] (differs from control) |
| Same aligned selection/deletion, fresh manager, no remote edits |
[1,5] (passes) |
Upstream regression's tracked points [1,4], delete [1,5), older history retained |
[4,7] (passes) |
| Same tracked points/deletion/remote edits, fresh manager after loading snapshot |
[4,9] (expected [4,7]) |
The last two rows follow undo_transform_cursor_position. That test tracks points inside the deletion range, rather than exactly matching the four characters being deleted. I kept those points for comparison and added the aligned-selection cases in the first three rows.
Before checking the selection, every case checks that the text is preserved and that both a fresh snapshot and the other peer have matching document values and version vectors. Those checks pass. The four tests finish with 2 passed and 2 failed, exit 101.
The two initialization paths differ in how they load the initial text and whether its creation enters the Undo history. I haven't isolated the internal cause of the difference.
Standalone reproduction
Create these two files in a new directory, then run:
cargo test --release --lib -- --nocapture --test-threads=1
Cargo.toml:
[package]
name = "loro-undo-selection-report"
version = "0.1.0"
edition = "2021"
[workspace]
[lib]
path = "repro.rs"
[dependencies]
loro = "=1.16.0"
serde_json = "=1.0.149"
[profile.release]
opt-level = 1
debug = 0
repro.rs (complete executable tests)
use loro::{cursor::{Cursor, Side}, ExportMode, LoroDoc, UndoItemMeta, UndoManager};
use std::sync::{Arc, Mutex};
fn sync(from: &LoroDoc, to: &LoroDoc) {
to.import(&from.export(ExportMode::updates(&to.oplog_vv())).unwrap()).unwrap();
}
// Keep an older-history arm solely as a control for the SDK's own cursor affinity.
fn roundtrip(natural: bool, end: usize, with_remote: bool) -> Vec<usize> {
let doc = LoroDoc::new();
doc.set_peer_id(300).unwrap();
let mut undo = if natural {
let saved = LoroDoc::new();
saved.set_peer_id(299).unwrap();
saved.get_text("text").insert(0, "Hello world!").unwrap();
saved.commit();
doc.import(&saved.export(ExportMode::Snapshot).unwrap()).unwrap();
doc.set_peer_id(301).unwrap();
UndoManager::new(&doc)
} else {
let manager = UndoManager::new(&doc);
doc.get_text("text").insert(0, "Hello world!").unwrap();
doc.commit();
manager
};
assert_eq!(undo.undo_count(), if natural { 0 } else { 1 });
let text = doc.get_text("text");
let selection: Arc<Mutex<Vec<Cursor>>> = Arc::new(Mutex::new(vec![
text.get_cursor(1, Side::Left).unwrap(),
text.get_cursor(end, Side::Right).unwrap(),
]));
let capture = selection.clone();
undo.set_on_push(Some(Box::new(move |_, _, _| {
let mut meta = UndoItemMeta::new();
for cursor in capture.lock().unwrap().iter() { meta.add_cursor(cursor); }
meta
})));
let popped = Arc::new(Mutex::new(Vec::new()));
let store = popped.clone();
undo.set_on_pop(Some(Box::new(move |_, _, meta| {
*store.lock().unwrap() = meta.cursors;
})));
text.delete(1, 4).unwrap();
doc.commit();
assert_eq!(undo.undo_count(), if natural { 1 } else { 2 });
let peer = doc.fork();
peer.set_peer_id(302).unwrap();
if with_remote {
peer.get_text("text").insert(0, "Hi ").unwrap();
peer.get_text("text").insert(4, "ii").unwrap();
peer.commit();
sync(&peer, &doc);
}
assert_eq!(undo.undo_count(), if natural { 1 } else { 2 });
assert!(undo.undo().unwrap());
assert_eq!(undo.undo_count(), if natural { 0 } else { 1 });
let body = text.to_string();
assert_eq!(body, if with_remote { "Hi Helloii world!" } else { "Hello world!" });
let restored = LoroDoc::from_snapshot(&doc.export(ExportMode::Snapshot).unwrap()).unwrap();
assert_eq!(restored.get_deep_value(), doc.get_deep_value());
assert_eq!(restored.oplog_vv(), doc.oplog_vv());
sync(&doc, &peer);
assert_eq!(peer.get_deep_value(), doc.get_deep_value());
assert_eq!(peer.oplog_vv(), doc.oplog_vv());
let cursors = popped.lock().unwrap();
assert_eq!(cursors.len(), 2);
let raw: Vec<_> = cursors.iter().map(|c| c.pos.pos).collect();
let resolved: Vec<_> = cursors.iter().map(|c| doc.get_cursor_pos(&c.cursor).unwrap().current.pos).collect();
eprintln!("{}", serde_json::json!({
"natural_session": natural, "selection_before": [1, end], "delete_range": [1, 5],
"remote": with_remote, "raw": raw, "resolved": resolved, "body": body,
"undo_remaining": undo.undo_count(), "fresh_snapshot_and_peer_readback": "passed"
}));
resolved
}
#[test]
fn official_tracked_range_with_older_history() {
assert_eq!(roundtrip(false, 4, true), vec![4, 7]);
}
#[test]
fn official_tracked_range_in_natural_session() {
assert_eq!(roundtrip(true, 4, true), vec![4, 7]);
}
#[test]
fn aligned_selection_natural_session_matches_older_history() {
let control = roundtrip(false, 5, true);
assert_eq!(roundtrip(true, 5, true), control);
}
#[test]
fn aligned_selection_without_remote_edit() {
assert_eq!(roundtrip(true, 5, false), vec![1, 5]);
}
Related issue
I checked #784 and its fixes. That issue was about the final Redo at a line endpoint. This one occurs when undoing a deletion with a non-collapsed selection after remote edits, so I don't know whether they share a cause.
Am I capturing and restoring the cursors correctly here? I'd expect the fresh session to restore the same range as the older-history control, but please point me to any cursor-handling step I've missed. Thanks.
I'm seeing a selection range expand when Undo removes the last item from the history. This reproduces with the unmodified Rust crate
loro = "=1.16.0". When an older local history item remains, the selection comes back as expected.The text is restored correctly, including the remote insertions. The difference shows up in both the positions attached to the popped cursors and the positions returned by
doc.get_cursor_pos()afterundo()returns.In the fresh-session case, I load an existing snapshot before creating UndoManager. There is no call to
clear().Environment
loro 1.16.0, without patches or a fork.rustc 1.94.0 (4a4ef493e 2026-03-02).Steps to reproduce
Hello world!.ello: offsets[1,5), start cursorSide::Left, end cursorSide::Right.H world!. The fresh manager now has one Undo item.Hiat offset 0, theniiat offset 4. Import its update locally. The text is nowHi Hii world!, and there is still one local Undo item.Hi Helloii world!.[4,8], selectingello. In the fresh session, they are[4,10], selectingelloii.The remote peer edits after receiving the deletion, so this is not a case of independent concurrent deletions. Each case calls Undo once. Redo is not tested.
Results
I used Restoring Selections and the older-history control to determine the expected offsets. The expectations do not assume another CRDT's merge ordering.
[1,5], delete[1,5), remote edits, older history retained[4,8][4,10](differs from control)[1,5](passes)[1,4], delete[1,5), older history retained[4,7](passes)[4,9](expected[4,7])The last two rows follow
undo_transform_cursor_position. That test tracks points inside the deletion range, rather than exactly matching the four characters being deleted. I kept those points for comparison and added the aligned-selection cases in the first three rows.Before checking the selection, every case checks that the text is preserved and that both a fresh snapshot and the other peer have matching document values and version vectors. Those checks pass. The four tests finish with 2 passed and 2 failed, exit 101.
The two initialization paths differ in how they load the initial text and whether its creation enters the Undo history. I haven't isolated the internal cause of the difference.
Standalone reproduction
Create these two files in a new directory, then run:
cargo test --release --lib -- --nocapture --test-threads=1Cargo.toml:repro.rs (complete executable tests)
Related issue
I checked #784 and its fixes. That issue was about the final Redo at a line endpoint. This one occurs when undoing a deletion with a non-collapsed selection after remote edits, so I don't know whether they share a cause.
Am I capturing and restoring the cursors correctly here? I'd expect the fresh session to restore the same range as the older-history control, but please point me to any cursor-handling step I've missed. Thanks.