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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions