Repository navigation
fix(loro.js): model MovableList like Rust (positions and LWW elements) - #1166
Merged
Merged
Conversation
Drive the Rust WASM build and loro.js with the same random multi-peer edits, crossing engines on every sync, and require equal values, versions, per-element metadata and events. Profiles that diverge on the current MovableList model are recorded as known divergences. Design and findings: context/loro-js-movable-list.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Event deltas that differ only in shape are checked by effect; reverts whose op order Rust leaves to hash order are checked by value and replicated from Rust; peers that both engines reject are retired; peer IDs are never reused; undo stays unchained; the minimizer keeps those rules. See context/loro-js-movable-list.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…list-differential # Conflicts: # AGENTS.md
…undo unchained Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…se diff shape differs Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow root Both follow Rust issues listed in context/loro-js-movable-list.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>
…ust reference Load Rust through tests/support/rust-reference.ts like the other differential suites, so it runs in `pnpm test` (and in CI's test-loro-js) and skips without a WASM build. Drop the separate workflow and scripts. insert/delete now converges on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MovableList keeps a Fugue sequence of list items and elements whose position and value are last-writer-wins registers, as Rust does. Moves of concurrently deleted elements, deletes after moves, snapshots (list item, element and last-set IDs, dead items), metadata getters, version transitions, revertTo/applyDiff (Rust's apply_delta), undo and events now match Rust. Snapshot-hydrated documents replay history before concurrent imports, and a few shallow-snapshot incompatibilities are fixed. The differential suite passes every profile. Design and remaining gaps: context/loro-js-movable-list.md. Rebuilt on current main: MovableList joins #1126's per-container snapshot completion (its snapshot state names Rust IDs), applyDiff/revertTo/undo use main's port of Rust #1138's apply_delta on the new model, and undo transforms only over changes the UndoManager did not make. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s-movable-list-model
…ules Decoding deferred snapshot history dropped pending changes. Shallow imports now check the unknown part of each change like is_before_shallow_root, and an import that only adds pending changes creates their root containers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's diff() reports such a child by its ops, including map keys set and deleted again, as Rust does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…acker A causal view per concurrent op made importing concurrent moves O(n^2). Like Rust's Tracker::checkout, keep a tracker version and a per-position delta metric that moves incrementally between op versions; see context/loro-js-movable-list.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lues A MovableList replaced by a plain value made the comparison enumerate the container and hit its guarded element sequence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Rust-model MovableList passes seeds 0-599 of the #1141 fuzz with all oracles in lockstep with Rust; main failed 144 of seeds 0-299. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y_delta Without moved children the delta was walked in order, which gave the ops other IDs than Rust's and later deletes by ID missed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…diff() A child created in the range whose keys were all set and deleted again is listed by Rust and omitted by loro.js, also on main and for any parent; see context/loro-js-movable-list.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s-movable-list-model # Conflicts: # context/loro-js-movable-list.md
This was referenced Sep 29, 2026
Contributor
WASM Size Report
|
The performance context still described main's MovableList snapshot special case and #1163 as open. Measurements are in context/loro-js-performance.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
canTransition walked the position tree once per insert op, which made checkout, diff and revertTo O(insert ops * positions). Collect the runs and check them in one containsIdRuns call, as the List branch does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The changeset named a test:differential script that does not exist. Rust's diff() can list a sequence with an empty delta where loro.js omits it (context/loro-js-movable-list.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s-movable-list-model # Conflicts: # context/loro-js-movable-list.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zxch3n
changed the base branch from
test/loro-js-movable-list-differential
to
main
September 29, 2026 19:43
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Second PR of the MovableList stack, based on #1132 (the differential suite; review only the commits after it). It replaces loro.js's
LoroMovableListinternals with Rust's model, so loro.js converges with Rust and reads and writes Rust's MovableList encoding. Design and Rust contract:context/loro-js-movable-list.md.loro-js/src/runtime/movable-list.ts).SequenceIndex; every Insert/Move creates positions.IdLp, and each element's position and value are separate LWW registers byIdLp.fromdeletes the position at that op index in the op's causal view, andtois resolved after that delete, as in Rust's tracker.Tracker::checkout: a per-position delta metric instead of one causal view per op.MovableListHandler.setalways records an op, andmove(i, i)is a no-op.crates/loro/tests/loro_js_interop.rsreads a TS snapshot with its metadata.getCreatorAt,getLastMoverAtandgetLastEditorAtreturn the element, position and value writer's peers.diff,revertTo,applyDiffand undo select winners as Rust does.applyDiff/revertTouse main's port of Rust fix: apply multi-child MovableList diffs as moves without losing elements #1138'sapply_delta(#applyMovableListMoves, now on the new model).undo_internal: the inverse diff, transformed only over changes the UndoManager did not make.sameMovableListStates). Before a transition or a concurrent import touches a hydrated MovableList, that one container is rebuilt from its own history. The same import step now also runs for Text and List when an import is concurrent with the snapshot, which fixes loro.js: an insert concurrent with a delete baked into an imported snapshot lands at the wrong position #1163 (latest state and exports were wrong).Also fixed along the way; each one was found by the differential suite and compared against Rust:
main.is_before_shallow_root, including a change sliced at the known version.KNOWN_DIVERGENTis empty, and #1141's multi-peer fuzz gets a MovableList CI run.Rationale
The old model stored one value per element plus a move history, and rebuilt order with an LIS pass. It could not express dead positions or a concurrent move of a deleted element, and it had no per-register writers. That is where the six divergences listed in #1132 came from. Porting Rust's model gives Rust's semantics by construction. The complexity contract (
context/loro-js-performance.md) holds:There are no public API signature changes. Observable behavior now matches Rust in three places: a same-value
setrecords an op,move(i, i)is a no-op, andgetIdAt/getCursoranchor the list item. For that reason the changeset isminor.Validation
pnpm -C loro-js checkpasses against a Rust WASM reference built from currentmain(LORO_REQUIRE_WASM_REFERENCE=1).New tests:
tests/movable-list.test.tscovers issues 1–5, metadata, the Rust snapshot fixtures decoded and re-encoded byte for byte, checkout/diff/revertTo/undo,applyDiffop order, cursors, snapshot-hydrated imports, shallow import rules, and concurrent-branch convergence.tests/runtime.test.tscovers pending root creation and pending changes across history decoding.All fix(loro.js): include reattached child containers' state in diff() and revertTo() #1131 undo/applyDiff tests (
diff-attached-children.test.ts) pass on the new model.cargo test -p loro --test loro_js_interop: 14 pass, including a TS MovableList snapshot read by Rust with its metadata.MovableList differential suite (test(loro.js): add a Rust differential suite for MovableList #1132): all 8 profiles converge at CI scale. On the stack tip (fix(loro.js): reject invalid MovableList move/set refs (port Rust #1125) #1168), each profile × 300 seeds from 1000 and 300 seeds from 2000, 150 steps each (4,800 runs), gives 0 divergences.
test(loro.js): add a multi-peer differential fuzz harness #1141 multi-peer fuzz with
movableList,nested, all oracles, lockstep with Rust: seeds 0–799 pass on the stack tip, whilemainfails 144 of seeds 0–299 (98 checkout-vs-replay, 29 lockstep). CI now runs 120 MovableList seeds.Benchmarks against
main(median of 5 alternating rounds, load average 2.6–4.7; ratio = this PR / main, stack tip within 5% of this PR):bench:complexity: no case beyond noise.bench:b4(text trace): apply, import and export all 0.99–1.02.benchmarks/movable-list.mjsat 8000 items:toJSONgetLastMoverAt+getLastEditorAtThe 2.00 row is the price of correctness.
mainapplied a concurrent update to a snapshot-loaded MovableList without its history; that is where most of its fuzz failures come from. This PR completes the list from its history first, so the first concurrent import after a snapshot costs about what a document loaded from updates pays: 92 ms vs 60 ms, wheremaintakes 35 ms and 96 ms. The same applies to Text/List through the loro.js: an insert concurrent with a delete baked into an imported snapshot lands at the wrong position #1163 fix: with 2000 random Text edits per peer, the concurrent import after a snapshot now takes 675 ms, equal tomainafter an update load (667 ms), instead ofmain's wrong 235 ms. Both are recorded incontext/loro-js-performance.md. The metadata getters now read three registers instead of returning the creator; each is still well under a microsecond.Known limitations
Details in
context/loro-js-movable-list.md("Remaining known divergences"):diff()delta shapes for multi-op changes: Rust composes element updates inFxHashMaporder, and a no-op move pair is reported where Rust reports nothing.revertTo/undo can therefore place a restored element on the other side of dead positions.main(per-opcausalView).getLastEditorAtseeding;updates-in-rangefrom a shallow doc exports nothing for a span that starts before the root.Merge order
import/importBatch(stacked on this one).The stack is merged with current
main(#1126–#1131, #1133, #1136–#1150), not rebased.🤖 Generated with Claude Code