Skip to content

feat: add pause()/resume() to UndoManager#1053

Open
thomasalvord wants to merge 4 commits into
loro-dev:mainfrom
thomasalvord:feat/undo-manager-pause-resume
Open

feat: add pause()/resume() to UndoManager#1053
thomasalvord wants to merge 4 commits into
loro-dev:mainfrom
thomasalvord:feat/undo-manager-pause-resume

Conversation

@thomasalvord

Copy link
Copy Markdown

Summary

Adds pause(), resume(), and isPaused() to UndoManager across the Rust and JS layers.

When paused, local edits aren't recorded as undo steps and checkout events don't clear the stacks. Import events still run while paused so the stacks stay in sync with remote changes.

Not a breaking change — paused defaults to false, so existing behavior is unchanged.

Why

We're building a history slider that lets users scrub through past document states using checkout(). Each checkout() clears the undo/redo stacks, so after the user closes the slider, their entire Ctrl+Z history is gone even though they didn't edit anything.

pause() before the checkout round-trip and resume() after preserves the stacks.

Validation

cargo fmt --all -- --check          # clean
cargo nextest run -p loro --test loro_rust_test -E "test(undo)"   # 33/33 passed
pnpm vitest run runtime.test.ts     # 69/69 passed

New tests:

  • pause_preserves_undo_stacks_across_checkout — pause, checkout to empty, attach, resume, verify undo/redo work
  • checkout_without_pause_clears_undo_stacks — confirms existing behavior is unchanged
  • JS runtime test — same flow in the TypeScript runtime

Closes #1050

Allow callers to temporarily pause the UndoManager so it ignores all
document events. While paused, Local events are not recorded, Import
events do not transform the stacks, and Checkout events do not clear
them. This enables read-only history preview (e.g. a timeline slider)
without destroying undo/redo history.

Closes loro-dev#1050
Move the paused guard from a blanket early-return to per-arm checks on
Local and Checkout only. Import events must still transform the undo/redo
stacks against remote changes while paused, otherwise undo after resume
targets the wrong positions.

Also:
- Add pause()/resume()/isPaused() to the pure TypeScript runtime
- Add a JS runtime test for pause across checkout
- Add changeset
- Align doc comments across all three layers

@lodystage lodystage Bot left a comment

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.

Thanks for this PR — the API shape is right, the per-arm guard design (letting Import events keep transforming the stacks while paused) is exactly the correct call, and catching that subtlety between the issue proposal and the PR was genuinely careful work. The changeset, three-layer doc alignment, and tests for both the new behavior and the preserved default are all appreciated.

That said, while reviewing I probed a bit beyond the checkout round-trip happy path and found two real bugs, both confirmed with executable repros on this branch. Details inline; repro code below.


Bug 1: Ctrl+Z while paused + detached permanently breaks the UndoManager (Rust/wasm)

The feature's primary state is now "non-empty undo stacks + detached doc" (history slider open). If the user presses Ctrl+Z there:

  1. perform() pops the top undo item, then doc.undo_internal(...) returns Err(EditWhenDetached)
  2. The ? propagates before processing_undo = false is reached at the end of perform()
  3. Result: the popped undo item is lost, and processing_undo is stuck true — the Local arm skips every future event, so undo recording is silently dead for the lifetime of the manager

Repro (add to crates/loro/tests/integration_test/undo_test.rs)

#[test]
fn undo_while_paused_and_detached() -> 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();
    undo.pause();
    doc.checkout(&Frontiers::default())?;
    // user hits Ctrl+Z mid-preview
    let r = undo.undo();          // => Err(EditWhenDetached), popped item lost
    assert!(r.is_err());
    doc.attach();
    undo.resume();
    text.insert(5, "!")?;
    doc.commit();
    // FAILS on this branch: processing_undo leaked true, the edit was never recorded
    assert!(undo.can_undo());
    Ok(())
}

Observed on this branch: can_undo() returns false after resume — recording is dead.

This is a pre-existing latent bug (any undo_internal error leaks processing_undo), but before this PR the state was effectively unreachable: checkout always cleared the stacks, so undo() while detached hit the empty-stack no-op. This PR makes it the main path of the target use case.

