Problem
When building a document history slider (time-travel UI), I need to call doc.checkout(frontiers) to preview past states, then doc.checkoutToLatest() to return. Both calls trigger EventTriggerKind::Checkout, which unconditionally clears the UndoManager's undo and redo stacks (undo.rs line 667-673).
This means users lose their entire Ctrl+Z/Y history just by previewing document history — even though no edits were made. The history slider is read-only; the user is only viewing past states, not modifying anything.
Current workaround
Destroying and re-creating the UndoManager after checkoutToLatest(), which also loses all undo history. The alternative — cloning the entire LoroDoc just for read-only preview — is heavyweight and complex to plumb through the rendering layer.
Use case
- User types text (undo stack has entries)
- User opens a history slider to preview past document states (
checkout to various frontiers)
- User closes the slider (
checkoutToLatest)
- User presses Ctrl+Z — expected: undo works; actual: undo stack is empty
The document returns to the exact same state after the round-trip, so the undo stack entries are still valid — they just get cleared by the checkout event handler.
Proposed fix: pause() / resume() on UndoManager
This follows the exact pattern already used by processing_undo in undo.rs (line 610/828), which causes the event handler to skip processing during undo operations. A paused flag would work identically.
Loro's own undo_internal (loro.rs lines 1148-1169) already performs checkouts that don't clear the stacks — it calls _checkout_without_emitting and skips emit_events(). The proposed fix achieves the same result from the consumer side.
Changes (~35 lines across 2 files)
crates/loro-internal/src/undo.rs
1. Add paused: bool field to UndoManagerInner (after processing_undo):
2. Guard the event subscription handler (inside UndoManager::new(), line 605):
let undo_sub = doc.subscribe_root(Arc::new(move |event| {
{
let lock = inner_clone.lock();
if lock.borrow().paused {
return;
}
}
match event.event_meta.by {
// ... existing arms unchanged ...
}
}));
3. Add public methods (after clear_undo()):
pub fn pause(&self) {
self.inner.lock().borrow_mut().paused = true;
}
pub fn resume(&self) {
self.inner.lock().borrow_mut().paused = false;
}
pub fn is_paused(&self) -> bool {
self.inner.lock().borrow().paused
}
crates/loro-wasm/src/lib.rs
Add WASM bindings (after clearUndo()):
pub fn pause(&self) {
self.undo.lock().pause();
}
pub fn resume(&self) {
self.undo.lock().resume();
}
pub fn isPaused(&self) -> bool {
self.undo.lock().is_paused()
}
Usage
undoManager.pause();
doc.checkout(someFrontiers);
// ... read-only preview ...
doc.checkoutToLatest();
undoManager.resume();
// undo/redo stacks are intact
Why this is safe
- Mirrors the existing
processing_undo flag pattern already in undo.rs
- Loro's own
undo_internal already does checkouts that skip the stack clear (via _checkout_without_emitting + no emit_events())
paused defaults to false, so no behavioral change for existing users
- While paused, Import events (remote changes) are also skipped — but for a read-only history preview round-trip this is fine since the doc returns to the same state
Environment
loro-crdt: 1.12.1
- Platform: WASM/JS (browser)
Problem
When building a document history slider (time-travel UI), I need to call
doc.checkout(frontiers)to preview past states, thendoc.checkoutToLatest()to return. Both calls triggerEventTriggerKind::Checkout, which unconditionally clears the UndoManager's undo and redo stacks (undo.rsline 667-673).This means users lose their entire Ctrl+Z/Y history just by previewing document history — even though no edits were made. The history slider is read-only; the user is only viewing past states, not modifying anything.
Current workaround
Destroying and re-creating the UndoManager after
checkoutToLatest(), which also loses all undo history. The alternative — cloning the entireLoroDocjust for read-only preview — is heavyweight and complex to plumb through the rendering layer.Use case
checkoutto various frontiers)checkoutToLatest)The document returns to the exact same state after the round-trip, so the undo stack entries are still valid — they just get cleared by the checkout event handler.
Proposed fix:
pause()/resume()on UndoManagerThis follows the exact pattern already used by
processing_undoinundo.rs(line 610/828), which causes the event handler to skip processing during undo operations. Apausedflag would work identically.Loro's own
undo_internal(loro.rslines 1148-1169) already performs checkouts that don't clear the stacks — it calls_checkout_without_emittingand skipsemit_events(). The proposed fix achieves the same result from the consumer side.Changes (~35 lines across 2 files)
crates/loro-internal/src/undo.rs1. Add
paused: boolfield toUndoManagerInner(afterprocessing_undo):2. Guard the event subscription handler (inside
UndoManager::new(), line 605):3. Add public methods (after
clear_undo()):crates/loro-wasm/src/lib.rsAdd WASM bindings (after
clearUndo()):Usage
Why this is safe
processing_undoflag pattern already inundo.rsundo_internalalready does checkouts that skip the stack clear (via_checkout_without_emitting+ noemit_events())pauseddefaults tofalse, so no behavioral change for existing usersEnvironment
loro-crdt: 1.12.1