Skip to content

fix(loro.js): include reattached child containers' state in diff() and revertTo() - #1131

Merged
zxch3n merged 29 commits into
mainfrom
fix/loro-js-diff-revived-child-content
Sep 29, 2026
Merged

zxch3n merged 29 commits into
mainfrom
fix/loro-js-diff-revived-child-content

Conversation

@zxch3n

@zxch3n zxch3n commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

Summary

Found while porting the Rust #1124 test fixture (shallow_checkout_equal_value.rs, revert = true) to loro.js.

Its map.sub is a child map {"k":"v"} that gets replaced by a string after the root, followed by revert_to(root). Rust restores {"k":"v"}; loro.js restores {}.

Root cause

LoroDoc.diff(from, to) only emits diffs for containers that have ops in the range. When the range attaches a child container again without editing it, for example a map key set back to an older child, the parent diff references the child, but the child's own state is never emitted. applyDiff, and therefore revertTo, then recreates the child empty.

Rust's diff for the same scenario emits the parent entry, the child map with {k: "v", text: <Text>}, and the nested Text with insert "hi". loro.js now emits the same three entries.

Fix

After the changed-container diffs are built, walk the child containers attached by map/list diffs. Every child that has no entry yet gets a full-state diff (containerDiff(child, undefined)), and this recurses into nested children. Parent-before-child order is kept. The extra work is proportional to the attached children's content, which is part of the returned diff anyway.

Validation

  • New test in loro-js/tests/runtime.test.ts ("reverts child containers whose content did not change in the range"): the diff includes the child map and its nested Text, and revertTo restores a map child and a list child, including a nested Text. It fails on main because the child has no diff entry.
  • Rust main for the same scenario: diff yields the same three entries, and revert_to gives {"map":{"sub":{"k":"v","text":"hi"}}}.
  • pnpm --dir loro-js test (17 files, 241 tests), typecheck, lint, format:check all pass.
  • bench:complexity 1000,2000,4000,8000 back to back against origin/main: one entry is above 1.5x, history-concurrent-movable-mixed-switch-subscribed@2000 at 0.345 → 0.526 ms, which does not call diff(). history-tail-diff stays at 0.12–0.40 ms. A first run under machine load showed broad slowdowns in unrelated paths (commit, import, toJSON), and they did not reproduce.

Related: #1124 (the fixture that exposed it).

Current state (after review rounds 1–6, aligned with merged Rust #1134)

  • diff() returns the full state. A container that is unreachable at from and reachable at to carries its whole state:

    • map/list re-attachment;
    • tree creates for revived nodes with their whole subtree and metadata, including nodes moved out of a deleted ancestor;
    • re-activated mergeable children.

    Entries are in parent-first order. Containers that are unreachable at to (deleted, under a deleted tree node, or a hidden mergeable child) have no entry, as in Rust.

  • Tree diffs are sequences, as in Rust. Items come in this order:

    1. moves of live nodes, parent first;
    2. the topmost deletes;
    3. the revived subtrees.

    Each index is counted in the tree the earlier items leave, in O(changed · log n) regardless of the sibling count. A node hidden at to is reported as deleted. applyDiff applies tree items in order and resolves them as Rust's apply_diff does: a create of a live node moves it, a move of a missing or deleted node creates one, and a delete of a deleted node does nothing. Details: context/loro-js-tree-diff.md.

  • applyDiff is incremental by default. applyDiff(diff, { fullState: true }) aligns each re-activated mergeable child with the receiver's state:

    • maps per key;
    • counters by the difference;
    • text and lists by common prefix and suffix, with style differences fixed by mark/unmark;
    • trees by TreeID.

    A full-state target with no entry is cleared. revertTo and undo apply only the actual change, so identity is kept. A regular map child is always set as a new container, as in Rust.

  • Events match Rust main.

    • A local re-ensure emits only the parent marker.
    • Import and checkout events carry the full state of a re-activated child. An event mirror should therefore pass { fullState: true } for import/checkout batches, as with loro-crdt; with the default, it applies that full state on top of its hidden copy. This is a behaviour change from loro.js main, noted in the changeset.
    • Undo of a delete only restores visibility.
  • Batch checks before writing. Before writing anything, applyDiff checks:

    • every list delta, against the lengths the batch produces (a hidden mergeable list uses its hidden length);
    • every tree item, in a dry run.

    An out-of-range list delta or a tree item that cannot apply rejects the whole batch. Other malformed entries (text ranges, counter values, type mismatches) can still leave earlier entries applied, as before and as in Rust.

  • Undo reverts MovableList moves. The element goes next to its old physical neighbour if that neighbour has not moved since. The UndoManager prunes its tracked ranges from the stack bottoms by lamport, so a commit costs the same as on main whatever maxUndoSteps is.

Known differences:

  • Undoing a MovableList move of an element deleted afterwards (by an excluded origin or a remote peer): Rust revives the deleted child as an empty Text; loro.js keeps it deleted. This is 2 of the 72 cases in the B1 matrix.
  • A remote move concurrent with an insert: undoing a move writes a real move op. loro.js still places a remote move by its index instead of its causal position, so the two can diverge from Rust. main shows the same divergence for any such move ([e0,e1]: A move(0,1) ‖ B insert(0,"b")). The MovableList model rework (test(loro.js): add a Rust differential suite for MovableList #1132) fixes it.
  • Tree placement: Rust places a node by its fractional index, loro.js by its index. They differ only when positions are equal (concurrent creates), where loro.js reproduces the source order. It also places items differently on a receiver whose other siblings differ (default apply with a different hidden mergeable tree: 2 of 96 matrix items).

The review responses and measurements are in the PR comments.

🤖 Generated with Claude Code

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>
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

WASM Size Report

  • Original size: 3277.59 KB
  • Gzipped size: 1089.33 KB
  • Brotli size: 762.83 KB

…le 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>

zxch3n commented Sep 27, 2026

Copy link
Copy Markdown
Member Author

Addressed all five P1s from the adversarial review in 08840f4.

New rule. diff(from, to) includes a container's whole state only when the container is unreachable at from and reachable at to. This is decided top-down in depth order:

  • A child of a newly reachable container is also newly reachable, because applyDiff recreates that parent under a new id.
  • Otherwise, a mergeable child keeps its identity: re-ensuring the marker resurfaces its preserved state. It gets only its own diff.
  • Otherwise, the child is newly reachable when the parent's diff attaches it and it was not in that slot at from. For Map, a changed key always means the child was not there. For List and MovableList, #reachableThroughParentAt checks the element at from: inserted, not deleted (someDeletion), and the value winner (valueHistory) is this child. So a moved child counts as already reachable. For Tree, create items count and move items do not.
  • A newly reachable child replaces any partial diff it already had.
  • The final batch is sorted by depth after all additions.

applyDiff consumer. A movable-list delta reports a moved child as a delete plus an insert of the same container. #applyMovableListMoves now applies that pair as a move of the existing element instead of creating an empty copy, which is what Rust does. Deltas without such a pair still take the O(delta) path.

Review item Result
1. Moved live child emitted full content; Rust applying the diff gave xxxx→xxxxxxxx The diff is now only [cid:root-l:MovableList], the same as Rust. Rust/WASM applying it keeps xxxx. A TS replica applying it keeps xxxx.
2. Mergeable revert doubled content Text hello, Counter 7, List/MovableList ['keep'], and Map are restored once. Rust's own revert_to still duplicates; Rust applying the loro.js diff reaches the target.
3. A child already in entries kept only its partial diff Map, List, and MovableList parents all restore {a:1,b:2}.
4. Parent was added after its child Order is root → cid:0@1:Map → cid:1@1:Map, the same as Rust, and g is restored in full.
5. Tree meta was not restored meta:{name:'keep',text:'hello'} is restored, with diff entries Tree → meta Map → Text, the same as Rust.

Each item has a regression test in the new loro-js/tests/diff-attached-children.test.ts (7 tests; 5 fail on the old head c7010b2).

Cross-check with the Rust main nodejs WASM build, for 8 scenarios built identically in both runtimes:

  • revertTo reaches the target version in loro.js for all 8. Tree nodes are compared without node ids, since revert creates new ones. Rust reaches it for all but mergeable.
  • Rust applyDiff of the loro.js diff on a replica at the latest version reaches the target for all 8.
  • The container list and order of the diff match Rust for all 7 non-mergeable scenarios. For mergeable, Rust also emits the child containers, and that is what causes its duplication.

Move-diff performance: MovableList [Text(N chars), "tail"], move the Text, time diff(latest, before) only; median of 11 runs, 3 rounds (ms):

N main old head c7010b2 08840f4
1k 0.037 / 0.032 / 0.036 0.216 / 0.212 / 0.215 0.038 / 0.038 / 0.039
10k 0.018 / 0.018 / 0.017 0.529 / 0.499 / 0.492 0.019 / 0.020 / 0.020
100k 0.019 / 0.019 / 0.017 6.83 / 6.51 / 6.34 0.021 / 0.019 / 0.022
1M 0.020 / 0.020 / 0.016 126.0 / 105.5 / 104.2 0.021 / 0.018 / 0.018

Restoring a deleted Text child is O(returned content): 1k 0.58 ms, 100k 13.5 ms, 1M 114 ms.

bench:complexity 1000,2000,4000,8000 against current main: no entry is slower than 1.5x.

Checks: pnpm --dir loro-js test (18 files, 248 tests), typecheck, lint, and format:check all pass. On the combined tree (main with #1129 + #1126→#1127→#1128 + #1130 + this PR), there are no conflicts and 22 files / 284 tests pass. The changeset is updated.

zxch3n and others added 2 commits September 28, 2026 02:56
…ve 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>

zxch3n commented Sep 27, 2026 •

Copy link
Copy Markdown
Member Author

Round 2 addressed in f6f28cd; origin/main (106d9e5, with #1123 and #1130) is merged into the branch (8347878).

B2: one move became N−1 moves. #applyMovableListMoves is now a direct port of Rust's MovableListHandler::apply_delta (handler/movable_list_apply_delta.rs): it preprocesses containers in the deleted ranges, turns each delete + re-insert of the same container into one mov, and runs the remaining deletions at the end, skipping slots already vacated by moves. When no deleted range holds a child container, it returns before doing any work, so an insert-only delta no longer scans the whole list.

B1: undo of an applied move. #undoOperation handled movable-list-move as a no-op. It now moves the element back next to the neighbour recorded in the move's beforePrevious/beforeNext, as long as that move is still the element's winning position. Redo works again because the undo writes a real op.

B3: tree reachability through ancestors. Before the reachability pass, #completeTreeRevivals rewrites each changed tree's items. A node that is alive at to but not at from is turned into a create. "Alive at from" walks the node's ancestors through tree op history at from. A move out of a deleted ancestor is such a case. Each of these nodes is followed by parent-first creates for its whole alive subtree, so the metadata of every created node is restored in full.

Cross-check with Rust/WASM. The WASM build is from this branch's tree (Rust main, including #1123). Each result is shown as loro.js | Rust:

Case Result
B1: move to end, then undo/redo [0..4,"xxxx"] with 1 op, undo gives ["xxxx",0..4], redo returns true (identical in both)
B2: concurrent move(3,0) of C, Rust receiver, mover = loro.js / Rust ["C","A","B","text"] / ["C","A","B","text"]
B3a: delete parent, then revert tree actions create, create; restored child meta {k:"v"}. Rust applying the loro.js diff gives the same
B3b: move a child out of a deleted ancestor, Rust applies the diff at from create, meta {merge:"keep",text:"hello"}
Earlier 8 scenarios loro.js revert reaches the target in 8/8; Rust applying the loro.js diff reaches it in 8/8; containers and order match Rust in 7/7 non-mergeable cases

New tests in diff-attached-children.test.ts: a one-op undoable move in both directions, the concurrent-merge order, a revived parent subtree (3 levels, with meta and nested Text), and a node moved out of a deleted ancestor. The file has 11 tests.

applyDiff on a MovableList (median of 5 runs):

Case main (wrong: empty copy) old 08840f4 f6f28cd
move-to-end @1k 0.249 ms, 2 ops, 102 B 6.72 ms, 999 ops, 8,829 B 0.294 ms, 1 op, 86 B
move-to-end @10k 0.101 ms, 2 ops 66.0 ms, 9,999 ops, 89,836 B 0.697 ms, 1 op, 87 B
move-to-end @100k 0.105 ms, 2 ops 896 ms, 99,999 ops, 1,067,077 B 4.15 ms, 1 op, 89 B
move-tail-one @100k 0.148 ms 726 ms, 99,998 ops 4.34 ms, 1 op
insert-tail @100k 0.062 ms 1.78 ms 0.050 ms

The remaining 4 ms at 100k is the cost of one plain LoroMovableList.move(0, n-1): 4.03 ms here and 3.81 ms on main. That is a pre-existing constant of the move operation, not of this path.

diff() now skips the reachability and sorting pass unless an entry attaches a child container. history-tail-diff@8000 over two back-to-back runs: main 0.093/0.093 ms, this PR 0.098/0.110 ms. bench:complexity 1000–8000: the second run has no entry slower than 1.5x, and the first run's two flagged entries (a subscribed text edit and unrelated-container dispatch) don't use diff/applyDiff.

Mergeable vs. #1134. (Edited: this paragraph described an earlier #1134 design that has since been dropped. See the round-3 comment below.) #1134 (b57170c) keeps apply_diff incremental. LoroDoc::diff and undo record a mergeable child re-activated under a parent that keeps its id as a delta relative to its hidden state, or nothing. This PR uses the same rule, and the diff shapes agree with #1134 for mergeable children. #1134 is still in review; this PR will follow its final design.

Checks: loro-js 19 files, 256 tests; typecheck, lint, format:check, and build all pass. The changeset is updated.

…oot 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>

zxch3n commented Sep 27, 2026

Copy link
Copy Markdown
Member Author

Addendum: 784f8e7.

Running the combined tree (main + #1126→#1127→#1128 + this PR + #1133) exposed an interaction. On a shallow doc, a node created in the shallow root commit has no retained op at the root version. #treeNodeAliveAt then treated it as dead and turned an ordinary tree move into a revival create. That broke 6 of #1127's shallow-root-map-winner diff tests in the combination.

The fix: when a node has retained history but no op at the version, its placement is taken from the shallow root state (#shallowRootTreePlacement, indexed per tree on first use). A new test checks that a move of a node that stays alive is reported as a move. The shallow variant depends on #1127's retreat fix; #1127's tests cover it in the combination.

Combined tree: 22 files / 292 tests pass; format, lint, typecheck, and build pass. It has two trivial conflicts:

The Rust/WASM cross-checks on the combined tree match Rust:

  • B1: 1 op, undo restores, redo is true.
  • B2: the Rust receiver gives ["C","A","B","text"] for both movers.
  • B3: the revived subtree's meta and the node moved out of a deleted ancestor match.
  • Earlier 8 scenarios: 8/8.

This branch: 19 files / 257 tests pass.

zxch3n and others added 2 commits September 28, 2026 03:55
…peated 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>

zxch3n commented Sep 27, 2026

Copy link
Copy Markdown
Member Author

Round 3 addressed in e7415e2; origin/main (18924fa, with #1133 and Rust #1124) is merged into the branch (403bf67).

B2/B3/N2: movable-list applyDiff. I replaced the index-bookkeeping port with an element-identity algorithm:

  1. Deleted ranges are collected in original indices; adjacent delete items are merged here, so unmerged input like delete 1, delete 2 works.
  2. Moved children are found only through the inserted container ids (child → binding → element → index in a deleted range). No deleted range is scanned. With no candidate, it returns before doing any work, so the O(delta) path is used again for scalar-only deletes.
  3. The other deleted elements are removed back to front.
  4. Each inserted item is placed right after its predecessor in the final order, which is either a retained element or an already-placed item. A moved child gets one move, skipped if it already sits there; anything else is inserted.

So several children moved out of one range work, and unplaced moved elements never shift the target indices.

B1: undo of repeated moves. Undo of a move now finds that op's entry in the element's move history. Moves made later by this document do not block it; these include the inverse ops written while undoing a later move in the same group. A later remote move still blocks it, as before.

B4: wide subtree. The revived subtree is walked with an explicit stack in preorder, with child indices taken from the traversal. There is no argument spread and no shift/unshift.

N1: mergeable undo. Undoing a deleted mergeable key now re-ensures the marker (ensureMergeableChild) instead of copying the child into a normal container. The child keeps its deterministic cid, so a remote edit made while it was hidden is kept in either arrival order. Redo deletes the marker again.

Cross-check with Rust/WASM (dev nodejs builds of the current main and #1134 b57170cf):

Case loro.js Rust
B1, both moves in one step undo [X,A,B,C], redo [A,X,B,C] same
B1, one step per move [A,B,C,X] → [X,A,B,C], redo [A,B,C,X] same
B1, two direct move()s undo [X,A,B,C] same
B2, delete 1, delete 2, insert ["new"], retain 1, insert child ["new","tail","T"] same
B2 from a real Rust diff (set(0,…); move(2,3); move(1,4), prefix delete 1, delete 2, insert) reaches the target same
B3, [A,B] + delete 2, insert [B,A] ["B","A"], with both children keeping their ids Rust main gives ["A"] (the known upstream bug; judged by correct semantics as requested)
B4, 150k direct children, reverse diff 150,001 creates 150,001
N1, undo-first / remote-first hello!, cid cid:root-🤝:$m>s:Text, redo {} #1134: identical in both orders
Earlier 8 scenarios 8/8 against main; 8/8 against #1134, including identical diff containers for the mergeable case

New tests in diff-attached-children.test.ts (5 of the 6 fail on 784f8e7):

  • repeated-move undo, three ways (one step, one step per move, direct moves);
  • unmerged delete items;
  • two children moved out of one range (3 ops: delete, one move, insert — the other child ends up in place);
  • a 150k-wide subtree (passes on the old head under vitest's larger stack; the plain-node overflow is measured below);
  • mergeable undo identity in both arrival orders.

MovableList applyDiff, median of 5 (ms / ops / bytes). main is wrong for the move cases (empty copy); old head 784f8e7:

Case main old e7415e2
move-to-end @100k 0.067–0.085 / 2 / 105 3.6–5.8 / 1 / 89 3.6–4.9 / 1 / 89
move-tail-one @100k 0.093 / 2 / 103 3.5–5.6 / 1 / 87 3.8–3.9 / 1 / 87
insert-tail @100k 0.038–0.041 0.041–0.052 0.045–0.048
delete-scalars @100k 0.056–0.064 4.9–5.6 0.065–0.070
delete-scalars @10k 0.040–0.049 0.66–0.90 0.052–0.072

The ~4 ms at 100k is the constant of one LoroMovableList.move in loro.js. A direct move() costs the same on main (3.8–4.0 ms), and the review measured 3.4 ms.

Wide subtree revival, plain node, median of 3 (ms):

Children old e7415e2
1k 2.1 2.4
10k 27.7 18.6
100k 4,220 856
150k RangeError 1,661 (150,001 creates)

The remaining superlinear part is the output itself. With 100k appended siblings the fractional-index strings grow, and bytesToHex of the returned fractionalIndex values dominates the profile. Rust returns the same strings.

bench:complexity 1000–8000, 3 runs against main: each run flags different entries (1–5, none on the diff/apply path), so they are noise. history-tail-diff@8000 is 0.105 on main vs 0.115 ms here.

Checks: this branch passes 19 files / 265 tests, with typecheck, lint, format:check, and build. The 150k test has a 60 s timeout because it builds 150k nodes under parallel test load.

Combined tree: main + current chain heads (#1126 e56d37a → #1127 5412537 → #1128 0d358cb) + this PR. There is one trivial field conflict with #1127 in document.ts (keep both). It passes 22 files / 310 tests, with format, lint, typecheck, and build. The 8-scenario cross-check on the combined build is 8/8 against main and against #1134.

The PR description and my round-2 comment no longer describe the old #1134 design. Mergeable handling follows #1134 b57170c (delta relative to the hidden state; apply_diff incremental). #1134 is still in review, and this PR will follow its final design.

… 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>

zxch3n commented Sep 27, 2026

Copy link
Copy Markdown
Member Author

Fixed the P1 from the #1126–#1128 combined review ("#1131 duplicates a child that was already alive") in d8cb391.

Cause. In #completeTreeRevivals, a revived parent's subtree was walked, and every node in it was emitted as a create. Nodes that were already alive at from were included, for example the child moved out of the deleted parent. Their own move item was dropped (insideRevived), so the old copy stayed: after the revert the new parent had a new child, and the old child was still at the root with later:1.

Fix. Within a revived subtree, the nodes alive at from are found with #treeNodeAliveAt, walking ancestors through tree history. A delete is emitted for the topmost such nodes, where the parent at from is not also such a node, before the subtree's creates. This is the same shape as Rust's own diff: delete old c, create p, create c. The recreated child carries the target version's full meta. A new helper, #treeNodePlacementAt (a node's own placement at a version, including the shallow-root fallback), now also backs #treeNodeAliveAt.

Cross-check with Rust/WASM (main). Scenario: delete the parent, move the child to the root, write later:1, then revert to before the delete. The second variant also has a grandchild with a Text meta:

Tree actions loro.js revertTo Rust applies the loro.js diff to latest
loro.js, child only delete, create, create 1 tree, 1 child, meta {k:"v"} reaches the target
Rust, child only delete, create, create same same
loro.js, with grandchild delete, create, create, create reaches the target (grandchild Text kept) reaches the target
Rust, with grandchild delete, create, create, create same same

On e7415e2 the diff was create, create: the child was duplicated in loro.js, and Rust kept the stale later:1.

The new test is "revives a parent whose child stayed alive elsewhere without duplicating it", with and without a grandchild. It checks the action sequence, revertTo, and applyDiff on a fork. It fails on e7415e2.

Checks. This branch: 19 files / 266 tests; typecheck, lint, format:check, and build all pass. The 8-scenario Rust cross-check is 8/8. Combined tree (main 18924fa + #1126 e56d37a → #1127 5412537 → #1128 0d358cb + this PR, with the known trivial field conflict with #1127): 22 files / 311 tests pass with all checks, and the repro on the combined build matches Rust.

Performance. The only added work is an alive-at-from check for each node of a revived subtree. It stops at the first deleted ancestor, so it is cheap in the usual case where the whole subtree was under the deleted node. The wide-subtree benchmark (one revived parent with N direct children) was very noisy on this machine. Over 3 alternating rounds, old (e7415e2) and new overlap at every size: 100k is old 1.1–2.7 s vs new 0.93–2.0 s, and 150k is old 1.9–4.4 s vs new 2.1–5.5 s. The profile is dominated by bytesToHex of the returned fractional indexes and by GC, as before.

Mergeable alignment still waits for #1134's final design (now 1605c7b/92e4780b: full-state public diff() plus apply-side alignment). I haven't changed that part yet.

zxch3n and others added 2 commits September 28, 2026 05:23
…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>

zxch3n commented Sep 27, 2026

Copy link
Copy Markdown
Member Author

Round 4 addressed in dc5106c; origin/main cdbc239 (with Rust #1138) is merged in as ae55ff7.

Item Fix Rust/WASM (main cdbc239)
B1 undo overwrote a later excluded-origin move UndoManager records every counter range it tracks: recorded edits, including undone ones, and the ops its undo/redo wrote. It passes an isTracked predicate to _undoIdSpan. Ops written by the current undo call itself count as tracked. A later move blocks undoing an earlier one unless it is tracked, so excluded-origin and remote moves keep their position. Same state [A,X,B]. Rust undo() returns false and loro.js returns true; loro.js main also returns true.
B2 redo after mixed moves and inserts gave [new,B,A] Each move records beforePhysicalNext, the physically following element, deleted or not. Undo puts the element back at exactly that slot through a one-shot placement hint that _applyMove consumes with moveBefore. The emitted op still carries plain visible from/to. Undo/redo sequences therefore restore the exact physical layout, so restoring deleted inserts keeps their order. With #1138, identical over 2 undo/redo rounds: [B,new,A] ↔ [A,B]
B3 lazily imported nested list emptied the Text #applyMovableListMoves hydrates the target list first, and #resolveDiffContainer uses the lazy-aware getContainerById. {outer:[["tail","hello"]]} in both
B4 delete: 1e100 looped forever; out-of-range retain was accepted validateListDelta runs for every list entry in the batch before anything is applied. It requires exactly one of retain/delete/insert per item, counts that are non-negative safe integers, and retain+delete within the current length when the target exists. It is also re-checked right before the list is mutated. A rejected batch leaves the doc unchanged: the test adds a map entry to the same batch and checks the whole state. Both rejected, state unchanged
B5 deep chain was quadratic #treeNodeAliveAt takes a cache and memoizes every node on the walked ancestor path, so each node of the subtree resolves once. 8,001 creates in both
B6 delete oldIndex was taken from the later move The deletes of moved-in live nodes are emitted first, when the state is still the from state. oldIndex is computed from the from siblings: unchanged current children plus changed nodes whose from parent matches, ordered by position, writer, and id at from. It is reduced by earlier deleted siblings. Identical: [delete 1@1 0, create 0@1 0, create 1@1 0]
N1 undo after a mergeable key was overwritten by a plain value threw _mapEnsureMergeable(…, overwrite = true) restores the marker over any value. The mergeable id is kept and redo restores the scalar. Rust main gives hellohello (the upstream bug #1134 fixes); #1134 gives hello, like loro.js
N2 a deleted range with one moved child was walked position by position Only the gaps between kept positions are deleted. —

I found one existing gap that is still open: redo of an overwrite by another container (key s is set to a new Text other, then undo and redo) re-creates the container empty. loro.js main behaves the same for regular containers, while Rust gives other. The test therefore checks redo only for the scalar overwrite.

New tests in diff-attached-children.test.ts (26 in total). On d8cb391, 6 of the 7 non-hanging new tests fail; the gap-deletion one is performance-only. The validation test would hang there on 1e100.

Performance. loadavg was 14–24 during all runs, so treat single numbers with care. Median of 3 (ms):

Deep chain N main (wrong: 1 create) e7415e2 d8cb391 dc5106c
1k 0.5 1.7 73 2.6
2k 0.1 2.1 341 3.3
4k 0.0 4.2 1,670 5.6
8k 0.0 7.8 9,302 13.2

Wide subtree, 2 alternating rounds (ms):

These overlap under this load; the only change on this path is the memoized alive check. The cost is dominated by the fractional-index strings in the output, as you measured.

One Text child plus N scalars deleted (delete N+1, insert child), d8cb391 → dc5106c: 1M 5.33 → 0.110 ms, 100k 0.538 → 0.098 ms.

bench:complexity 1000–8000 against main: one flagged entry, history-get-all-changes@1k (0.32 → 0.58 ms), which is unrelated to this code (noise under load).

Checks. This branch: 19 files / 274 tests; typecheck, lint, format:check, and build all pass. Combined tree (main cdbc239 + #1126 134c7c7 → #1127 880bd5f → #1128 c12ccc9 + this PR, with the one known trivial field conflict): 22 files / 330 tests, with format, lint, typecheck, and build.

The mergeable diff/applyDiff protocol is still waiting on #1134's final design (full-state diff plus apply-side alignment vs. incremental). I haven't changed it in this round.

zxch3n and others added 3 commits September 29, 2026 09:38
…ved-child-content

# Conflicts:
#	loro-js/src/runtime/document.ts
…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>
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>
zxch3n and others added 5 commits September 29, 2026 14:56
…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>
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>
…ith Rust #1134

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

zxch3n commented Sep 29, 2026

Copy link
Copy Markdown
Member Author

Round 5 + Rust #1134 alignment

Head: 16d135f6. It includes origin/main up to ce6498b5 (#1159).

Blocking items

# Item Commit Fix Regression test
B1 Undo anchor drift 76d57aea Undo of a move places the element next to its old physical predecessor or successor if that neighbour has no untracked later move; otherwise it uses the visible anchors. Ops written by the current undo count as tracked. Neighbour-changed slot cases
B2 Incomplete batch validation 26daeb82 The whole batch is validated first against simulated list lengths: created children start at 0, a list may appear twice, roots start at 0. Nothing is written on error, and checked entries are not validated again. Create-then-invalid, same list twice, second entry out of range, new root leaves no root
B3 Wrong tree delete indexes bc214abe Indexes are computed at from, per parent, in apply order. New root at index 0/1: exact items, matching Rust
B4 Quadratic sibling scan bc214abe Siblings are bucketed once per affected parent and indexed with a Fenwick tree. 4,000 moved-in nodes: 3,067 ms → 26 ms. 4k moved-in: < 1.5 s, all oldIndex 0
B5 Unbounded undo span retention eccefa8c Tracked ranges are pruned to what the undo/redo stacks still reference, and cleared by clear(). The count stays ≤ 22 over 5k rounds with maxUndoSteps 10. Bounded count; 0 after clear

Mergeable alignment with merged Rust #1134 (6a8d700e)

The TS behaviour now follows #1134:

  • diff() returns the full state and has no entry for unreachable containers.
  • applyDiff is incremental by default. { fullState: true } aligns maps per key, counters by difference, text and lists by prefix and suffix (styles via mark/unmark), and trees by TreeID.
  • revertTo and undo apply only the actual change.
  • A local re-ensure emits only the marker. Import and checkout events carry the full state of re-activated children; the post-processing is shared between diff() and #emit.
  • Undo of a delete only restores visibility.

Cross-checks against the Rust main WASM build (crates/loro-wasm/nodejs):

  • 36-case matrix, run both ways (TS→TS, TS→Rust, Rust→TS, Rust→Rust): 144/144 equal.
  • Extended checks: 13/13 equal, covering:
    • default apply;
    • styled and nested fullState against same, different, and absent receivers;
    • revert identity;
    • events for revert, re-ensure, undo-delete, and revert-modified (mirror and event containers).
  • Event shapes: equal for checkout, import, and tree-revival checkout.
  • B1 matrix: 70/72 equal. The 2 differences are deliberate and described in the PR body: a move is undone after the element was deleted, and Rust revives it as an empty Text.

New loro-js/tests/mergeable-diff-alignment.test.ts holds 15 cases, with expected values taken from Rust. 10 of them fail on the previous head 26daeb82.

Checks

pnpm check in loro-js passes: format, lint (0 warnings), typecheck, test (23 files, 358 tests), and build.

Performance

Three alternating rounds, main (ce6498b5) and PR. Loadavg 6.1–8.2. Numbers are round medians.

Benchmark main PR Note
diff after deleting a tree root, deep, 10k 0.2 ms (1 item) 13.7 ms (10,001 items) main omits the subtree (the bug); PR is linear
same, wide, 10k 0.2 ms (1 item) 17.0 ms (10,001 items) same
applyDiff MovableList move-to-end, 100k 0.08 ms / 2 ops (delete + fresh empty child) 3.6 ms / 1 move op The PR time is the first-move lamport index build, which a plain list.move also pays on main (first 4.1 ms, then 0.04 ms). A second move through applyDiff takes 0.18 ms.
applyDiff MovableList move-to-end, 10k 0.05 ms 0.43–0.60 ms same one-time index
Batch validation, 1k / 100k / 1M delta items 0.02 / 1.50 / 5.9 ms 0.09 / 0.97 / 10.1 ms Simulated-length validation of the whole batch before writing; 100k is faster
diff of a moved child, 1M-char text 0.021 ms 0.025 ms noise level
JSON import/export (binary, movable, counter) 23.1 / 12.6 / 5.4 ms 22.7 / 13.2 / 5.3 ms flat
b4 apply / snapshot import / update import 248 / 116 / 175 ms 241 / 115 / 173 ms flat; main round 1 had a 429 ms apply outlier

zxch3n and others added 8 commits September 29, 2026 17:32
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…port

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>
…fore 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>
… 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>
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>

zxch3n commented Sep 29, 2026

Copy link
Copy Markdown
Member Author

Round 6

Head: 585bf894. It includes origin/main ce6498b5. Each fix was pushed as its own commit.

Blocking items

# Item Commit Fix Regression tests (fail on 16d135f6)
NB3 A hidden mergeable child was validated as a new root of length 0 1db476c3 Validation uses the child's hidden length, which is what re-ensuring resurfaces. A delta beyond that length is still rejected. revertTo after a remote edit while hidden; event mirror of re-ensure + edit in one commit; out-of-range delta rejected (List and MovableList each). Matches Rust main [1,2] / [1,"new",2].
NB2 Commits scanned the undo and redo stacks 4584b1c8 Items and tracked ranges store their lamports. Pruning drops ranges whose last op precedes the first op of either stack's bottom item, which is O(tracked peers + dropped ranges). This also fixes a bug from eccefa8c: pruning compared a peer's ranges only with that peer's own items, so after setPeerId the last undo of a move did nothing. 20k commits with maxUndoSteps: 1e8 (was ~19 s); undo across a peer change.
NB1 revertTo threw and left an empty tree 3800f8bc Tree diffs are now sequences, as in Rust: moves of live nodes (parent first), then the topmost deletes, then revived subtrees. Each index is counted in the tree the earlier items leave. applyDiff applies tree items in order, and a dry run rejects a failing batch before anything is written. A node hidden at to is emitted as a delete. The review repro ([x, p>c], same items as Rust apart from where delete y sits); a node leaving a deleted subtree; a move into a parent created and deleted in range; a node hidden inside a revived subtree; nothing written when a later tree item fails.

After NB1, #sequenceTreeItems counts unchanged siblings with the tree's rank query instead of enumerating them. Without that, one move among 100k roots took 49 ms to diff and 75 ms to check out with a subscriber, against 1 ms on main.

Random tree oracle (300 concurrent two-peer histories, 1,200 version pairs; the target is checkout(to)):

forkAt(from) + applyDiff revertTo Rust diff → this apply this diff → Rust apply
main ce6498b5 841 536 723 (Rust panics on main diffs)
previous head 16d135f6 1,068 1,020 723 –
this PR 1,200 1,200 1,200 1,170
Rust itself 1,171 1,136 – –

There are no regressions against main or 16d135f6 in any column. The 30 cases where Rust applies a PR diff differently are all the same class: concurrent creates with equal fractional indexes, which Rust places by position (see "Known differences" in the description).

Atomicity fuzz (30,000 random tree batches): main writes a partial state in 59; this PR in 0. It accepts exactly the same 29,826 batches as main, so no valid batch is rejected.

Non-blocking items

  • NB4, deep trees: fixed in 0ea360a8. Hidden checks in the reachability filter and the revival pass are memoized, and fractional indexes are built as flat strings. On 8,000-deep chains:

    • diff from empty: 65 ms (main 45 ms, previous head 4.8 s);
    • import with a subscriber: 56 ms (main 45 ms, previous head 2.3 s);
    • checkout with a subscriber: 104 ms (main 88 ms, previous head 2.4 s).

    The test asserts that 4× the depth costs less than 12× the time (linear is about 5×, the previous head about 17×). An absolute 1.5 s bound flaked on the slower CI runner.

  • Default applyDiff vs Rust: fixed in f48faf5f. The plain child Text of a mergeable Map is set as a new container, as in Rust (x, not xx). Tree items resolve as Rust's apply_diff does, so a mergeable tree's nodes are reused by TreeID instead of duplicated. On a 96-item matrix (6 kinds × same/edited while hidden × fork/fresh/other-hidden receiver × default/fullState, plus revertTo and target), this PR matches Rust in 94, main in 37 and the previous head in 88.

    • The 2 remaining items are placement by index vs fractional index on a receiver with different hidden siblings.
    • I tried placing by fractional index to reach 96/96. It lost 30 applies and 47 reverts in the oracle, because equal positions tie on the new op's writer, so I kept index placement.
    • The 144-case matrix and the 13 extended checks from round 5 still pass.
  • Event mirrors: fixed in f6efed6c. The ApplyDiffOptions.fullState doc and the changeset now say that a mirror passes { fullState: true } for import/checkout batches. Tests pin both behaviours, which match Rust exactly: with the flag the mirror stays [1,2]; with the default it becomes [1,2,1,2] and then [1,2,1,2,1,2].

  • Changeset wording: fixed in f6efed6c. It now lists exactly what is checked before writing (list deltas and tree items), and says that text, counter and type-mismatch entries can still be partially applied, as before and as in Rust.

  • MovableList move vs concurrent insert: kept, and documented in the PR and the changeset.

    • Why: the divergence comes from placing a remote move by its index instead of its causal position (the movable-list-move case in #applyOperation).
    • Scope: main has it for every such move, and the MovableList model rework (test(loro.js): add a Rust differential suite for MovableList #1132) fixes it.
    • Why undo keeps writing moves: rounds 4 and 5 required undo to revert moves.
  • Context: new context/loro-js-tree-diff.md (item order, index counting, apply resolution, dry run, pitfalls), linked from AGENTS.md. The loro.js note in context/mergeable-containers.md is extended.

Checks

Performance

Three alternating rounds of main ce6498b5 and this PR. The 1-minute loadavg was 4.4–5.2. Values are medians in ms, except per-commit µs.

Benchmark main PR Note
UndoManager commit, text, maxUndoSteps 100 / 1,000 / 10,000 (µs) 5.3 / 3.8 / 3.3 5.8 / 4.3 / 3.7 Was 14 / 85 / 670 on 16d135f6
UndoManager commit, map, maxUndoSteps 1e8 (µs) 4.2 4.6 Was 950
Deep chain 8k: diff from empty / import+sub / checkout+sub 45 / 45 / 88 65 / 56 / 104 Previous head: 4,793 / 2,333 / 2,361
Wide 100k, one move: diff / checkout+sub 0.8 / 0.4 0.9 / 0.5
Tree revival, wide 10k / 100k 0.1 / 0.5 15 / 353 main omits the subtree (the bug); the PR's output is 10k / 100k items
Tree revival, deep 8k 0.2 9.9 Same reason
Tree moved-in 4k 14.6 19.3
applyDiff MovableList move-to-end, 100k 0.075 (2 ops, empty copy) 3.4 (1 move) First-move lamport index, which list.move also pays on main
List batch validation, 1k / 100k / 1M items 0.02 / 1.15 / 5.6 0.10 / 1.40 / 11.2 Whole-batch check before writing
JSON import: binary / movable / counter 22.0 / 13.1 / 5.2 22.0 / 12.6 / 5.1
b4: apply / snapshot import / update import 230 / 115 / 171 233 / 111 / 169

🤖 Generated with Claude Code

zxch3n commented Sep 29, 2026

Copy link
Copy Markdown
Member Author

Follow-up to the round-6 comment above. The head is now d2573258:

Checks:

🤖 Generated with Claude Code

…ved-child-content

# Conflicts:
#	loro-js/src/runtime/document.ts
@zxch3n
zxch3n merged commit 238988f into main Sep 29, 2026
2 checks passed
zxch3n added a commit that referenced this pull request Sep 29, 2026
#1131 now revives re-attached children in #emit itself
(#completeDiffEntries), so take its version and drop
#reviveAttachedChildren. One case is left: its reachability exemption
covers every sequence, but a listener only keeps a moved child for a
movable list. For events, apply the exemption to movable lists only, so
a child that a whole-value List diff (the replay fallback) deletes and
re-inserts is revived. diff()/revertTo keep #1131's behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zxch3n added a commit that referenced this pull request Sep 29, 2026
…path

Keep main's one-level _isContainerDeleted for getContainerById and the
diff/event reachability walk, and use the ancestor walk only in
isDeleted(), getPathToContainer() and getCursorPos(). Drop the guard on
local edits to deleted containers: it threw on a redo that main allows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zxch3n added a commit that referenced this pull request Sep 29, 2026
Seeds 0-119 with nested children and snapshot imports pass on main now
that #1131 is in. See context/loro-js-differential-fuzz.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zxch3n added a commit that referenced this pull request Sep 29, 2026
* test(loro.js): add a multi-peer differential fuzz harness

Random multi-peer edits, update exchanges in several orders, and
checkouts, checked against replay, checkout-at-version, snapshot,
shallow-snapshot, and event-mirror oracles, and optionally step by step
against a loro-crdt WASM build. Failures are grouped by signature,
minimized, and printed as loro.js calls.

The default run is a fixed 120-seed Map/List/Text/Counter set that needs
no WASM build (~2 s). Seeds that fail on main are listed with the fix
that makes them pass. Usage and findings:
context/loro-js-differential-fuzz.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(loro.js): record the fuzz findings and their PRs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(loro.js): state which PR the lockstep measurement used

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): run nested-container fuzz seeds in CI

Seeds 0-119 with nested children and snapshot imports pass on main now
that #1131 is in. See context/loro-js-differential-fuzz.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
pull Bot pushed a commit to Superoldman96/loro that referenced this pull request Sep 29, 2026
…ro-dev#1147)

* fix(loro.js): treat containers under deleted ancestors as deleted

isDeleted() only looked at a container's own parent binding, and a tree
node only at its own deleted flag. A child of a removed map value, or a
node under a deleted tree node, therefore counted as alive: it kept a
path, getNodes() listed it, and local edits to it recorded ops on
invisible state. Rust's DocState::is_deleted follows the whole chain and
rejects such edits with ContainerDeleted.

Walk the parent chain (and tree ancestors) for isDeleted,
getPathToContainer, and a new guard in #appendAndApply that throws
Rust's message. getContainerById keeps its one-level check, since Rust
still resolves those IDs. nodes() now returns every node and getNodes()
lists alive nodes breadth-first, as Rust does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(loro.js): keep deleted-container checks off loro-dev#1131's reachability path

Keep main's one-level _isContainerDeleted for getContainerById and the
diff/event reachability walk, and use the ancestor walk only in
isDeleted(), getPathToContainer() and getCursorPos(). Drop the guard on
local edits to deleted containers: it threw on a redo that main allows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): list tree nodes without spreading child lists into push()

getNodes() spread each child list, and with withDeleted the deleted
subtrees, into one push() call, which throws a RangeError past V8's
argument limit (about 125k on Node's main thread).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

1 participant