Skip to content

fix(loro.js): model MovableList like Rust (positions and LWW elements) - #1166

Merged
zxch3n merged 25 commits into
mainfrom
fix/loro-js-movable-list-model
Sep 29, 2026
Merged

zxch3n merged 25 commits into
mainfrom
fix/loro-js-movable-list-model

Conversation

@zxch3n

@zxch3n zxch3n commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Summary

Second PR of the MovableList stack, based on #1132 (the differential suite; review only the commits after it). It replaces loro.js's LoroMovableList internals 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.

  • Model (loro-js/src/runtime/movable-list.ts).
    • Positions (list items) are a Fugue SequenceIndex; every Insert/Move creates positions.
    • Elements are keyed by their IdLp, and each element's position and value are separate LWW registers by IdLp.
    • Pointed alive positions are user-visible; dead ones still count in op indices.
    • A move's from deletes the position at that op index in the op's causal view, and to is resolved after that delete, as in Rust's tracker.
    • A concurrent move of a deleted element brings it back.
  • Concurrent ops resolve indices through an incremental tracker, like Rust's Tracker::checkout: a per-position delta metric instead of one causal view per op.
  • Local ops compute op indices like MovableListHandler. set always records an op, and move(i, i) is a no-op.
  • Encoding. Snapshots write Rust's MovableList layout (list item IDs, element IDs, last-set IDs, dead items; peers in first-use order). The TS encoder's bytes equal Rust's for the same state, and crates/loro/tests/loro_js_interop.rs reads a TS snapshot with its metadata.
  • Metadata. getCreatorAt, getLastMoverAt and getLastEditorAt return the element, position and value writer's peers.
  • Versions.
  • Snapshots (fix(loro.js): correct checkout after a snapshot import #1126). MovableList is no longer excluded from snapshot-sequence completion. Its snapshot state names Rust IDs, so a replay of its history is comparable to it (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:

  • Pending changes imported while snapshot history was still undecoded were dropped when the history was decoded (for example by a checkout). This is on main.
  • Shallow imports follow Rust's is_before_shallow_root, including a change sliced at the known version.
  • An import that only adds pending changes creates their root containers.
  • A shallow snapshot rooted at a peer's first op writes Rust's start-version entry.
  • Comparing event values never walks a container's fields.

KNOWN_DIVERGENT is 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:

  • index conversions are O(log n);
  • version switches revisit only the positions the switched ops touched;
  • a run of concurrent imported ops costs O((ops + touched positions) · log n).

There are no public API signature changes. Observable behavior now matches Rust in three places: a same-value set records an op, move(i, i) is a no-op, and getIdAt/getCursor anchor the list item. For that reason the changeset is minor.

Validation

  • pnpm -C loro-js check passes against a Rust WASM reference built from current main (LORO_REQUIRE_WASM_REFERENCE=1).

  • New tests:

    • tests/movable-list.test.ts covers issues 1–5, metadata, the Rust snapshot fixtures decoded and re-encoded byte for byte, checkout/diff/revertTo/undo, applyDiff op order, cursors, snapshot-hydrated imports, shallow import rules, and concurrent-branch convergence.
    • tests/runtime.test.ts covers 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, while main fails 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.mjs at 8000 items:

      case main this PR ratio
      import 8000 moves/sets 71.7 ms 35.9 ms 0.50
      import concurrent moves/sets 124.0 ms 96.6 ms 0.78
      checkout to latest 89.9 ms 36.3 ms 0.40
      checkout before moves 34.9 ms 47.2 ms 1.35
      8000 local moves/sets 33.1 ms 37.2 ms 1.12
      8000 pushes 5.5 ms 7.5 ms 1.35
      snapshot export 17.8 ms 23.8 ms 1.34
      snapshot import + toJSON 20.2 ms 27.8 ms 1.37
      snapshot import, then a concurrent update 66.8 ms 133.7 ms 2.00
      8000 × getLastMoverAt + getLastEditorAt 1.7 ms 4.0 ms 2.34

    The 2.00 row is the price of correctness. main applied 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, where main takes 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 to main after an update load (667 ms), instead of main's wrong 235 ms. Both are recorded in context/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"):

  • Differences of form, where the effect is the same:
    • Event and diff() delta shapes for multi-op changes: Rust composes element updates in FxHashMap order, 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.
  • Undo:
    • When an untracked change touched the list, the inverse is transformed over everything since, not only Rust's remote diff (approximate).
    • Other containers keep loro.js's op-based undo.
  • Not MovableList-specific: loro.js emits events for children of deleted elements, and List/Text cursor offsets differ.
  • A MovableList whose replay differs from its snapshot state (a loro.js 0.2 snapshot) stays "unreplayable", as fix(loro.js): correct checkout after a snapshot import #1126 describes. Older versions are then approximate.
  • Performance: the first import concurrent with a snapshot replays the touched Text/List/MovableList history once (see Validation). Text concurrent imports stay superlinear in the number of ops, as on main (per-op causalView).
  • Rust issues:
    • shallow getLastEditorAt seeding;
    • non-atomic outdated-deps imports;
    • updates-in-range from a shallow doc exports nothing for a span that starts before the root.

Merge order

  1. test(loro.js): add a Rust differential suite for MovableList #1132, the differential suite (this PR's base).
  2. This PR.
  3. fix(loro.js): make import and importBatch atomic #1167 atomic import/importBatch (stacked on this one).
  4. fix(loro.js): reject invalid MovableList move/set refs (port Rust #1125) #1168 MovableList move/set validation, following the merged Rust fix: reject malformed movable list move/set ops on import instead of aborting #1125 (stacked on 3).

The stack is merged with current main (#1126–#1131, #1133, #1136–#1150), not rebased.

🤖 Generated with Claude Code

zxch3n and others added 20 commits September 28, 2026 02:07
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>
…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>
…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
@github-actions

Copy link
Copy Markdown
Contributor

WASM Size Report

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

zxch3n and others added 5 commits September 30, 2026 02:47
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
zxch3n changed the base branch from test/loro-js-movable-list-differential to main September 29, 2026 19:43
@zxch3n
zxch3n merged commit 408d649 into main Sep 29, 2026
2 checks passed
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.

loro.js: an insert concurrent with a delete baked into an imported snapshot lands at the wrong position

1 participant