Skip to content

Per-container UndoManager scope (use case: per-file undo on a unified LoroDoc) #981

Description

@edochi

Context

I'm building a collaborative application that syncs a multi-file workspace as a single unit: one LoroDoc per workspace, a LoroTree of file nodes, each file's content in a LoroText under its node's meta map. The unified-LoroDoc shape is the right fit for what I'm building — cross-workspace causal ordering matters for me and I don't want to reinvent it across multiple docs.

What I'd like on top of that is per-file undo: Ctrl+Z while focused in file A reverts only file A's local edits.

Current workaround

One UndoManager per file, with cross-exclusion via origin prefixes:

let undo_a = UndoManager::new(&doc);
undo_a.add_exclude_origin_prefix("file:b");
undo_a.add_exclude_origin_prefix("file:c");
undo_a.add_exclude_origin_prefix("undo:file:b");
undo_a.add_exclude_origin_prefix("undo:file:c");
// ...repeat for every file

Plus discipline: set_next_commit_origin("file:X") before every edit, and set_next_commit_origin("undo:file:X") before every undo/redo so the inverse op carries the same origin and other managers exclude it.

It works, but:

  • O(N²) registration. Every new file means every existing manager adds two more prefixes.
  • Mixed commits aren't handled. A single commit touching multiple files (workspace-wide find-and-replace, save flow that bumps content + metadata, any batched cross-file op) gets fully reverted by any file's undo. I work around this with strict commit-boundary discipline that leaks into application design.
  • Undocumented carry-through. The scheme depends on set_next_commit_origin carrying through to undo()'s internal commit. Not documented anywhere I could find; if it ever changes, cross-manager exclusion silently breaks.
  • Boilerplate. Every consumer has to reimplement the same 4-origin scheme.

Proposed API

pub enum UndoScope {
    Doc,                                  // default — preserves today's behavior
    Containers(FxHashSet<ContainerID>),
}

impl UndoManager {
    pub fn set_scope(&self, scope: UndoScope);
    pub fn with_scope(self, scope: UndoScope) -> Self;
}

let undo_a = UndoManager::new(&doc).with_scope(UndoScope::containers([text_a.id()]));

Reads to me like a generalization of add_exclude_origin_prefix — same record-time path, just keyed on ContainerID membership instead of origin prefix. Mixed commits (which the origin-prefix workaround can't handle) would be handled at replay time by masking the computed DiffBatch to in-scope containers before _apply_diff.

Prototype

Sketched at edochi/loro@feat/undo-scope (4 commits on top of main at 095ad56f):

  • Default UndoScope::Doc is a true no-op — all existing undo tests pass unchanged.
  • Non-breaking signature: undo_internal is preserved as a thin #[inline] wrapper; new behavior lives on undo_internal_with_scope.
  • 9 new integration tests covering basic scope, mixed commits, redo symmetry, edge cases.
  • Bench (1000 commits/iter, n=50): default-scope path is +45 ns/commit vs main (~1.3%, near noise floor); replay path is statistically identical with or without the mask.

Rust-only at the moment — happy to discuss whether loro-wasm bindings should be in the same PR or a follow-up. I'd rather agree on the Rust API shape before sending a PR.

Questions

  1. Is this direction acceptable upstream, or have you considered per-container scope before and decided against it for reasons I'm not seeing?
  2. Mixed-commit semantics. The replay-time mask breaks cross-container atomicity at undo time — that's what scope means, but I want to confirm you're OK with that as the default rather than asking users to opt in.
  3. Is there a better shape I'm missing? Different API, different scope primitive, different place to put the filter — open to suggestions.

Thanks for considering!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions