Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/undo-pause-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"loro-crdt": minor
"loro-js": minor
---

Add `pause()`, `resume()`, and `isPaused()` to `UndoManager`. While paused,
local edits are not recorded as undo steps and checkout events do not clear the
stacks. Import events (remote changes) are still processed so that the stacks
remain correctly transformed against concurrent edits. Use this to preserve
undo/redo history across temporary checkouts such as read-only history previews.
70 changes: 63 additions & 7 deletions crates/loro-internal/src/undo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ struct UndoManagerInner {
undo_stack: Stack,
redo_stack: Stack,
processing_undo: bool,
paused: bool,
last_undo_time: i64,
merge_interval_in_ms: i64,
max_stack_size: usize,
Expand Down Expand Up @@ -244,6 +245,16 @@ impl UndoGroup {
}
}

struct ProcessingUndoGuard {
inner: Arc<parking_lot::ReentrantMutex<RefCell<UndoManagerInner>>>,
}

impl Drop for ProcessingUndoGuard {
fn drop(&mut self) {
self.inner.lock().borrow_mut().processing_undo = false;
}
}

#[derive(Debug)]
struct Stack {
stack: VecDeque<(VecDeque<StackItem>, Arc<Mutex<DiffBatch>>)>,
Expand Down Expand Up @@ -502,6 +513,7 @@ impl UndoManagerInner {
undo_stack: Default::default(),
redo_stack: Default::default(),
processing_undo: false,
paused: false,
merge_interval_in_ms: 0,
last_undo_time: 0,
max_stack_size: usize::MAX,
Expand Down Expand Up @@ -616,15 +628,18 @@ impl UndoManager {
.iter()
.find(|x| x.peer == peer_clone.load(std::sync::atomic::Ordering::Relaxed))
{
let is_paused = lock.borrow().paused;
let should_exclude = lock
.borrow()
.exclude_origin_prefixes
.iter()
.any(|x| event.event_meta.origin.starts_with(&**x));
if should_exclude {
// If the event is from the excluded origin, we don't record it
// in the undo stack. But we need to record its effect like it's
// a remote event.
if is_paused || should_exclude {
// Paused and excluded-origin edits are not recorded as
// undo steps, but their effects must be composed into
// both stacks so transforms stay correct, and
// next_counter must advance so the next recorded item
// does not fold these edits into its span.
let mut inner = lock.borrow_mut();
inner.undo_stack.compose_remote_event(event.events);
inner.redo_stack.compose_remote_event(event.events);
Expand Down Expand Up @@ -666,6 +681,9 @@ impl UndoManager {
}
EventTriggerKind::Checkout => {
let lock = inner_clone.lock();
if lock.borrow().paused {
return;
}
let mut inner = lock.borrow_mut();
inner.undo_stack.clear();
inner.redo_stack.clear();
Expand Down Expand Up @@ -766,6 +784,9 @@ impl UndoManager {
get_opposite: impl Fn(&mut UndoManagerInner) -> &mut Stack,
kind: UndoOrRedo,
) -> LoroResult<bool> {
if self.inner.lock().borrow().paused {
return Ok(false);
}
let doc = &self.doc.clone();
// When in the undo/redo loop, the new undo/redo stack item should restore the selection
// to the state it was in before the item that was popped two steps ago from the stack.
Expand Down Expand Up @@ -828,6 +849,9 @@ impl UndoManager {
inner.processing_undo = true;
get_stack(&mut inner).pop()
};
let _guard = ProcessingUndoGuard {
inner: self.inner.clone(),
};

let mut executed = false;
while let Some((mut span, remote_diff)) = top {
Expand All @@ -836,7 +860,7 @@ impl UndoManager {
let inner = self.inner.clone();
// We need to clone this because otherwise <transform_delta> will be applied to the same remote diff
let remote_change_clone = remote_diff.lock().clone();
let commit = doc.undo_internal(
let commit = match doc.undo_internal(
IdSpan {
peer: self.peer(),
counter: span.span,
Expand All @@ -850,7 +874,14 @@ impl UndoManager {
get_stack(&mut inner.borrow_mut()).transform_based_on_this_delta(diff);
});
},
)?;
) {
Ok(c) => c,
Err(e) => {
get_stack(&mut self.inner.lock().borrow_mut())
.push(span.span, span.meta);
return Err(e);
}
};
drop(commit);
let inner = self.inner.lock();
let mut is_some = false;
Expand Down Expand Up @@ -914,7 +945,6 @@ impl UndoManager {
}
}

self.inner.lock().borrow_mut().processing_undo = false;
Ok(executed)
}

Expand Down Expand Up @@ -977,6 +1007,32 @@ impl UndoManager {
self.inner.lock().borrow_mut().undo_stack.clear();
}

/// Pause the UndoManager so that local edits and checkout events are ignored.
///
/// While paused, local edits are not recorded as undo steps and checkout
/// events do not clear the stacks. Import events (remote changes) are still
/// processed so that the stacks remain correctly transformed against
/// concurrent edits.
///
/// Use this before temporary checkouts (e.g. read-only history preview) that
/// should not disturb the undo/redo history. Close any open group (via
/// [`Self::group_end`]) before pausing.
///
/// Call [`Self::resume`] after returning the document to its original state.
pub fn pause(&self) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undo()/redo() are not guarded while paused, and the primary use case makes that fatal. With the slider open the doc is detached and — thanks to this PR — the stacks are non-empty for the first time in that state. A stray Ctrl+Z then runs perform(), which pops the top item and gets Err(EditWhenDetached) from undo_internal. The ? exits before processing_undo = false is restored, so:

  • the popped undo item is lost, and
  • processing_undo stays true forever → every future Local event is skipped → undo recording silently dead.

Repro in the review body (can_undo() is false after resume + edit).

Suggested fix: early-return Ok(false) from perform() when paused, and make the error path safe regardless (reset processing_undo via a drop guard, push the popped item back on error) — the TS runtime's #invert already does both via catch/finally, so this also restores parity.

self.inner.lock().borrow_mut().paused = true;
}

/// Resume the UndoManager after a [`Self::pause`].
pub fn resume(&self) {
self.inner.lock().borrow_mut().paused = false;
}

/// Returns whether the UndoManager is currently paused.
pub fn is_paused(&self) -> bool {
self.inner.lock().borrow().paused
}

pub fn set_top_undo_meta(&self, meta: UndoItemMeta) {
self.inner.lock().borrow_mut().undo_stack.set_top_meta(meta);
}
Expand Down
25 changes: 25 additions & 0 deletions crates/loro-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5452,6 +5452,31 @@ impl UndoManager {
pub fn clearUndo(&self) {
self.undo.lock().clear_undo();
}

/// Pause the UndoManager so that local edits and checkout events are ignored.
///
/// While paused, local edits are not recorded as undo steps and checkout
/// events do not clear the stacks. Import events (remote changes) are still
/// processed so that the stacks remain correctly transformed against
/// concurrent edits.
///
/// Use this before temporary checkouts (e.g. read-only history preview) that
/// should not disturb undo/redo history. Close any open group before pausing.
///
/// Call `resume()` after returning the document to its original state.
pub fn pause(&self) {
self.undo.lock().pause();
}

/// Resume the UndoManager after a `pause()`.
pub fn resume(&self) {
self.undo.lock().resume();
}

/// Returns whether the UndoManager is currently paused.
pub fn isPaused(&self) -> bool {
self.undo.lock().is_paused()
}
}

/// Use this function to throw an error after the micro task.
Expand Down
26 changes: 26 additions & 0 deletions crates/loro/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3976,6 +3976,32 @@ impl UndoManager {
pub fn top_redo_value(&self) -> Option<LoroValue> {
self.0.top_redo_value()
}

/// Pause the UndoManager so that local edits and checkout events are ignored.
///
/// While paused, local edits are not recorded as undo steps and checkout
/// events do not clear the stacks. Import events (remote changes) are still
/// processed so that the stacks remain correctly transformed against
/// concurrent edits.
///
/// Use this before temporary checkouts (e.g. read-only history preview) that
/// should not disturb undo/redo history. Close any open group (via
/// [`Self::group_end`]) before pausing.
///
/// Call [`Self::resume`] after returning the document to its original state.
pub fn pause(&self) {
self.0.pause();
}

/// Resume the UndoManager after a [`Self::pause`].
pub fn resume(&self) {
self.0.resume();
}

/// Returns whether the UndoManager is currently paused.
pub fn is_paused(&self) -> bool {
self.0.is_paused()
}
}
/// When a undo/redo item is pushed, the undo manager will call the on_push callback to get the meta data of the undo item.
/// The returned cursors will be recorded for a new pushed undo item.
Expand Down
151 changes: 151 additions & 0 deletions crates/loro/tests/integration_test/undo_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1761,3 +1761,154 @@ fn undo_with_custom_commit_options() -> anyhow::Result<()> {
assert_eq!(trigger_times.load(atomic::Ordering::SeqCst), 2);
Ok(())
}

#[test]
fn pause_preserves_undo_stacks_across_checkout() -> LoroResult<()> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both new tests are single-peer checkout round-trips — they pass with or without the Import-while-paused distinction, so the central claim of the second commit is currently untested. Suggest adding (here and mirrored in runtime.test.ts):

#[test]
fn import_while_paused_keeps_stacks_transformed() -> LoroResult<()> {
    let doc_a = LoroDoc::new();
    doc_a.set_peer_id(1)?;
    let doc_b = LoroDoc::new();
    doc_b.set_peer_id(2)?;
    let mut undo = UndoManager::new(&doc_a);
    let text_a = doc_a.get_text("text");

    text_a.insert(0, "local")?;
    doc_a.commit();

    undo.pause();
    // concurrent remote edit arrives while paused
    doc_b.import(&doc_a.export(loro::ExportMode::all_updates())?)?;
    doc_b.get_text("text").insert(0, "remote-")?;
    doc_b.commit();
    doc_a.import(&doc_b.export(loro::ExportMode::all_updates())?)?;
    undo.resume();

    // undo must remove only the local edit, at the transformed position
    undo.undo()?;
    assert_eq!(text_a.to_string(), "remote-");
    Ok(())
}

This is the regression test that would catch a future refactor back to a blanket early-return guard.

let doc = LoroDoc::new();
doc.set_peer_id(1)?;
let mut undo = UndoManager::new(&doc);
let text = doc.get_text("text");

text.insert(0, "Hello")?;
doc.commit();
text.insert(5, " World")?;
doc.commit();
assert_eq!(text.to_string(), "Hello World");
assert!(undo.can_undo());

// Pause before checkout so undo stacks survive
undo.pause();
assert!(undo.is_paused());
doc.checkout(&Frontiers::default())?;
assert_eq!(text.to_string(), "");
doc.attach();
assert_eq!(text.to_string(), "Hello World");
undo.resume();
assert!(!undo.is_paused());

// Undo stacks should still be intact
assert!(undo.can_undo());
undo.undo()?;
assert_eq!(text.to_string(), "Hello");
undo.undo()?;
assert_eq!(text.to_string(), "");

// Redo should also work
assert!(undo.can_redo());
undo.redo()?;
assert_eq!(text.to_string(), "Hello");
undo.redo()?;
assert_eq!(text.to_string(), "Hello World");

Ok(())
}

#[test]
fn checkout_without_pause_clears_undo_stacks() -> LoroResult<()> {
let doc = LoroDoc::new();
doc.set_peer_id(1)?;
let undo = UndoManager::new(&doc);
let text = doc.get_text("text");

text.insert(0, "Hello")?;
doc.commit();
assert!(undo.can_undo());

// Checkout without pausing should still clear stacks (existing behavior)
doc.checkout(&Frontiers::default())?;
assert!(!undo.can_undo());
assert!(!undo.can_redo());

Ok(())
}

#[test]
fn undo_while_paused_does_not_leak_processing_flag() -> LoroResult<()> {
let doc = LoroDoc::new();
doc.set_peer_id(1)?;
let mut undo = UndoManager::new(&doc);
let text = doc.get_text("text");

text.insert(0, "Hello")?;
doc.commit();
assert!(undo.can_undo());

undo.pause();
assert_eq!(undo.undo()?, false);
undo.resume();

assert!(undo.can_undo());
undo.undo()?;
assert_eq!(text.to_string(), "");

text.insert(0, "World")?;
doc.commit();
assert!(undo.can_undo());
undo.undo()?;
assert_eq!(text.to_string(), "");

Ok(())
}

#[test]
fn paused_local_edits_are_not_folded_into_next_undo() -> LoroResult<()> {
let doc = LoroDoc::new();
doc.set_peer_id(1)?;
let mut undo = UndoManager::new(&doc);
let text = doc.get_text("text");

text.insert(0, "A")?;
doc.commit();

undo.pause();
text.insert(1, "B")?;
doc.commit();

undo.resume();
text.insert(2, "C")?;
doc.commit();
assert_eq!(text.to_string(), "ABC");

undo.undo()?;
assert_eq!(text.to_string(), "AB");

undo.undo()?;
assert_eq!(text.to_string(), "B");

assert!(!undo.can_undo());

Ok(())
}

#[test]
fn import_during_pause_transforms_undo_stack_correctly() -> LoroResult<()> {
let doc_a = LoroDoc::new();
doc_a.set_peer_id(1)?;
let mut undo = UndoManager::new(&doc_a);
let text_a = doc_a.get_text("text");

text_a.insert(0, "Hello")?;
doc_a.commit();

undo.pause();

let doc_b = LoroDoc::new();
doc_b.set_peer_id(2)?;
let updates_a = doc_a.export(ExportMode::all_updates())?;
doc_b.import(&updates_a)?;
doc_b.get_text("text").insert(0, "XY")?;
doc_b.commit();
let updates_b = doc_b.export(ExportMode::updates(&doc_a.oplog_vv()))?;

doc_a.import(&updates_b)?;
assert_eq!(text_a.to_string(), "XYHello");

undo.resume();
undo.undo()?;
assert_eq!(text_a.to_string(), "XY");

undo.redo()?;
assert_eq!(text_a.to_string(), "XYHello");

Ok(())
}
Loading