Repository navigation
fix: restore deleted mergeable children once on revert/apply_diff/undo - #1134
Conversation
A revival diff carries the child's full state; a re-activated mergeable
child reuses its deterministic cid with the hidden state, so applying it
appended the content ("hello" -> "hellohello", 7 -> 14). `_apply_diff`
now reconciles such children against their current state instead.
Rationale: context/mergeable-containers.md ("Revert, Apply-Diff, And Undo").
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
WASM Size Report
|
Replace the apply-side full-state reconcile with a recording-side rule:
while `LoroDoc::diff` and undo record, a mergeable child re-activated by a
container that keeps its id emits its own delta instead of a full-state
revival. `apply_diff` stays incremental, so forwarded local events stay
consistent, and revert keeps char/element/TreeID identity.
Rationale: context/mergeable-containers.md ("Revert, Apply-Diff, And Undo").
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Addressed the adversarial review of ef61c31 by redesigning the fix (b57170c; main, including #1123, is merged in). What changedI dropped the apply-side reconcile (
Blocking items
Concurrency and semantics
Validation
PerformanceRelease build. Median of 5 builds of
The regular paths vary run to run on both sides; the code they run is unchanged. 🤖 Generated with Claude Code |
Restore the public `diff()`/event contract (a child that becomes visible carries its full state) and record the actual change of containers that kept their state (`KeptChange`) next to it. Revert and undo apply the changes; `apply_diff` aligns full states with the target's hidden state; the undo manager transforms against the changes; local transactions that re-activate a mergeable child now emit its full state too. Rationale: context/mergeable-containers.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…duplicate-content
|
Round-2 review of b57170c is addressed in 1605c7b. Main is merged in up to 18924fa. B1: public
|
| case | main | PR | ops main → PR |
|---|---|---|---|
| plain Text, revert 20k inserts | 2.82 / 2.77 | 2.61 / 2.56 | 20,000 → 20,000 |
| 5k regular Text children | 30.2 / 29.2 | 27.6 / 27.2 | 30,000 → 30,000 |
| 5k mergeable Text children | 33.5 / 30.2 | 14.4 / 14.3 | 30,000 → 5,000 |
| 100k-char Text, hidden equal | 0.183 / 0.217 | 0.008 / 0.009 | 100,001 → 1 |
100k-char Text, extra ! |
0.282 / 0.283 | 0.204 / 0.190 | 100,001 → 2 |
1M-char Text, extra ! |
2.78 / 2.33 | 1.75 / 2.28 | 1,000,001 → 2 |
| 100k List, hidden equal | 66.3 / 67.4 | 0.047 / 0.047 | 100,001 → 1 |
| 100k List, one extra | 65.7 / 66.2 | 0.081 / 0.085 | 100,001 → 2 |
| 100k List, one extra, revert with subscriber | 69.2 / 68.4 | 4.37 / 4.32 | 100,001 → 2 |
100k List, one extra, public diff+apply_diff |
68.1 / 69.8 | 11.0 / 10.7 | 100,001 → 2 |
100k-char Text, extra !, public diff+apply_diff |
0.283 / 0.287 | 0.621 / 0.630 | 100,001 → 2 |
The subscriber row pays for building the full-state event, which is the contract. Public apply_diff pays an O(n) alignment; for Text that is 0.6 ms against main's 0.28 ms, and main's result was duplicated content. _apply_diff only tracks per-container bookkeeping when alignment is on (public apply_diff); revert and undo skip it.
Trade-offs, also in context/mergeable-containers.md
- Aligning against a different hidden state is content-based. Identity is kept only for the shared prefix/suffix and for tree nodes by TreeID, and the edits are new ops of the applying doc.
- Counters stay additive: concurrent 10→7 reverts give 4, like a root counter.
revert_toreverts a remote hidden edit it had already received and merges one that arrives later. Undo of a delete keeps remote hidden edits in either order.
🤖 Generated with Claude Code
…duplicate-content
…apes
Local events go back to main's shapes (a re-ensure emits only the parent
marker), so mirrors that share the source's hidden state keep working.
`LoroDoc::diff` marks its batch as full-state (`DiffBatch::is_full_state`;
WASM `applyDiff(diff, { fullState: true })`), and only such batches are
aligned with the target's hidden state. Moved movable-list children are
full-state targets, style-only differences are re-marked instead of
rewritten.
Rationale: context/mergeable-containers.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Compare the two texts by bytes and walk style runs for re-marking, so aligning a 1M-char text costs a byte comparison plus the run count. Adds emoji/combining-mark/CJK alignment tests in Rust and WASM (UTF-16 units). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Round 3 is addressed with option 1: alignment is opt-in, and event shapes stay as on main. Commits 6574efd and a3bcbbc; main is merged up to 27982a5, including #1135 and #1138. Why opt-inA So:
B1: MovableList childrenOn top of #1138 (delete + re-insert of a child becomes a move), the element loss is gone. The remaining duplication ( The test covers Map/Text children × {swap, rotate, reverse, delete, insert new} × target same/absent/different hidden, plus B2: event shapesLocal events are main's again.
Round-2 (b) (source local events → a doc that shares the hidden state) still holds. Non-blocking items
Validation
PerformanceRelease build, main 27982a5 vs this PR, separate worktrees and
The 1M-char 🤖 Generated with Claude Code |
…ag use An empty batch adopts the other side's flag, two full-state batches stay full-state, mixing modes panics, and `clear` resets the flag. Document that only `diff()` results (and import/checkout events) may be marked full-state, never local events. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Round-4 follow-ups are in 5ff481a. main (27982a5) is already included; I re-fetched and it hasn't moved.
Validation:
As agreed, the #1142 interaction is left for #1142 to adapt after this merges. 🤖 Generated with Claude Code |
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tainers With #1134, a full-state batch (`LoroDoc::diff`) aligns a re-activated mergeable child with the state this doc kept for it. The pre-check looked at the raw full states, so re-activating a child that still holds an unknown container was rejected as creating it. Plan the alignment without side effects (`align_full_state` dry run) and check the edits it produces; the targets it aligns are never skipped by the apply loop, so they are always checked. Rationale: context/unknown-containers.md Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ro-dev#1142) * fix: reject creating unknown-type containers instead of panicking `apply_diff` (and `revert_to`/undo, which share `_apply_diff`) hit `unreachable!()` in `Handler::new_unattached` when a diff inserted a container whose type is unknown to this version, e.g. from JS `applyDiff` with "🦜:cid:0@1:Unknown(9)". Local ops have no rollback, so `_apply_diff` now checks the whole batch before applying anything and returns `ArgErr`; MovableList moves of existing unknown elements and mergeable map children still work. Copying a container that is or holds an unknown container via `insert_container`/`set_container` is rejected the same way. Also fixes other panics reachable with unknown containers: import or checkout of a change that creates and edits an unknown container, cursor queries on non-sequence containers, `parent()` of a child of an unknown container, and undo cursors on missing containers. Rationale: crates/loro-internal/src/state/AGENTS.md (unknown_state entry). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep apply_diff/undo atomic when they meet unknown containers Review of loro-dev#1142 found the pre-check could let a batch through that still failed half way: the apply loop recreates container values under new ids, revives mergeable children and recreates deleted tree nodes, so diffs it skipped as unreachable were applied after all. The pre-check now predicts those changes from the whole batch (fresh / revived / removed), reuses the MovableList move planner, skips diffs targeting unknown containers, and only reads the list when a move needs it. An undo/redo step that would recreate an unknown container now fails as a whole with ArgErr and is dropped, instead of being applied partially. loro-crdt event batches keep the events they can convert. Adds a get_parent regression test and a no-op Diff::compose_ref arm for unknown diffs, and unifies the error message. Rationale: context/unknown-containers.md Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: check the aligned edits of full-state apply_diff for unknown containers With loro-dev#1134, a full-state batch (`LoroDoc::diff`) aligns a re-activated mergeable child with the state this doc kept for it. The pre-check looked at the raw full states, so re-activating a child that still holds an unknown container was rejected as creating it. Plan the alignment without side effects (`align_full_state` dry run) and check the edits it produces; the targets it aligns are never skipped by the apply loop, so they are always checked. Rationale: context/unknown-containers.md Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: full-state revival with unknown containers in Rust and loro-crdt Adds the reviewer's Map/List/MovableList/tree meta cases for full-state batches, extends the random atomicity check to both full-state and incremental application, and ships a Rust-generated fixture for the WASM tests (JSON updates can't carry the binary mergeable markers). JS `doc.diff()` leaves out the empty entries of unknown containers instead of throwing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: unknown containers in full-state batches and loro-crdt diff() Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: benchmark full-state revival with and without unknown containers Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: plan full-state alignment once and keep undo after a rejected step right Review round 2 of loro-dev#1142 found three ways the unknown container check and the apply loop disagreed: - B1: the full-state plan resolved ids with the initial remap only, so the mergeable child of a container recreated by the batch was aligned against its old hidden state and accepted, while the loop aligned it against a new, empty child and failed half way. The plan now treats containers the loop may recreate (and their mergeable children) as new. - B3: the skip prediction built the ancestor chain from the visible path, which is empty below a hidden mergeable container. It now follows arena parents. - B2: a rejected undo step already rebased the other steps over itself. The rebase now waits until the step is accepted; a rejected step's changes are composed into the remote diff of its row instead. The loop now applies the edits planned for full-state entries instead of aligning them again, and asserts in debug builds that it never applies an entry the check predicted to be skipped or checked differently. `align_full_state` has no side effects any more. Rationale: context/unknown-containers.md Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: twin oracle for unknown containers and the undo-after-rejection oracle Random edits on a doc holding unknown containers, compared with a twin holding Counters; atomicity alone couldn't see an Ok with a wrong state. Seeds 0-2999 pass (60 run by default). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * chore: keep an unrelated undo.rs line as formatted on main Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: JS reproductions of the second loro-dev#1142 review; update unknown container docs Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * perf: plan full-state alignment only for batches holding unknown containers Batches without unknown values are aligned by the loop as before, so the plan adds nothing to them. Adds the review's benchmarks with many small full-state targets. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: check mergeable children where the loop writes them, and skip-aware planning Third review of loro-dev#1142: - R3-1/R3-2: the check keyed a mergeable child by the value's id, while the loop writes the marker of `mergeable(parent, key)` and remaps the value to it. With a parent the undo manager had already remapped (or a value under another key) the check looked into the old hidden child and accepted a move of U that the loop turned into a creation: undo returned Ok(true) half applied. Such a child is now checked as new and empty. - R3-3: the full-state plan counted marker writes of an entry the loop skips (its target unreachable before the batch, in a reordered batch). - `_apply_diff` asserts in debug builds that an accepted batch never fails on creating an unknown container. Prototype by the reviewer. Rationale: context/unknown-containers.md Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: third-review reproductions and the collaboration twin oracle Adds the R3-1 (undo into a host the undo manager recreated: list element, tree meta, nested), R3-2 (mergeable value under another key) and R3-3 (reordered full-state batch) reproductions, a test pinning that undo after a rejected move behaves like after a remote move, and the reviewer's oracle with a remote peer, interleaved undo/redo, reordered batches and 3-level mergeable chains (60 seeds by default; 0-2999 pass with debug assertions). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: JS reproduction of undo into a recreated host; document rebase limits of undo A rejected undo step's edits are rebased over like a remote peer's, with the same limits (a move of an element an earlier step inserted). Documented in the undo docs, the changeset and context/unknown-containers.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ith Rust #1134 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d revertTo() (#1131) * fix(loro.js): include reattached child state in diff() and revertTo() A child container without ops in the range was emitted only as a reference, so applyDiff/revertTo recreated it empty. Emit its full state, as Rust does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): emit full state only for containers that become reachable in diff() Emit a child's whole state only when it is unreachable at `from`, replace a partial diff for such a child, include revived tree node metadata, and order the result parent-first. Moved movable-list children and mergeable children keep only their own ops. applyDiff now moves an existing movable-list child instead of recreating it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): apply movable-list moves minimally, undo them, and revive tree subtrees in diff() Port Rust's MovableListHandler::apply_delta so a moved child becomes one move instead of reordering the whole list, undo movable-list moves through their recorded neighbors, and emit creates for every node that becomes reachable, including descendants of revived nodes and nodes moved out of a deleted ancestor. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): read a trimmed tree node's placement from the shallow root in diff() On a shallow doc a node created before the root has no retained op at the root version; use its root-state placement instead of treating it as revived. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): resolve movable-list moves by element identity, undo repeated moves, and restore mergeable markers on undo applyDiff now finds moved children through their inserted ids and places every inserted item after its final-order predecessor, so unmerged delete items, several children moved out of one range, and scalar-only deletes are handled without index bookkeeping or scans. Undo reverts earlier moves of an element that only this document moved since. A wide revived subtree is walked iteratively. Undoing a mergeable key deletion re-ensures the marker. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): delete a moved-in live node before recreating a revived subtree in diff() A node alive at `from` inside a subtree revived by the range (e.g. a child moved out of a deleted parent and then reverted back under it) was created again while its old copy stayed. Delete the topmost such nodes first, as Rust's diff does, then create the subtree. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): tracked-span move undo, exact physical undo placement, delta validation, and linear tree revival - Undo only lets later moves the UndoManager tracks (its recorded edits and the inverses it wrote) pass; excluded-origin and remote moves block. - Each move records its physical successor; undo puts the element back there, so redo after mixed moves and inserts restores the original order. - applyDiff validates list deltas for the whole batch before applying, hydrates lazily imported lists before looking for moved children, and deletes only the gaps between kept positions. - Tree revival memoizes "alive at from" along ancestor paths and emits the deletes of moved-in live nodes first, with their index at `from`. - Undo restores a mergeable marker over a later overwrite. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): undo a move next to its old physical neighbor that has not moved since The old successor may have been moved by an untracked op; prefer the old predecessor, then the successor, and write no op when the element is already there. Matches Rust in 70 of 72 neighbor-change cases (the other two: Rust revives a deleted element as an empty container). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): bound the undo manager's tracked ranges by its stacks Drop tracked ranges that end before the earliest stacked op of their peer, and all of them on clear/destroy; only later moves are ever checked. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): compute tree delete indexes at `from` once per parent, in apply order Put the diff's own deletes and the moved-in deletes first, resolve each parent's sibling order at `from` from history (nodes that only exist at `from` included), and count indexes with a Fenwick tree as the deletes are applied. Fixes indexes next to new roots and the quadratic rescans. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): validate an applyDiff batch against simulated list lengths Simulate list lengths in batch order before any write: lists created by an earlier entry start empty, repeated entries see the previous result, and the lookup creates nothing (no empty root after a rejected batch). Entries whose bounds were checked skip the second validation. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): align mergeable diff, applyDiff fullState, and events with Rust #1134 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: point the mergeable context at the loro.js alignment Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): validate a hidden mergeable list against its hidden length Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loro.js): prune undo tracked ranges from the stack bottoms by lamport Pruning visited both stacks on every commit (quadratic sessions), and compared a peer's ranges only with that peer's items, so after a peer change it could drop a range a stacked move still needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loro.js): emit tree diffs in apply order and reject tree items before writing Tree diff items now apply in order, as in Rust: moves of live nodes (parent first), then the topmost deletes, then revived subtrees, with every index counted in the tree the earlier items leave. applyDiff applies tree items in order and dry-runs them with the list checks, so a failing batch writes nothing. A node hidden at the target version is emitted as a delete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(loro.js): memoize hidden tree nodes so deep trees diff in linear time The reachability filter and tree revival checked each node's ancestors without a cache (quadratic in depth). Fractional indexes are now built as flat strings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loro.js): resolve applyDiff tree items and map children as Rust does A create of a node that is alive here moves it, a move of a missing or deleted node creates a new one, and a delete of a deleted node does nothing, so default applyDiff on a doc holding a mergeable tree's hidden state no longer duplicates its nodes. A regular map child is always set as a new container, so its full state is not appended to a hidden copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(loro.js): document fullState for event mirrors and the batch checks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: add the loro.js tree diff context Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(loro.js): check deep-tree linearity by scaling, not wall time The CI runner is several times slower than a dev machine, so the 1.5 s bound flaked; compare 8,000-deep with 2,000-deep instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(loro.js): shrink the deep-tree scaling test for slow CI runners Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…erts (#1150) * fix(loro.js): include reattached child state in diff() and revertTo() A child container without ops in the range was emitted only as a reference, so applyDiff/revertTo recreated it empty. Emit its full state, as Rust does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): emit full state only for containers that become reachable in diff() Emit a child's whole state only when it is unreachable at `from`, replace a partial diff for such a child, include revived tree node metadata, and order the result parent-first. Moved movable-list children and mergeable children keep only their own ops. applyDiff now moves an existing movable-list child instead of recreating it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): apply movable-list moves minimally, undo them, and revive tree subtrees in diff() Port Rust's MovableListHandler::apply_delta so a moved child becomes one move instead of reordering the whole list, undo movable-list moves through their recorded neighbors, and emit creates for every node that becomes reachable, including descendants of revived nodes and nodes moved out of a deleted ancestor. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): read a trimmed tree node's placement from the shallow root in diff() On a shallow doc a node created before the root has no retained op at the root version; use its root-state placement instead of treating it as revived. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): resolve movable-list moves by element identity, undo repeated moves, and restore mergeable markers on undo applyDiff now finds moved children through their inserted ids and places every inserted item after its final-order predecessor, so unmerged delete items, several children moved out of one range, and scalar-only deletes are handled without index bookkeeping or scans. Undo reverts earlier moves of an element that only this document moved since. A wide revived subtree is walked iteratively. Undoing a mergeable key deletion re-ensures the marker. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): delete a moved-in live node before recreating a revived subtree in diff() A node alive at `from` inside a subtree revived by the range (e.g. a child moved out of a deleted parent and then reverted back under it) was created again while its old copy stayed. Delete the topmost such nodes first, as Rust's diff does, then create the subtree. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): tracked-span move undo, exact physical undo placement, delta validation, and linear tree revival - Undo only lets later moves the UndoManager tracks (its recorded edits and the inverses it wrote) pass; excluded-origin and remote moves block. - Each move records its physical successor; undo puts the element back there, so redo after mixed moves and inserts restores the original order. - applyDiff validates list deltas for the whole batch before applying, hydrates lazily imported lists before looking for moved children, and deletes only the gaps between kept positions. - Tree revival memoizes "alive at from" along ancestor paths and emits the deletes of moved-in live nodes first, with their index at `from`. - Undo restores a mergeable marker over a later overwrite. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): send the full state of child containers attached by an event When a checkout or import attached a child container again (a map key set back to an older text, a restored list element), the child's event was missing or was a delta against the state it had while hidden. A listener starts an attached child from empty, so it ended up with wrong content, or with an invalid delta ("text span beyond 0"). Like Rust's DocState::apply_diff, revive every child that a batch attaches: its event becomes its whole state, recursively, and events are ordered parent first. A movable-list child that only moved keeps its delta. Uses #attachedChildren and #reachableThroughParentAt from the diff() fix this branch is based on. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): undo a move next to its old physical neighbor that has not moved since The old successor may have been moved by an untracked op; prefer the old predecessor, then the successor, and write no op when the element is already there. Matches Rust in 70 of 72 neighbor-change cases (the other two: Rust revives a deleted element as an empty container). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): bound the undo manager's tracked ranges by its stacks Drop tracked ranges that end before the earliest stacked op of their peer, and all of them on clear/destroy; only later moves are ever checked. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): compute tree delete indexes at `from` once per parent, in apply order Put the diff's own deletes and the moved-in deletes first, resolve each parent's sibling order at `from` from history (nodes that only exist at `from` included), and count indexes with a Fenwick tree as the deletes are applied. Fixes indexes next to new roots and the quadratic rescans. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): validate an applyDiff batch against simulated list lengths Simulate list lengths in batch order before any write: lists created by an earlier entry start empty, repeated entries see the previous result, and the lookup creates nothing (no empty root after a rejected batch). Entries whose bounds were checked skip the second validation. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): align mergeable diff, applyDiff fullState, and events with Rust #1134 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: point the mergeable context at the loro.js alignment Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): validate a hidden mergeable list against its hidden length Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loro.js): prune undo tracked ranges from the stack bottoms by lamport Pruning visited both stacks on every commit (quadratic sessions), and compared a peer's ranges only with that peer's items, so after a peer change it could drop a range a stacked move still needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loro.js): emit tree diffs in apply order and reject tree items before writing Tree diff items now apply in order, as in Rust: moves of live nodes (parent first), then the topmost deletes, then revived subtrees, with every index counted in the tree the earlier items leave. applyDiff applies tree items in order and dry-runs them with the list checks, so a failing batch writes nothing. A node hidden at the target version is emitted as a delete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(loro.js): memoize hidden tree nodes so deep trees diff in linear time The reachability filter and tree revival checked each node's ancestors without a cache (quadratic in depth). Fractional indexes are now built as flat strings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loro.js): resolve applyDiff tree items and map children as Rust does A create of a node that is alive here moves it, a move of a missing or deleted node creates a new one, and a delete of a deleted node does nothing, so default applyDiff on a doc holding a mergeable tree's hidden state no longer duplicates its nodes. A regular map child is always set as a new container, so its full state is not appended to a hidden copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(loro.js): document fullState for event mirrors and the batch checks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: add the loro.js tree diff context Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(loro.js): check deep-tree linearity by scaling, not wall time The CI runner is several times slower than a dev machine, so the 1.5 s bound flaked; compare 8,000-deep with 2,000-deep instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(loro.js): shrink the deep-tree scaling test for slow CI runners Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Summary
After a mergeable child's map key is deleted, going back to a version where the child was visible added its content a second time.
revertToturned"hello"into"hellohello", a Counter 7 into 14, and['keep']into['keep','keep']. The same happened for nested mergeable containers, in undo/redo, and indiff(a,b)+applyDiffon the same doc.Root cause
A mergeable child that becomes visible again keeps its deterministic cid and its hidden state. Revival reports its full state, and a doc that kept the hidden state applied that full state on top of it.
Contract (backward compatible)
diff()or a snapshot.applyDiffis incremental by default, as on main.diff()results. Rust:LoroDoc::diffmarks its batch (DiffBatch::is_full_state/set_full_state), andapply_diffaligns only such batches. JS:applyDiff(diff, { fullState: true })(ApplyDiffOptions). A re-activated mergeable child's full state is then aligned with whatever the target holds at its cid (none, the same, or different):diff()to a doc that may keep the deleted child (the same doc, a fork, another history). Without it, such a doc gets main's behavior (appended). A doc without hidden state gets the same result either way.KeptChangenext to revival full states;DiffBatch::from_changes). They write only the real difference and keep char, element and TreeID identity.Known limitations
These are documented in
context/mergeable-containers.md:revert_totreats remote hidden edits like a root container would; undo of a delete keeps them.Validation
crates/loro/tests/mergeable_revert.rs(33 tests, 1 of them an ignored perf test). It covers every reproduction from the three review rounds, including reordered MovableList/List children, style-only restyle with a concurrent edit, emoji/UTF-16, re-ensure event mirroring, and cross-peer undo.mergeable.test.ts: 17 tests.diff()matrix (6 kinds × source hidden same/modified × target absent/same/different): 36/36 withfullState; 12/12 on the default path for targets without hidden state.cargo test -p loro -p loro-internal: 1223 passed.pnpm checkis clean. WASM: 393 passed; CJS, tsc, deno and bun pass.The performance table is in the PR comment.
🤖 Generated with Claude Code