Skip to content

[pull] main from loro-dev:main - #204

Merged
pull[bot] merged 3 commits into
Superoldman96:mainfrom
loro-dev:main
Sep 29, 2026
Merged

pull[bot] merged 3 commits into
Superoldman96:mainfrom
loro-dev:main

Conversation

@pull

@pull pull Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

zxch3n and others added 3 commits September 29, 2026 18:20
…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>
* 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 (#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 #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

#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 #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 #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>
…d revertTo() (#1131)

* fix(loro.js): include reattached child state in diff() and revertTo()

A child container without ops in the range was emitted only as a reference,
so applyDiff/revertTo recreated it empty. Emit its full state, as Rust does.

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

* fix(loro.js): emit full state only for containers that become reachable in diff()

Emit a child's whole state only when it is unreachable at `from`, replace a
partial diff for such a child, include revived tree node metadata, and order
the result parent-first. Moved movable-list children and mergeable children
keep only their own ops. applyDiff now moves an existing movable-list child
instead of recreating it.

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

* fix(loro.js): apply movable-list moves minimally, undo them, and revive tree subtrees in diff()

Port Rust's MovableListHandler::apply_delta so a moved child becomes one
move instead of reordering the whole list, undo movable-list moves through
their recorded neighbors, and emit creates for every node that becomes
reachable, including descendants of revived nodes and nodes moved out of a
deleted ancestor.

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

* fix(loro.js): read a trimmed tree node's placement from the shallow root in diff()

On a shallow doc a node created before the root has no retained op at the
root version; use its root-state placement instead of treating it as revived.

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

* fix(loro.js): resolve movable-list moves by element identity, undo repeated moves, and restore mergeable markers on undo

applyDiff now finds moved children through their inserted ids and places
every inserted item after its final-order predecessor, so unmerged delete
items, several children moved out of one range, and scalar-only deletes are
handled without index bookkeeping or scans. Undo reverts earlier moves of an
element that only this document moved since. A wide revived subtree is walked
iteratively. Undoing a mergeable key deletion re-ensures the marker.

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

* fix(loro.js): delete a moved-in live node before recreating a revived subtree in diff()

A node alive at `from` inside a subtree revived by the range (e.g. a child
moved out of a deleted parent and then reverted back under it) was created
again while its old copy stayed. Delete the topmost such nodes first, as
Rust's diff does, then create the subtree.

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

* fix(loro.js): tracked-span move undo, exact physical undo placement, delta validation, and linear tree revival

- Undo only lets later moves the UndoManager tracks (its recorded edits and
  the inverses it wrote) pass; excluded-origin and remote moves block.
- Each move records its physical successor; undo puts the element back
  there, so redo after mixed moves and inserts restores the original order.
- applyDiff validates list deltas for the whole batch before applying,
  hydrates lazily imported lists before looking for moved children, and
  deletes only the gaps between kept positions.
- Tree revival memoizes "alive at from" along ancestor paths and emits the
  deletes of moved-in live nodes first, with their index at `from`.
- Undo restores a mergeable marker over a later overwrite.

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

* fix(loro.js): undo a move next to its old physical neighbor that has not moved since

The old successor may have been moved by an untracked op; prefer the old
predecessor, then the successor, and write no op when the element is already
there. Matches Rust in 70 of 72 neighbor-change cases (the other two: Rust
revives a deleted element as an empty container).

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

* fix(loro.js): bound the undo manager's tracked ranges by its stacks

Drop tracked ranges that end before the earliest stacked op of their peer,
and all of them on clear/destroy; only later moves are ever checked.

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

* fix(loro.js): compute tree delete indexes at `from` once per parent, in apply order

Put the diff's own deletes and the moved-in deletes first, resolve each
parent's sibling order at `from` from history (nodes that only exist at
`from` included), and count indexes with a Fenwick tree as the deletes are
applied. Fixes indexes next to new roots and the quadratic rescans.

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

* fix(loro.js): validate an applyDiff batch against simulated list lengths

Simulate list lengths in batch order before any write: lists created by an
earlier entry start empty, repeated entries see the previous result, and the
lookup creates nothing (no empty root after a rejected batch). Entries whose
bounds were checked skip the second validation.

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

* fix(loro.js): align mergeable diff, applyDiff fullState, and events with Rust #1134

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

* docs: point the mergeable context at the loro.js alignment

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

* fix(loro.js): validate a hidden mergeable list against its hidden length

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

* fix(loro.js): prune undo tracked ranges from the stack bottoms by lamport

Pruning visited both stacks on every commit (quadratic sessions), and
compared a peer's ranges only with that peer's items, so after a peer
change it could drop a range a stacked move still needed.

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

* fix(loro.js): emit tree diffs in apply order and reject tree items before writing

Tree diff items now apply in order, as in Rust: moves of live nodes
(parent first), then the topmost deletes, then revived subtrees, with
every index counted in the tree the earlier items leave. applyDiff
applies tree items in order and dry-runs them with the list checks, so
a failing batch writes nothing. A node hidden at the target version is
emitted as a delete.

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

* perf(loro.js): memoize hidden tree nodes so deep trees diff in linear time

The reachability filter and tree revival checked each node's ancestors
without a cache (quadratic in depth). Fractional indexes are now built
as flat strings.

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

* fix(loro.js): resolve applyDiff tree items and map children as Rust does

A create of a node that is alive here moves it, a move of a missing or
deleted node creates a new one, and a delete of a deleted node does
nothing, so default applyDiff on a doc holding a mergeable tree's hidden
state no longer duplicates its nodes. A regular map child is always set
as a new container, so its full state is not appended to a hidden copy.

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

* docs(loro.js): document fullState for event mirrors and the batch checks

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

* docs: add the loro.js tree diff context

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

* test(loro.js): check deep-tree linearity by scaling, not wall time

The CI runner is several times slower than a dev machine, so the 1.5 s
bound flaked; compare 8,000-deep with 2,000-deep instead.

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

* test(loro.js): shrink the deep-tree scaling test for slow CI runners

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@pull pull Bot locked and limited conversation to collaborators Sep 29, 2026
@pull pull Bot added the ⤵️ pull label Sep 29, 2026
@pull
pull Bot merged commit 238988f into Superoldman96:main Sep 29, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant