feat: add pause()/resume() to UndoManager#1053
Conversation
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
There was a problem hiding this comment.
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:
perform()pops the top undo item, thendoc.undo_internal(...)returnsErr(EditWhenDetached)- The
?propagates beforeprocessing_undo = falseis reached at the end ofperform() - Result: the popped undo item is lost, and
processing_undois stucktrue— 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()withif paused { return Ok(false); }— a paused manager should be inert. - Harden the error path regardless: reset
processing_undoon 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.
| // the DiffBatch for undo here | ||
| let lock = inner_clone.lock(); | ||
| if lock.borrow().processing_undo { | ||
| if lock.borrow().processing_undo || lock.borrow().paused { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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_undostaystrueforever → 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<()> { |
There was a problem hiding this comment.
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.
| for (const target of targets) this.#remoteTargets.add(target); | ||
| return; | ||
| } | ||
| if (this.#paused) return; |
There was a problem hiding this comment.
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
|
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, Second, I added a Third, if On the TS side, 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 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 On the TS side, the paused path now adds targets to 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. |
Summary
Adds
pause(),resume(), andisPaused()toUndoManageracross 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 —
pauseddefaults tofalse, so existing behavior is unchanged.Why
We're building a history slider that lets users scrub through past document states using
checkout(). Eachcheckout()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 andresume()after preserves the stacks.Validation
New tests:
pause_preserves_undo_stacks_across_checkout— pause, checkout to empty, attach, resume, verify undo/redo workcheckout_without_pause_clears_undo_stacks— confirms existing behavior is unchangedCloses #1050