Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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.
33 changes: 32 additions & 1 deletion 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 @@ -502,6 +503,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 @@ -607,7 +609,7 @@ impl UndoManager {
// TODO: PERF undo can be significantly faster if we can get
// the DiffBatch for undo here
let lock = inner_clone.lock();
if lock.borrow().processing_undo {
if lock.borrow().processing_undo || lock.borrow().paused {

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.

Dropping the Local event entirely means next_counter is not advanced, so the paused ops get folded into the span of the next record_checkpoint — they do become undoable, contradicting the doc comment, and diverging from the TS runtime which genuinely drops them (see review body for the three-way repro).

Suggest handling paused like the should_exclude branch below instead of returning early:

let should_exclude = /* ... */;
if should_exclude || lock.borrow().paused {
    let mut inner = lock.borrow_mut();
    inner.undo_stack.compose_remote_event(event.events);
    inner.redo_stack.compose_remote_event(event.events);
    inner.next_counter = Some(id.counter + 1);
} else { /* record_checkpoint */ }

That's the established pattern for "local edit that must not become an undo step," it keeps the stacks correctly transformed, and it makes the documented semantics true on both packages.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done — paused now shares the should_exclude path.

return;
}
if let Some(id) = event
Expand Down Expand Up @@ -666,6 +668,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 @@ -977,6 +982,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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done — perform() returns Ok(false) when paused, added a ProcessingUndoGuard drop guard, and the popped item is pushed back on error.

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
60 changes: 60 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,63 @@ 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done — added import_during_pause_transforms_undo_stack_correctly in both Rust and TS.

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(())
}
16 changes: 15 additions & 1 deletion loro-js/src/runtime/undo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export class UndoManager {
#onPush: UndoConfig["onPush"];
#onPop: UndoConfig["onPop"];
#applying = false;
#paused = false;
#groupDepth = 0;
#unsubscribe: () => void;

Expand Down Expand Up @@ -186,6 +187,18 @@ export class UndoManager {
this.#redo.length = 0;
}

pause(): void {
this.#paused = true;
}

resume(): void {
this.#paused = false;
}

isPaused(): boolean {
return this.#paused;
}

destroy(): void {
this.#unsubscribe();
this.clear();
Expand All @@ -195,13 +208,14 @@ export class UndoManager {
if (this.#applying) return;
const targets = new Set(event.events.map(({ target }) => target));
if (event.by === "checkout") {
this.clear();
if (!this.#paused) this.clear();
return;
}
if (event.by === "import") {
for (const target of targets) this.#remoteTargets.add(target);
return;
}
if (this.#paused) return;

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.

Note: this early return gives the runtime different semantics from the Rust side for local edits made while paused — here they're dropped forever (never undoable), while Rust folds them into the next undo item (see the divergence table in the review body). Whichever semantics wins in undo.rs, this path should be updated to match and covered by a shared test — this suite's name promises wasm compatibility.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done — paused local edits now add to remoteTargets (matching the excluded-origin path). Both layers produce the same result.

if (
event.origin !== undefined &&
[...this.#excludeOriginPrefixes].some((prefix) => event.origin!.startsWith(prefix))
Expand Down
33 changes: 33 additions & 0 deletions loro-js/tests/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,39 @@ describe("loro-wasm-compatible runtime", () => {
expect(text.toString()).toBe("Hello");
});

test("pause preserves undo stacks across checkout", () => {
const doc = new LoroDoc();
doc.setPeerId(1);
const undo = new UndoManager(doc, { mergeInterval: 0 });
const text = doc.getText("text");

text.insert(0, "Hello");
doc.commit();
text.insert(5, " World");
doc.commit();
expect(text.toString()).toBe("Hello World");
expect(undo.canUndo()).toBe(true);

undo.pause();
expect(undo.isPaused()).toBe(true);
doc.checkout([]);
expect(text.toString()).toBe("");
doc.attach();
expect(text.toString()).toBe("Hello World");
undo.resume();
expect(undo.isPaused()).toBe(false);

expect(undo.canUndo()).toBe(true);
expect(undo.undo()).toBe(true);
expect(text.toString()).toBe("Hello");
expect(undo.undo()).toBe(true);
expect(text.toString()).toBe("");

expect(undo.canRedo()).toBe(true);
expect(undo.redo()).toBe(true);
expect(text.toString()).toBe("Hello");
});

test("round-trips compressed and uncompressed JSON updates", () => {
const source = new LoroDoc();
source.setPeerId(19);
Expand Down