Skip to content

feat(undo): per-container UndoScope for UndoManager - #1039

Open
edochi wants to merge 7 commits into
loro-dev:mainfrom
edochi:feat/undo-scope
Open

feat(undo): per-container UndoScope for UndoManager#1039
edochi wants to merge 7 commits into
loro-dev:mainfrom
edochi:feat/undo-scope

Conversation

@edochi

@edochi edochi commented Jul 1, 2026

Copy link
Copy Markdown

Summary

Adds an opt-in UndoScope to UndoManager so a manager can be restricted to a set of containers. Ctrl+Z then reverts only edits within that scope, even when a single commit touched containers both inside and outside it. The default is UndoScope::Doc, which is bit-for-bit today's behavior, so the change is purely additive and every existing undo test passes unchanged. Closes #981.

let undo = UndoManager::new(&doc).with_scope(UndoScope::containers([text_a.id()]));
// undo.undo() now reverts only text_a's local edits

Motivation

The use case is a collaborative multi-file workspace held in a single LoroDoc: a LoroTree of file nodes, each file's content a LoroText. We want per-file undo on that unified doc. Today the only per-scope knob is add_exclude_origin_prefix, so per-file undo requires N UndoManager instances cross-excluded by origin string, which is O(N²) to register, can't handle a commit that touches two files at once, relies on set_next_commit_origin carrying through into the reverse op undo() emits internally, and pushes the same boilerplate onto every consumer. UndoScope::Containers reads as a generalization of add_exclude_origin_prefix: same record-time filter path, keyed on ContainerID membership instead of an origin-string prefix.

Design

Two filter points, mirroring how undo already works. At record time, an out-of-scope local commit is not pushed onto the undo stack; it's routed through the same compose_remote_event path that handles real remote peers, so cursor transforms still apply and counter bookkeeping advances cleanly. The scope check is inlined so the optimizer folds the Doc arm to a constant, leaving the default path to pay only a discriminant load. At replay time, a new undo_internal_with_scope masks the computed DiffBatch to in-scope containers before _apply_diff, walking container_remap to mirror its own remap chain; if a commit touched A, B, C and only A is in scope, only A is reverted. That replay-time mask is what makes mixed commits correct, and it has no equivalent in the origin-filter approach. The old undo_internal stays as a thin #[inline] wrapper delegating with scope_filter: None, so no existing caller changes.

pub enum UndoScope {
    Doc,                                  // #[default], current behavior
    Containers(FxHashSet<ContainerID>),
}
impl UndoScope {
    pub fn containers<I: IntoIterator<Item = ContainerID>>(ids: I) -> Self;
}
impl UndoManager {
    pub fn set_scope(&self, scope: UndoScope);
    pub fn with_scope(self, scope: UndoScope) -> Self;  // builder
}

Tests

9 new integration tests in crates/loro-internal/tests/undo.rs (16 total, all passing): default-is-doc-wide regression guard; out-of-scope exclusion and redo symmetry; out-of-scope edits not corrupting the in-scope stack; mixed-commit undo and redo (impossible with the origin workaround); chained mixed commits; three-container scope sets; and scope changed after construction.

Benchmarks

crates/loro-internal/benches/undo.rs (criterion, 1000 commits/iter, n=50), main vs this branch:

Path main this branch Δ/commit
no_manager (baseline) 2.32 µs 2.26 µs within noise
default_scope (existing hot path) 3.54 µs 3.59 µs +45 ns (~1.3%)
scoped_one_container (new opt-in) n/a 3.58 µs ≈ default-scope
replay per undo+redo 5.17 ms 5.15 ms within noise

The default-path regression is ~45 ns/commit; the opt-in scoped path is indistinguishable from default-scope, and the replay-time mask is free relative to the surrounding checkout-and-diff cost.

Compatibility

Purely additive; UndoScope::Doc is the default and preserves current behavior. Fits a minor bump.

edochi and others added 4 commits July 1, 2026 10:05
Introduces a new `UndoScope` enum that lets `UndoManager` filter local
commits by the set of containers they touch. The default `UndoScope::Doc`
preserves today's doc-wide behavior, so existing users see no change.

When `UndoScope::Containers([cid, ...])` is set, local commits that touch
zero in-scope containers follow the same path as origin-excluded commits:
their effect is composed into both stacks via `compose_remote_event` (so
cursor transforms remain accurate) and `next_counter` is advanced past
them, keeping subsequent in-scope `CounterSpan`s tight.

Two ergonomic forms are provided on `UndoManager`:

- `set_scope(&self, scope)` for mutating an existing manager
- `with_scope(self, scope) -> Self` for builder-style construction

The scope check is inlined at the subscription-callback site so the
optimizer can fold the `Doc` arm to a constant `false` and skip the
iteration entirely in the default path. Bench (1000 commits/iter, n=50):

  default scope vs main:  +45 ns/commit (~1.3%, near noise floor)
  scoped one container:   identical to default within noise

The replay-time half of the feature (masking the computed `DiffBatch` so
mixed commits revert only their in-scope portion) follows in the next
commit; this commit alone handles the simpler "all in or all out" case.

Re-exports `UndoScope` from both `loro-internal` and the public `loro`
crate, and adds `set_scope`/`with_scope` to the `loro::UndoManager`
wrapper.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds the replay-time half of UndoScope. Without this, a single commit
that touched both in-scope and out-of-scope containers would have its
*entire* effect reverted at undo time — because the recorded CounterSpan
covers the whole commit and `undo_internal` produces a diff covering
every container in that range.

The fix is small and local: after the diff is computed but before
`_apply_diff` consumes it, mask `cid_to_events` and `order` to retain
only entries whose ContainerID (after walking `container_remap`, to
mirror `_apply_diff`'s own remap chain) is in the scope set. `_apply_diff`
already iterates containers independently and tolerates missing entries,
so dropping unwanted (cid, diff) pairs is structurally safe.

To keep this non-breaking for any direct callers of `undo_internal`, the
new behavior lives on a new method `undo_internal_with_scope` that takes
an `Option<&FxHashSet<ContainerID>>`. The existing `undo_internal` is
preserved as a thin `#[inline]` wrapper that delegates with `None`.
`#[instrument]` lives on the new method only, so direct callers get
exactly one tracing span (no duplicates).

`UndoManager::perform()` snapshots the active scope (if any) per undo
iteration and threads it through. Per-iteration snapshot is correct
under any threading model in case `set_scope` is called concurrently.

Bench: replay path is statistically identical to main (5.15s vs 5.17s
for 1000 undo+redo cycles, well within Criterion's noise band). The
mask is O(M) per undo where M is the number of containers in the diff —
dominated by the existing O(n + m) checkout-and-diff cost.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds 9 integration tests in crates/loro-internal/tests/undo.rs:

- scope_default_is_doc_wide: UndoScope::Doc preserves historical behavior
- scope_excludes_out_of_scope_local_commits: filter at record time
- scope_redo_only_in_scope: redo respects scope symmetrically
- scope_out_of_scope_edits_do_not_corrupt_in_scope_stack: counter
  bookkeeping stays correct when out-of-scope commits sit between
  in-scope ones
- scope_mixed_commit_only_undoes_in_scope: replay-time mask works (the
  killer case — without the mask, this would revert both containers)
- scope_mixed_commit_redo_only_restores_in_scope: redo of a mixed-undo
  also stays in-scope
- scope_multiple_mixed_commits_chained: 3 chained mixed commits, undo
  walks back through only the in-scope portion of each
- scope_mixed_commit_with_three_containers: scope = {a, c}, single
  commit touches a + b + c, undo reverts a and c but leaves b
- scope_change_after_construction: set_scope mid-session; commits are
  classified by the scope active at record time

All 16 tests in tests/undo.rs pass (7 baseline + 9 new). The 7 baseline
tests prove backward compatibility is preserved (default UndoScope::Doc
is a no-op).

Co-Authored-By: Claude <noreply@anthropic.com>
Adds a Criterion bench at crates/loro-internal/benches/undo.rs (gated
on the test_utils feature, matching the existing benches in this crate)
covering three groups:

- record_local_commits — measures the subscription-callback hot path
  with no UndoManager, with a default-scope (Doc) UndoManager, and with
  a Containers-scoped UndoManager.
- record_mixed_scope — interleaves in-scope and out-of-scope commits to
  measure how the compose-as-remote branch performs vs full recording.
- replay_all — measures undo() + redo() of a full 1000-commit history,
  exercising undo_internal_with_scope end-to-end (with and without the
  scope mask block).

Runs with:
  cargo bench -p loro-internal --bench undo --features test_utils

Numbers on Apple Silicon (1000 commits/iter, n=50):

| variant                                  | time     | per commit |
|------------------------------------------|----------|------------|
| no_manager                               | 2.26 ms  | 2.26 µs    |
| undo_manager_default_scope (this branch) | 3.59 ms  | 3.59 µs    |
| undo_manager_default_scope (main)        | 3.54 ms  | 3.54 µs    |
| undo_manager_scoped_one_container        | 3.58 ms  | 3.58 µs    |
| replay/doc_scope                         | 5.15 s   | 2.57 ms/op |
| replay/scoped_one_container              | 5.18 s   | 2.59 ms/op |

The default-scope path is ~45 ns/commit slower than main (~1.3%),
attributable to one extra match-arm dispatch on the new `scope` field
in the subscription callback. Replay is statistically identical
(scoped vs unscoped within Criterion's noise band) — the mask block is
free relative to the surrounding O(n + m) checkout-and-diff cost.

Co-Authored-By: Claude <noreply@anthropic.com>
@edochi edochi closed this Jul 1, 2026
@edochi edochi reopened this Jul 1, 2026
@edochi
edochi marked this pull request as ready for review July 1, 2026 09:30
edochi added 3 commits July 21, 2026 15:05
…chor-tracker

Add LoroDoc::compare_cursors, a total order over two cursors defined for
deleted (tombstoned) characters and convergent across peers — unlike
get_cursor_pos, whose live-position answer is underdetermined at a deletion
seam. Gated behind a new default-off persistent-anchor-tracker feature.

Built as a sibling of query_pos_internal's slow path: a fresh DiffCalculator
from genesis so both ids are present in the fugue rope (live or tombstoned),
then resolve each to (leaf, within-leaf offset) and compare structurally via
compare_pos. Adds panic-free IdToCursor::try_get_insert and
Tracker::compare_ids/resolve_id; a history-cleared id returns HistoryCleared.

Tests cover: strict order within one deleted RLE run, concurrent insert at a
tombstone seam (divergent oplogs + arrival-order independence),
snapshot/checkout round-trip stability, undo-resurrection transitivity, a
seeded multi-peer total-order property test, and non-panicking guards.
…ding per call

LoroDoc::compare_cursors resolved each cursor by building a fresh
DiffCalculator from the genesis version on every call, so ordering two
cursors cost O(history) — prohibitive when comparing many cursors at the
same document version.

Keep a persistent per-container richtext tracker in ContainerHistoryCache
and advance it forward on demand:

- A query at an unchanged document version reuses the warm tracker; a query
  after k new operations advances it by those k ops through the causal
  traversal instead of replaying all history.
- The advance reuses the existing per-op checkout-to-deps + insert/delete
  integration and finishes with a single checkout(head) to settle, so the
  tracker rests at head with no diff status set. It does not call
  start_tracking: the calculator is created once in Crdt mode and only ever
  moves forward, so it is never reset to a later per-container baseline nor
  switched to Linear mode.
- OpLog::compare_text_ids drives build/advance/read under the history_cache
  lock; the cache is dropped on import rollback and on reset.

The result is identical to the genesis rebuild: cursor order is the
structural position in the fugue tree, a convergent function of the applied
operations and independent of tracker fragmentation. Debug builds cross-check
sampled queries against a fresh genesis rebuild and assert a normalized
per-entity equivalence, alongside a settled-at-head invariant.

Gated behind the persistent-anchor-tracker feature (off by default). Shallow
and detached documents fall back to the existing path.
…hed tracker

get_cursor_pos resolved a deleted (tombstoned) character by building a fresh
DiffCalculator from genesis on every call — O(history) per query. Read the
seam position off the persistent per-container tracker instead: a new
Tracker::id_to_activated_index sums activated length through the b-tree cache
(O(log n)), added beside the existing O(n) walk (which its mid-diff list
callers still need).

The tombstoned-text arm of query_pos_internal now routes through
OpLog::text_id_to_pos, guarded to attached, auto-committing, full-history text
and filtered to genuine tombstones — every other input (live id, list,
detached, shallow, uncommitted txn) falls through to the untouched slow path,
so behavior is byte-identical. compare_cursors' debug length guard takes the
same auto-commit condition (state and oplog head diverge under an open
non-auto-commit txn).

Gated behind persistent-anchor-tracker (off by default). Tests: the O(log n)
readout matches the O(n) oracle over live+tombstoned multi-peer ids (incl.
within-leaf offsets), and a tombstoned get_cursor_pos fast path matches the
genesis rebuild across delete / undo-resurrection / concurrent-split /
style-mark scripts.
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.

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

1 participant