Suggested fix (both):

  • Guard perform() with if paused { return Ok(false); } — a paused manager should be inert.
  • Harden the error path regardless: reset processing_undo on all exits (drop guard) and push the popped item back on error. The TS runtime already does exactly this (catch { this.#undo.push(item); throw } + finally) and recovers cleanly; the Rust side should match.

Bug 2: paused local edits are not actually ignored — and the two packages disagree

The doc comment on all three layers says local edits made while paused "are not recorded as undo steps." On the Rust side they are recorded — late. The Local arm drops the event without advancing next_counter, so the next record_checkpoint spans from the stale counter and folds the paused edits into the next undo item (record_new_checkpoint() inside undo() scoops them up even with no post-resume edit).

Repro

#[test]
fn paused_local_edit_semantics() -> 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")?;   // docs say: not recorded
    doc.commit();
    undo.resume();
    text.insert(2, "C")?;
    doc.commit();
    undo.undo()?;
    // FAILS on this branch: actual result is "A" — B and C were folded into one undo item
    assert_eq!(text.to_string(), "AB");
    Ok(())
}

Same sequence through the pure TS runtime (loro-js):

text.insert(0, "A"); doc.commit();
undo.pause();
text.insert(1, "B"); doc.commit();
undo.resume();
text.insert(2, "C"); doc.commit();
undo.undo();  // "AB"  (B dropped entirely)
undo.undo();  // "B"   (B permanently un-undoable, A removed)
Layer After 1 undo After 2 undos
Documented behavior "AB"
Rust / loro-crdt "A" (B+C one item) ""
Pure TS loro-js "AB" "B"

Same user code, three-way disagreement between docs, wasm, and the TS runtime.

Suggested fix: in the Rust Local arm, treat paused like the existing should_exclude branch: compose_remote_event on both stacks + advance next_counter. That's the codebase's established pattern for "local edit that must not become an undo step," it makes the doc comment true, and it converges with the runtime's behavior. Then pin it with a cross-layer test.


Test gap: the Import-while-paused behavior has no coverage

The key semantic claim of the second commit — Import events must keep transforming the stacks while paused, otherwise undo after resume targets wrong positions — is stated in the changeset and doc comments but never tested. Both new tests are single-peer checkout round-trips, which pass with or without the Import distinction. Suggested test inline.


Everything else checks out: wasm binding correctly stays off the pending-events allowlist (pause/resume/isPaused don't mutate or emit), changeset covers both packages, scope matches #1050 exactly. Happy to see this land once the paused-state edge cases are nailed down.

Comment thread crates/loro-internal/src/undo.rs Outdated
// 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.

/// [`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.

}

#[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.

Comment thread loro-js/src/runtime/undo.ts Outdated
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.

- Add ProcessingUndoGuard so processing_undo resets on error
- Return Ok(false) from perform() when paused (no-op undo/redo)
- Treat paused local edits like excluded-origin edits: compose into
  both stacks and advance next_counter instead of dropping silently
- Add paused guards to TS undo()/redo() for consistency
- Fix TS #record() to accumulate remoteTargets when paused
- Add tests for all three fixes plus import-while-paused coverage
@thomasalvord

Copy link
Copy Markdown
Author

Thanks for the thorough review — all three issues were real. Here's what I changed:

Bug 1 — undo() while paused and detached

Three fixes here. First, perform() now returns Ok(false) immediately when the undo manager is paused, so undo/redo are complete no-ops while paused. This prevents ever entering the undo loop when the doc is detached with items still on the stack.

Second, I added a ProcessingUndoGuard — a small RAII struct that resets processing_undo to false when it drops. This replaces the manual cleanup at the end of perform(), which the ? operator could skip on error.

Third, if undo_internal() does return an error, the popped item is now pushed back onto the stack before propagating. This matches what the TS runtime already does in its catch block, so the Rust side now recovers the same way.

On the TS side, undo() and redo() now return false immediately when paused, matching the Rust behavior.

Bug 2 — paused local edits folding into the next undo item

The old code returned early from the Local event handler (the path that processes the current user's own edits) when paused, which meant next_counter never advanced. The next edit after resume would create an undo item covering everything since the stale counter, accidentally bundling in the paused edits.

The fix reuses the codebase's existing pattern for edits that should not become an undo step but still need to be accounted for (the same path used for excluded-origin edits): call compose_remote_event on both the undo and redo stacks and advance next_counter.

On the TS side, the paused path now adds targets to remoteTargets (matching the excluded-origin path) instead of returning with no side effects.

After the fix, both Rust and TS produce the same result: type A → pause → type B → resume → type C → undo gives AB, undo again gives B. B was typed during pause so it's not undoable — it stays in the document permanently, like a remote edit.

Test gap — import during pause

Added a multi-peer test on both sides. Peer A types Hello, pauses, peer B prepends XY (which shifts the position of Hello), peer A imports the remote edit while paused, resumes, and undoes. The test verifies Hello is removed at the correct shifted position, leaving just XY.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add pause()/resume() to UndoManager (or expose checkout without stack clear)

1 participant