Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/movable-list-apply-diff-moves.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"loro-crdt": patch
---

`applyDiff` on a `LoroMovableList` now keeps every child container that the diff moves. Before, a diff that moved two or more children at once, such as `[{ delete: 2 }, { insert: ["🦜:" + b.id, "🦜:" + a.id] }]` to swap two `LoroText` children, could drop one of them or put elements in the wrong order. `UndoManager.undo()` and `redo()` go through the same code, and after some movable-list edits they could panic with an `OutOfBound` error.
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ Loro is a Rust CRDT workspace with JS/WASM packaging and a MoonBit codec.
- Bounded decoded-value cache in `InnerStore` (second-chance FIFO, eviction
safety contract, loro-dev/loro#1092):
[context/container-value-cache.md](context/container-value-cache.md).
- Movable-list `apply_diff`/undo: delete + re-insert of a child becomes a move,
cursor invariant, pitfalls: [context/movable-list-apply-diff.md](context/movable-list-apply-diff.md).
- Context backlog: [context/CONTEXT-GAPS.md](context/CONTEXT-GAPS.md).

## Commands
Expand Down
63 changes: 63 additions & 0 deletions context/movable-list-apply-diff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Movable List `apply_diff` and Undo

Verified against code 2026-09-28.

`MovableListHandler::apply_delta`
(`crates/loro-internal/src/handler/movable_list_apply_delta.rs`) turns a list
delta into local movable-list ops. `LoroDoc::apply_diff`, `revert_to`, and
`UndoManager` undo/redo all reach it through `LoroDoc::_apply_diff` →
`Handler::apply_diff`. Undo is only a diff: `LoroDoc::undo_internal` computes
the inverse delta by checkout and replays it through this handler.

## Contract

- An inserted container value that matches a child in a deleted range of the
same delta is applied as a move, so the child keeps its id and content. The
lookup follows `container_remap` for children recreated by an earlier
undo/apply_diff.
- Any other container value creates a new child and records
`old id -> new id` in `container_remap`. Later diffs in the batch for the old
id go to the new child.
- An insert with `from_move` whose child still exists elsewhere in the list is
skipped. Undo transforms can leave such inserts behind.
- Scalars have no identity. Deleting and re-inserting a scalar is a delete and
a new insert, even when `is_move` is set.

## Algorithm

1. Plan: collect delete ranges in original indices and decide which deleted
children are claimed by the inserts.
2. Delete unclaimed elements right to left, so original indices stay valid.
3. Walk the delta with a live `cursor`. Each inserted value goes right after
the previously placed target element. `live[cursor..]` is the unplaced
claimed children of the current delete gap, followed by the unmoved
original elements at or after the delta position. A retain skips both. A
claimed child found before the cursor was left behind by an earlier retain
and moves to `cursor - 1`.

Claimed children are located with `get_child_index` (O(log n)). Every step
costs O(log n) plus the op itself.

## Pitfalls

- `undo_internal` logs and ignores an `Err` from `_apply_diff`, but a panic
escapes, and in WASM a panic traps. Until 2026-09 this handler deferred all
deletes and tracked moved slots with index arithmetic. The arithmetic
underflowed or produced out-of-range `mov` calls behind an `unwrap()`: a
swap of two children lost one of them, and undo/redo could panic with
`OutOfBound`. Keep index bookkeeping checked by the random tests below.
- The public `ListDiffItem` → `DeltaRope` conversion merges adjacent deletes,
so Rust callers never see `delete 1, delete 2`. loro.js walks its public
delta array item by item and has to handle split deletes itself.
- Moves are not minimal. `delete 3, insert [b, c, a]` moves `b` and `c`
instead of only `a`. Deltas from `LoroDoc::diff` only move elements that
really moved, so they produce the minimal number of ops.

## Tests

- `crates/loro/tests/movable_list_apply_diff.rs`: targeted cases, a seeded
differential test against `LoroDoc::diff`, random deltas with undo/redo
across every step, and a two-peer no-panic test.
- `crates/loro-wasm/tests/movable_list.test.ts`: the JS `applyDiff` cases.
- `crates/loro/tests/perf_movable_list_apply_diff.rs` (ignored): 100k-element
timings for `apply_diff` and undo.
3 changes: 3 additions & 0 deletions crates/loro-internal/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ over graceful degradation.
before changing mergeable containers.
- `src/handler.rs`: typed container handlers, local operation creation, and
`MapHandler::ensure_mergeable_*`.
`src/handler/movable_list_apply_delta.rs` applies movable-list diffs for
`apply_diff` and undo:
[../../context/movable-list-apply-diff.md](../../context/movable-list-apply-diff.md).
- `src/diff_calc/`: diff calculation when moving between versions.
- `docs/diff_calc.md`: design notes for diff calculation.
- `docs/critical-version-spec.md`: specification and proof skeleton for
Expand Down
2 changes: 1 addition & 1 deletion crates/loro-internal/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use loro_common::{
};
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use std::{borrow::Cow, cmp::Reverse, collections::BinaryHeap, fmt::Debug, ops::Deref, sync::Arc};
use std::{borrow::Cow, fmt::Debug, ops::Deref, sync::Arc};
use tracing::{error, instrument};

pub use crate::diff::diff_impl::UpdateOptions;
Expand Down
Loading
Loading