Repository navigation
fix(loro.js): correct checkout after a snapshot import - #1126
Conversation
…mport
Snapshot-hydrated state has no tombstones or op history, so incremental
transitions returned wrong historical state, and a replay could hydrate
lazy latest-state entries on top of replayed ops ("duplicate sequence id").
Rationale: context/loro-js-performance.md (snapshot-hydrated state).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
WASM Size Report
|
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The first checkout after a snapshot import replayed the whole document, up to three times for the first `diff`, and replaced correct snapshot state with loro.js replays. Replaying Rust-created rich text lost styles and moved text, because loro.js positions do not count Rust style anchors. A transition now hydrates and rebuilds only the Text/List containers it touches, once, and keeps a snapshot state that the replay does not reproduce for every version with the same operations on that container. Checkout events of unread nested containers are correct, and a full snapshot export keeps unread containers after history is loaded. See context/loro-js-performance.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Shallow export rebuilds the root and latest states by full replay, which replaced snapshot state that the replay does not reproduce (Rust rich text) in both the export and the live document. The rebuilds now pass the version they leave, so snapshot states are stashed and reinstated, and unread lazy sequences are hydrated first. The import builds the root entry index only when it merges a latest state. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The check for crossing snapshot operations ran before lazily encoded containers were hydrated, so an unread nested Text or List skipped its history rebuild and the checkout fell back to replaying the whole document. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Resolution: a detached snapshot export (#1130) builds its latest state with the same prepared transition as checkout (#prepareSnapshotTransition, #settleSnapshotSequences) and encodes it with the lazy-aware state encoder. Lazily imported documents therefore also use the O(delta) transition instead of a staging replay, and snapshot states that a replay does not reproduce stay exact in the export. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… checkout Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Follow-up on the adversarial review of What changed
Blocking reprosCross-checked with a local
The middle Rebuild count. Measured on an instrumented build with
PerformanceNode 22.23.1, Apple M5 Pro. The machine was under load (load average about 20). Each number is the median of 5 separate processes, run in alternating order. The time is the first checkout after the snapshot import; the import itself is excluded.
*
Validation
Not fixed here (all present on
|
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t state A Text or List whose replay does not reproduce its snapshot state (Rust rich text, since loro.js does not count style anchors in positions) no longer installs the replay and pins the snapshot state back. Operations applied while the replay was installed, such as an update imported while detached, changed the latest state and exports. Such a container now keeps its snapshot state as the authority: every transition that touches it rebuilds it from that state plus later operations, so older versions are approximate but the latest state and exports never come from a replay. Full rebuilds and forkAt do the same. Transitions also deduplicate sequence elements by id. A packed Text span returns a new wrapper per lookup, so two concurrent deletes of one character deleted it twice and a subscribed checkout threw after half-updating the document. A checkout that throws now restores its previous version and state. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`pull_request` ran only for PRs into main, dev, or feat-gc, so PRs stacked on another branch had no checks, and no job ran `pnpm -C loro-js check`. Drop the base-branch filter and add a loro-js job. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…sed operations Rebuilding an unreplayable container from its snapshot state on every transition cost O(container) per checkout (about 25 ms for a 64k-character Rust rich text). When the installed state already has every forward operation, a Text is now toggled by id and style version, as a state without history is, and only rebuilt otherwise (for example after an update imported while detached). Its events come from the transition's recording unless style operations are crossed. Adds a regression test over Rust rich-text histories: random checkouts, detaches, and imports must keep the latest state of a document that only imports. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comparing a styled text's replay with its snapshot state read both deltas even when the visible ids already differed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Round-2 follow-up: B1: the pinned-snapshot mitigation is removed and redoneThe stash/pin mechanism is gone. When
New regression tests:
B2: coalesced inserts and overlapping deletes
CI
The previous push got Validation
PerformanceApple M5 Pro, Node 22.23.1. Medians of 5 separate processes in alternating order, with load1 8.2–16.4 (median 10.8) during the run.
* On
|
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Round-3 update: The merge brings in main's Rust crates, changesets, and context docs only. Rechecked against Rust at
|
| case | main* | round 2 134c7c7b |
now 1c5ff489 |
|---|---|---|---|
| Text 65,536 ops | 4.4 | 23.5 | 23.8 |
| Text 262,144 ops | 9.1 | 52.0 | 53.3 |
| Text 262,144 ops, subscribed | 9.3 | 53.0 | 53.3 |
| 32k child Maps, retreat one | 53.6 | 47.7 | 47.5 |
| 32k child Maps, peak RSS (MiB) | 234.1 | 234.2 | 233.8 |
| unreplayable rich text 65,536 | 2.3 | 31.8 | 32.1 |
| unreplayable rich text 262,144 | 2.7 | 70.4 | 69.5 |
| unreplayable rich text 65,536, round trip | 0.05 | 0.06 | 0.06 |
| same round trip, subscribed | 0.10 | 0.09 | 0.09 |
* On main, the Text rows return N−1 characters and the Map row returns the latest value. Both are wrong, as in round 2.
bench:complexity 1000,2000,4000,8000 (7 alternating rounds × 7 variants, load1 9.6–22.0, median 12.0). A case is flagged when it is over 1.5× the baseline and more than 0.3 ms slower.
-
Against the round-2 heads: 0 of 852 entries flagged.
-
Against
main: 1 of 852,text-repeated-mark-tail-retreat@8000.- Times: fix(loro.js): correct checkout after a snapshot import #1126 0.455 → 0.780 ms; fix(loro.js): keep root-time map values and tree nodes when a shallow doc retreats #1127 0.72 ms; fix(loro.js): drop containers deleted before the shallow root, keep tree node metadata #1128 0.70 ms. The round-2 heads measure 0.60–0.64 ms with identical code.
- An earlier 3-round run flagged a different case,
text-full-range-insert-retreat@1000, which the 7-round run does not flag.
-
Targeted recheck of the repeated-mark case (7 alternating processes, load1 about 18):
repeated-mark retreat main round 2 now 8,000 marks, first retreat 1.90 2.04 2.07 64,000 marks, first retreat 3.81 3.81 3.71 Warm retreat and restore are equal. The difference is a constant of about 0.15 ms on the first call, not a scaling cost.
B4 (3 alternating rounds, load1 7.8–8.3), main / round-2 #1128 / now #1128, in ms:
| phase | main | round-2 #1128 | now #1128 |
|---|---|---|---|
| apply | 249.0 | 256.4 | 245.7 |
| snapshot export | 115.5 | 122.7 | 115.3 |
| update export | 74.9 | 73.4 | 75.0 |
| snapshot import | 125.8 | 121.5 | 127.3 |
| update import (coalesced inserts) | 253.7 | 181.9 | 182.0 |
Checks
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- forkAt/fork keeps an unreplayable snapshot state only when the fork's version includes it; an older fork replays its own operations, as on main. - A MovableList is never unreplayable: its hydrated state carries Rust position ids, so its replay replaces the state, as on main. - A completion that throws reinstalls the snapshot state; #transitionTo prepares inside its try, and diff restores with a full rebuild when it or its move back throws. Rationale and limits: context/loro-js-performance.md (issue #1163 tracks the concurrent-delete placement bug that main already has). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Replaying a hydrated MovableList to the current version and retreating from there carried loro.js's MovableList non-convergence at the latest version into older versions (random Rust histories: 581 mismatching versions vs 91 on main). A MovableList is no longer a snapshot sequence: its hydrated state has no move or value history, so a transition that crosses its snapshot operations replays to the target, as on main. See context/loro-js-performance.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>
|
Round-4 follow-up: Commits:
The PR description is rewritten for the current design. It adds merge order, known limitations, and #1136/#1137 integration notes. NEW-B1:
|
forkAt(v1) / checkout(v1); fork() |
main |
1c5ff489 |
now |
|---|---|---|---|
| value | hello |
ellXo |
hello |
after a local insert of Q, then Rust reads the fork's updates |
Qhello / Qhello |
QellXo / Qhello |
Qhello / Qhello |
fork.import(snapshot) |
elloX |
throws duplicate sequence id 7@1 |
elloX |
MovableList forkAt(v0) |
[0,1,2] |
[9,1,2,0] |
[0,1,2] |
NEW-B2: Rust MovableList with a move
Decision: a MovableList is not a snapshot sequence at all. It stays on main's path: its hydrated state has no move or value history, so a transition that crosses its snapshot operations replays to the target.
Why: there is no sound way to compare a hydrated MovableList with its replay.
- Its Rust snapshot names a moved element by position id.
listItemIdsalso includes the invisible old positions, and Rust encodes element ids as (peer, lamport), not (peer, counter).
Options tried, on 90 random Rust histories × every version:
| option | MovableList versions ≠ Rust |
|---|---|
1c5ff489 (move = unreplayable) |
1,352 |
e8dca05a: always accept the replay at the current version, then retreat incrementally |
581 |
| complete only lists whose snapshot uses insert ids | 167 |
main |
91 |
| now | 56 |
- The incremental options expose loro.js's own MovableList transition bugs with concurrent moves and sets. A full-history loro.js doc on
mainfails the same versions, for example seed 17[62,65]vs Rust[62,93].main's snapshot docs avoid them by replaying to the target. - The reviewer's repro now matches
mainand Rust:checkout(v0),revertTo(v0)(with Rust re-import),diff(v1,v0), andforkAt(v0)all give[0,1,2].- A checkout round trip followed by the remote update gives
[0,7,2].
Cost, as on main:
- A move or set imported right after a Rust snapshot import, before any checkout, still resolves the wrong element (
[7,2,0]). - When loro.js's replay really differs from Rust, the latest MovableList can change at the first crossing checkout: 37 of 90 seeds, vs 38 on
main. - Both are left to the MovableList model work (test(loro.js): add a Rust differential suite for MovableList #1132 and follow-ups).
Performance (8,192 / 65,536 elements, one move; first / each later checkout):
| 8,192 | 65,536 | |
|---|---|---|
main |
9.0 / 0.15 ms | 35.6 / 0.16 ms |
| now | 9.2 / 0.18 ms | 38.0 / 0.19 ms |
1c5ff489 (wrong value) |
17.0 / 2.16 ms | 74.5 / 17.1 ms |
P2 and diff
- A completion that throws at any point reinstalls the snapshot state and leaves the container hydrated.
#transitionToprepares inside itstry; a failure before the version is set just rethrows, since nothing changed.diffrestores the current state with a full rebuild when it, or its move back, throws.- Tests:
- The reviewer's injection points (the n-th
visibleIdRuns/_swapStatecall during checkout anddiff, followed by a retry and a Rust-readable export) reproduceelloXon1c5ff489and pass now. - A second test injects into
setIdRunsVisible/setIdRunsDeleted/setDeletedduringdiff, covering both the forward move and the move back.
- The reviewer's injection points (the n-th
Docs, changeset, issue
context/loro-js-performance.mdis corrected:- the latest state is exact only as "the snapshot state plus later ops as loro.js applies them";
- old versions of unreplayable text are approximate, with the review's numbers;
- it covers the
forkAtrule and the MovableList decision with its measurements.
- The changeset no longer claims that deleted text is restored for unreplayable text.
- The pre-existing concurrent-insert bug (
abefXvs RustabXef, reproduced onmain29553d5b) is now loro.js: an insert concurrent with a delete baked into an imported snapshot lands at the wrong position #1163.
Validation
- Checks:
pnpm -C loro-js checkpasses with 276 tests. The 5 new fork/MovableList tests and the 2 fault tests fail on1c5ff489. - Rust oracle
29553d5b: B1, B2, F1–F4, detached/shallow/lazy export, and the rich-text fuzz (240/240) are unchanged. The newmovable-moves.jsonfixture regenerates byte-identical. - Random Rust histories (90 seeds; the full table is in the description):
| mismatches with Rust | main |
now |
|---|---|---|
| MovableList versions | 91 | 56 |
forkAt Text / MovableList |
64 / 7 | 64 / 7 |
revertTo MovableList |
45 | 41 |
| Text versions | 517 | 787 |
revertTo Text |
62 | 124 |
| export re-imported by Rust | 0 | 0 |
The Text rows are the documented rich-text trade-off; #1137 removes it.
- General loro.js-written fuzz (60 seeds): every non-shallow failure also fails on
main(109 of 109 seed/variant pairs). The shallow failing seeds are a subset ofmain's (47 vs 53). - Performance (load1 4.1–6.0; tables in the description):
- First checkout and rich text are unchanged from round 3: Text 262k 53.9 ms, unreplayable rich text 262k 70.1 ms.
bench:complexity: 0 of 852 entries flagged againstmainand against the round-3 heads.- B4 update import: 174.0 vs 239.5 ms on
main.
- CI on
8ef6818d: build and loro-js pass.
A full rebuild only checked Text whose snapshot state still had styles. When every marked character was deleted, the mark's anchors still shift later Rust positions, so the replay (hlXo -> hloX) replaced the snapshot state and was exported. The check now also covers Text with a mark operation in its history (#markedTexts) or styles in its shallow root state; plain text still skips it. See context/loro-js-performance.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Round-5 follow-up: R4-B1: full rebuilds now check Text that had styles
Rust-written
The new test ( Exhaustive
Docs and description
Checks
Performance262,144 characters; 5 alternating rounds against
|
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-richtext-anchors Integrates #1126–#1128: `_swapState` also swaps the anchor registry and keeps `_styleIndex` replaceable with `TextStyleIndex<TextStyle>`; the shallow export passes #1128's retained-container set and this branch's style redaction to `#buildStateStore`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e moot #1126 checked snapshot-hydrated styled Text before every full replay because loro.js did not count the style anchors, so a replay of Rust rich text could differ from its snapshot state. With the anchors in the sequence, styled Text replays like plain Text; the unreplayable fallback stays for snapshots whose state disagrees with their own history (context/loro-js-performance.md). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ow export With #1126 on main, a checkout keeps a 0.2 snapshot's state while a fork or shallow export replays its history (Rust's reading); the test pins the fork difference from Rust. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ntial suite (#1136) * fix(loro.js): converge plain-text edits with Rust A seeded differential suite now runs the same Text edits, syncs, checkouts, snapshots, cursors, undo, and revertTo in loro.js and loro-crdt (nodejs WASM) and compares every step; it skips when no WASM build exists. It found these divergences, fixed here: Fugue placement after a sibling subtree, deletes applied by recorded ID instead of position, doubly deleted text restored twice by checkout events, and cursor semantics. Ops are also written in Rust's shape (merged inserts, last delete run first). See context/loro-js-rust-differential.md and context/loro-js-performance.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): stop a concurrent insert at the end of its origin's subtree The indexed Fugue path jumped from the last direct child of `originLeft` to the origin-right element. Rust's scan stops at the first concurrent element whose origin is further left, so the insert could land after that element. Replicas then disagreed depending on import order, and all of them could disagree with loro-crdt. Check whether the interval's last element descends from `originLeft` and otherwise binary-search the boundary. The descent test skips implicit runs through per-peer sorted explicit counters. Rationale and cost: context/loro-js-performance.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * perf(loro.js): jump between ID runs when finding a Fugue subtree end An element right after its ID predecessor has it as origin-left, so only run starts can end a sibling's subtree (context/loro-js-performance.md). Importing a concurrent character after a 512k-character run drops from ~9.5 ms to ~0.2 ms. Adds text-concurrent-insert-after-long-run to bench:complexity. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): read 0.2 data like Rust and document the upgrade Decision and rationale: context/loro-js-rust-differential.md ("Data written by loro.js 0.2"). The changeset becomes major; README "Upgrading from 0.2" lists what changes and how to migrate. Fixtures written by loro.js 0.2.1 pin how loro.js and Rust read its updates, snapshots, and cursors. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(loro.js): check out per-op versions in the differential suite Visits two versions per checkout, including versions right after a random op, so transitions between historical versions and mid-change versions are compared with Rust. README "Upgrading from 0.2" now reflects what a document loaded from a 0.2 snapshot does after its history is used. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): describe per-op checkouts in the differential suite Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): record Fugue subtree-end measurements Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): record Fugue measurements after adopting #1139 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): describe 0.2 snapshots after checkout, fork, and shallow export With #1126 on main, a checkout keeps a 0.2 snapshot's state while a fork or shallow export replays its history (Rust's reading); the test pins the fork difference from Rust. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * perf(loro.js): check a transition's sequence elements once per container #canTransitionRecords walked the whole sequence once per insert op, so checking out across scattered edits was O(ops * elements): 668 ms at 8k characters with 2k edits on main, 4 ms now. Adds text-scattered-edits-checkout to bench:complexity. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): record the transition check measurements Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): correct the 0.2 cursor and migration notes Cursors move for side 1 and end-of-text, not astral text; reading the final state with 1.0 needs the snapshot to be the final version; a shallow export switches a 0.2-snapshot document to Rust's reading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(loro.js): make the differential checkout's second version independent The second checkout version came from `imul(roll, 0x9e3779b1)`, whose low bits repeat the roll's (the multiplier is 1 modulo 16), so it usually picked the first version again. It now uses the product's high bits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-dev#1137) * fix(loro.js): converge plain-text edits with Rust A seeded differential suite now runs the same Text edits, syncs, checkouts, snapshots, cursors, undo, and revertTo in loro.js and loro-crdt (nodejs WASM) and compares every step; it skips when no WASM build exists. It found these divergences, fixed here: Fugue placement after a sibling subtree, deletes applied by recorded ID instead of position, doubly deleted text restored twice by checkout events, and cursor semantics. Ops are also written in Rust's shape (merged inserts, last delete run first). See context/loro-js-rust-differential.md and context/loro-js-performance.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): keep rich-text style anchors in the text sequence Rust writes every Text op position as an entity index that counts style anchors. loro.js kept styles beside the text, so once a document had a mark, each runtime applied the other's later edits at different places (loro-dev#1133). Anchors are now zero-width elements of the Text sequence: remote marks insert them with Fugue, local edits follow Rust's insert/delete/mark position rules, styles cover the elements between their anchors, and snapshots, checkout, events, and undo use them. The differential suite now runs with marks. See context/loro-js-richtext-anchors.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): stop a concurrent insert at the end of its origin's subtree The indexed Fugue path jumped from the last direct child of `originLeft` to the origin-right element. Rust's scan stops at the first concurrent element whose origin is further left, so the insert could land after that element. Replicas then disagreed depending on import order, and all of them could disagree with loro-crdt. Check whether the interval's last element descends from `originLeft` and otherwise binary-search the boundary. The descent test skips implicit runs through per-peer sorted explicit counters. Rationale and cost: context/loro-js-performance.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * perf(loro.js): jump between ID runs when finding a Fugue subtree end An element right after its ID predecessor has it as origin-left, so only run starts can end a sibling's subtree (context/loro-js-performance.md). Importing a concurrent character after a 512k-character run drops from ~9.5 ms to ~0.2 ms. Adds text-concurrent-insert-after-long-run to bench:complexity. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): read 0.2 data like Rust and document the upgrade Decision and rationale: context/loro-js-rust-differential.md ("Data written by loro.js 0.2"). The changeset becomes major; README "Upgrading from 0.2" lists what changes and how to migrate. Fixtures written by loro.js 0.2.1 pin how loro.js and Rust read its updates, snapshots, and cursors. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(loro.js): hydrate lazy text, redact dead styles, and keep checkout events - Insert positions hydrate a lazily loaded Text first; its anchors decide where text goes. - Shallow snapshots null the values of styles with no text left, like Rust's redact_dead_style_values (context/shallow-snapshot-style-redaction.md). - A transition that only toggles a start anchor starts the text's event baseline, so it no longer reports the whole text as inserted. - The differential suite checks out per-op versions, two in a row. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(loro.js): check out per-op versions in the differential suite Visits two versions per checkout, including versions right after a random op, so transitions between historical versions and mid-change versions are compared with Rust. README "Upgrading from 0.2" now reflects what a document loaded from a 0.2 snapshot does after its history is used. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): read 0.2 rich text with Rust's positions The decision and its reasons are in context/loro-js-richtext-anchors.md ("Data written by loro.js 0.2"). The changeset becomes major and lists the breaking changes; README "Upgrading from 0.2" covers rich text; a 0.2.1 fixture pins how loro.js and Rust read its rich-text ops. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): describe per-op checkouts in the differential suite Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): record Fugue subtree-end measurements Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): record the accepted rich-text anchor costs Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): record Fugue measurements after adopting loro-dev#1139 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): add the rerun rich-text measurements Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor(loro.js): drop the styled-text replay check that anchors made moot loro-dev#1126 checked snapshot-hydrated styled Text before every full replay because loro.js did not count the style anchors, so a replay of Rust rich text could differ from its snapshot state. With the anchors in the sequence, styled Text replays like plain Text; the unreplayable fallback stays for snapshots whose state disagrees with their own history (context/loro-js-performance.md). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): describe 0.2 snapshots after checkout, fork, and shallow export With loro-dev#1126 on main, a checkout keeps a 0.2 snapshot's state while a fork or shallow export replays its history (Rust's reading); the test pins the fork difference from Rust. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * perf(loro.js): check a transition's sequence elements once per container #canTransitionRecords walked the whole sequence once per insert or mark op, so checking out across scattered edits was O(ops * elements): 668 ms at 8k characters with 2k edits on main, 5 ms now. Adds text-scattered-edits-checkout to bench:complexity. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * perf(loro.js): check a transition's sequence elements once per container #canTransitionRecords walked the whole sequence once per insert op, so checking out across scattered edits was O(ops * elements): 668 ms at 8k characters with 2k edits on main, 4 ms now. Adds text-scattered-edits-checkout to bench:complexity. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): record the transition check measurements Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): record anchor-model constant costs and rich-text checkout Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(loro.js): correct the 0.2 cursor and migration notes Cursors move for side 1 and end-of-text, not astral text; reading the final state with 1.0 needs the snapshot to be the final version; a shallow export switches a 0.2-snapshot document to Rust's reading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loro.js): don't re-insert hidden style anchors when a checkout replays After a snapshot import and a checkout back past a mark, attach, checkout, and diff threw `duplicate sequence id` when the forward records could not move incrementally (a movable-list set, move, or delete): the replay inserted the mark's anchors again although the state already held them. `hasMaterializedSequenceInsertions` now counts anchor ops, and `diff` rebuilds its `from` state in that case too. The differential harness now edits a movable list, checks out three versions of every imported full snapshot, diffs, and attaches; seed 19 fails without this fix. It also derived its second checkout roll from bits that repeated the first. Details: context/loro-js-richtext-anchors.md and context/loro-js-rust-differential.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loro.js): text typed where an undone mark started has no style Undoing a before- or both-expand mark put the restoring mark's start anchor after the undone one (`mark`'s insert rule), so text typed at the old start went between the two anchors and kept the undone value; Rust leaves it unstyled. `_undoStyle` now starts the restoring mark right before the undone style's start anchor. Against Rust, 159 of 160 expand/prior-style/typing cases now give the same text (3 differed before); see context/loro-js-richtext-anchors.md for the remaining one. The differential harness now requires only the loro.js diff to reach its target and logs Rust's (seed 162 at 120 steps shows a Rust diff that disagrees with its checkout). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(loro.js): make the differential checkout's second version independent The second checkout version came from `imul(roll, 0x9e3779b1)`, whose low bits repeat the roll's (the multiplier is 1 modulo 16), so it usually picked the first version again. It now uses the product's high bits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * revert(loro.js): leave the mark-undo boundary fix for a follow-up The scope of loro-dev#1137 was narrowed to N1 and the docs, so this backs out the `_undoStyle` change of d1663c9 and its tests; the known gap is recorded in context/loro-js-richtext-anchors.md. The differential harness keeps the Rust-diff tolerance from that commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(loro.js): show how a 0.2 snapshot plus later updates reads in 1.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#1166) * test(loro.js): add a Rust differential suite for MovableList 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> * test(loro.js): compare event effects and replicate unordered reverts 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> * test(loro.js): join shallow snapshots through Rust and keep replayed undo unchained Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(loro.js): accept no-op sequence events and replicate reverts whose diff shape differs Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(loro.js): skip shallow editors and start range exports at the shallow 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> * test(loro.js): measure revert container depths at the revert target Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(loro.js): run the MovableList differential suite on the shared Rust 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> * fix(loro.js): model MovableList like Rust (positions and LWW elements) 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> * test(loro.js): skip final syncs with a peer that left the run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loro.js): keep pending changes and follow Rust's shallow import rules 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> * test(loro.js): pin diff() of a MovableList child created in the range 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> * perf(loro.js): resolve concurrent MovableList op indices through a tracker 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> * chore(loro.js): describe the merged MovableList fixes in the changeset Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loro.js): never walk a container's fields when comparing event values 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> * test(loro.js): fuzz MovableList in the multi-peer differential CI run 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> * fix(loro.js): apply MovableList diffs deletes first, like Rust's apply_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> * test(loro.js): skip map entries that only delete keys when comparing 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> * docs(loro.js): record snapshot completion on import and its cost 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> * perf(loro.js): check MovableList transition inserts in one tree walk 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> * test(loro.js): fix the suite's changeset and ignore empty diff() deltas 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> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* test(loro.js): add a Rust differential suite for MovableList 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> * test(loro.js): compare event effects and replicate unordered reverts 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> * test(loro.js): join shallow snapshots through Rust and keep replayed undo unchained Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(loro.js): accept no-op sequence events and replicate reverts whose diff shape differs Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(loro.js): skip shallow editors and start range exports at the shallow 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> * test(loro.js): measure revert container depths at the revert target Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(loro.js): run the MovableList differential suite on the shared Rust 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> * fix(loro.js): model MovableList like Rust (positions and LWW elements) 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> * test(loro.js): skip final syncs with a peer that left the run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loro.js): keep pending changes and follow Rust's shallow import rules 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> * test(loro.js): pin diff() of a MovableList child created in the range 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> * perf(loro.js): resolve concurrent MovableList op indices through a tracker 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> * chore(loro.js): describe the merged MovableList fixes in the changeset Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loro.js): make import and importBatch atomic A failed import no longer leaves history, pending changes, version, containers, state or events half applied. In-place history changes are journaled and undone, state is rebuilt only when it had changed, events are emitted after success. Details: context/import-batch-atomicity.md ("loro.js"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loro.js): never walk a container's fields when comparing event values 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> * test(loro.js): fuzz MovableList in the multi-peer differential CI run 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> * fix(loro.js): apply MovableList diffs deletes first, like Rust's apply_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> * test(loro.js): skip map entries that only delete keys when comparing 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> * docs(loro.js): record snapshot completion on import and its cost 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> * perf(loro.js): check MovableList transition inserts in one tree walk 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> * test(loro.js): fix the suite's changeset and ignore empty diff() deltas 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> * perf(loro.js): journal snapshot-sequence writes instead of copying per import Each import copied the whole snapshot-sequence table for rollback, so its cost grew with the number of snapshot-hydrated containers. Record each write in the import journal instead (context/import-batch-atomicity.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…) (#1168) * test(loro.js): add a Rust differential suite for MovableList 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> * test(loro.js): compare event effects and replicate unordered reverts 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> * test(loro.js): join shallow snapshots through Rust and keep replayed undo unchained Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(loro.js): accept no-op sequence events and replicate reverts whose diff shape differs Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(loro.js): skip shallow editors and start range exports at the shallow 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> * test(loro.js): measure revert container depths at the revert target Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(loro.js): run the MovableList differential suite on the shared Rust 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> * fix(loro.js): model MovableList like Rust (positions and LWW elements) 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> * test(loro.js): skip final syncs with a peer that left the run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loro.js): keep pending changes and follow Rust's shallow import rules 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> * test(loro.js): pin diff() of a MovableList child created in the range 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> * perf(loro.js): resolve concurrent MovableList op indices through a tracker 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> * chore(loro.js): describe the merged MovableList fixes in the changeset Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loro.js): make import and importBatch atomic A failed import no longer leaves history, pending changes, version, containers, state or events half applied. In-place history changes are journaled and undone, state is rebuilt only when it had changed, events are emitted after success. Details: context/import-batch-atomicity.md ("loro.js"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loro.js): reject MovableList moves and sets of elements outside their history Port of #1125; see context/loro-js-movable-list.md (Validation). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loro.js): never walk a container's fields when comparing event values 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> * test(loro.js): fuzz MovableList in the multi-peer differential CI run 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> * fix(loro.js): apply MovableList diffs deletes first, like Rust's apply_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> * test(loro.js): skip map entries that only delete keys when comparing 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> * docs(loro.js): record snapshot completion on import and its cost 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> * perf(loro.js): check MovableList transition inserts in one tree walk 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> * test(loro.js): fix the suite's changeset and ignore empty diff() deltas 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> * perf(loro.js): journal snapshot-sequence writes instead of copying per import Each import copied the whole snapshot-sequence table for rollback, so its cost grew with the number of snapshot-hydrated containers. Record each write in the import journal instead (context/import-batch-atomicity.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Base of a stack: #1126 → #1127 → #1128. Merge the three back to back (see "Merge order").
Summary
On
main,checkout/diffon aloro.jsdocument imported from a full or shallow snapshot can return wrong state, throw, or persist a wrong latest state:{"a":2,"b":3}instead of{"a":1}).list: []instead of[1]).duplicate sequence id N@P.event diff position N is out of rangeduring checkout, leaving a half-updated state.Design
Details and rationale:
context/loro-js-performance.md.Per-container completion. A Text or List hydrated from a snapshot is recorded in
#snapshotSequences. The first transition that crosses one of its snapshot operations replays only that container from its own history, once (#prepareSnapshotTransition,#completeSnapshotSequence). Unrelated containers stay as they are, and the lazy SSTable is kept.Unreplayable Text. When the replay does not reproduce the snapshot state (visible ids, plus the delta when styled), the replay is discarded and the container keeps its snapshot state. Transitions move it separately: by id when the installed state already has the forward operations, otherwise by rebuilding from the snapshot state plus later operations.
Full rebuilds check styled history. Before a full replay (the non-incremental fallback, shallow export,
forkAt), every hydrated Text that has or had styles is compared first:#markedTexts).A mark whose characters were all deleted leaves no style, but its anchors still shift later Rust positions. Plain text is not checked, so it pays nothing extra.
MovableList stays on main's path. Its Rust snapshot names moved elements by position id, and its hydrated state has no move or value history. A transition that crosses its snapshot operations therefore replays to the target, as on
main. The context doc explains why the other options were worse.forkAt/forkkeep a snapshot state only when the fork's version includes it. An older fork replays its own operations, as onmain.Transitions deduplicate sequence elements by id (
SequenceElementSet).Atomicity. Nothing is left half-updated when something throws:
#transitionTo, including its preparation);diffrestores the current state when it, or its move back, throws.CI.
.github/workflows/rust.ymlruns for any PR base and adds aloro-jsjob (pnpm -C loro-js check).Merge order
Do not release this PR without #1127. On its own, this PR guards root-time Map/Tree winners of a shallow document with a full rebuild. The round-3 review measured 83–88 ms for every return to the root of a 32k-Map shallow document; #1127 makes that 0.014 ms. Merge #1126, #1127, and #1128 back to back, or merge #1128's branch.
Known limitations
revertToresults thanmain, whilemaincorrupts the latest state instead.main.main:mainis right. Round-4 seed 22: after checking out v11, v12, then v13, the list shows[11], where Rust,main, and a full-history document show[9]. The latest state andrevertToare right.main:s=1299:qqhhbbbcccbb, Rustqqhhbbbbbccc). This happens for plain text too, and fix(loro.js): converge plain-text edits with Rust, add a Rust differential suite #1136 fixes the plain-text case.#encodeShallowSnapshotrebuilds without a restore.Integration with #1136 / #1137 (whichever lands second resolves)
SequenceElementSetwith the same semantics; keep one. There is also one comment conflict indocument.ts.containers.tsand 3 indocument.ts.LoroText._styleIndex: it is mutableTextStyleIndex<TextStyleMeta>here andreadonly TextStyleIndex<TextStyle>there. It must stay swappable.LoroText._swapState/TextStatemust also swap fix(loro.js): keep rich-text style anchors in the text sequence #1137's anchor state (#anchorStarts, and whatever replaces_detachedStyleCounter). Otherwise a completion leaves stale anchors behind.#buildStateStore(frontiers, retained)) and fix(loro.js): keep rich-text style anchors in the text sequence #1137's style redaction (#buildStateStore(frontiers, { redacted, only })) need one combined signature, and the export needs both.Validation
loro-js/tests/snapshot-checkout.test.ts, with Rust fixturesstyled-text,rich-text-history, andmovable-moves;loro-js/tests/version-transition.test.ts, which covers the concurrent-delete dedupe and fault injection for checkout, completion, anddiff.deleted-mark.jsoncovers a Rust text whose marked characters were deleted. The test runs checkout → attach, a live shallow export and its blob, andforkAt(latest); it fails on round-48ef6818d(hloXinstead ofhlXo).pnpm -C loro-js checkpasses with 277 tests. The fork/MovableList/fault tests fail on round-31c5ff489.checkout(f)→ shallow export atg→attachover every (f, g) of 600 random 2–3-peer Rust Text histories, plus a variant that deletes marked text:main8ef6818dforkAt(latest)):main110/110/93/93; round 4 0/0/0/0, and 0/0/3/3 with the delete variant; now 0 in both.main29553d5b): exports of detached, forked, and shallow documents re-import in Rust as the loro.js value, and the rich-text invariance fuzz passes 240/240.main1c5ff489forkAtText / MovableList (360)revertToText / MovableList (360)Performance
Setup: Apple M5 Pro (18 cores, shared with other sessions), Node 22.23.1.
main(29553d5b), the round-3 heads, and the current heads.First checkout after a snapshot import (ms, median of 5; load1 4.1–6.0, median 4.9, per-variant medians within 0.2):
main*1c5ff4898ef6818d* On
main, the Text rows return N−1 characters and the Map row returns the latest value.bench:complexity 1000,2000,4000,8000(7 rounds × 7 variants, load1 5.2–6.0): 0 of 852 entries flagged againstmain, and 0 against the round-3 heads. A flag means over 1.5× the baseline and more than 0.3 ms slower. The largest ratios are entries under 0.2 ms, up to 1.97×.Round 5: full-rebuild paths, 262,144 characters (5 alternating rounds with
mainand the round-4 heads, load1 5.0–6.0), in ms:mainforkAt(latest)/ first checkoutThe last row is the price of R4-B1: one replay of that text on a full-rebuild path. Plain text is unchanged.
B4, 259,778 actions (3 rounds, load1 5.6–6.0), in ms. The results are byte-identical.
main🤖 Generated with Claude Code