Skip to content

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

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

pull[bot] merged 7 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 7 commits September 29, 2026 21:19
…1144)

loro.js rooted a shallow snapshot at the requested version, or at the
meet of its heads. A branch merged after that version but forked below
it is concurrent with it, so a retained op straddled the root. Checking
out retained versions and returning could then reorder concurrent
Text/List elements, and the root differed from a Rust export.

Port Rust's latest_single_head_critical_version (dag.rs) and use it with
the latest frontiers, as calc_shallow_doc_start does. The StyleStart
nudge and the clamp to an existing shallow root are unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three API calls produced different ops than Rust, so the same edits got
different op IDs, and a concurrent map set/delete could resolve
differently:

- Map.delete of an absent key recorded nothing; Rust records a delete.
- Counter.increment(0) and attaching a detached counter with value 0
  recorded nothing; Rust records the increment.
- A text delete across several ID runs was emitted left to right; Rust's
  TextHandler::delete emits the runs right to left.

The old "no semantic no-op edits" test asserted the loro.js behavior; it
now asserts Rust's. MovableList set-to-same-value (Rust records it) is
left to the MovableList rework.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…erts (#1150)

* 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): send the full state of child containers attached by an event

When a checkout or import attached a child container again (a map key
set back to an older text, a restored list element), the child's event
was missing or was a delta against the state it had while hidden. A
listener starts an attached child from empty, so it ended up with wrong
content, or with an invalid delta ("text span beyond 0").

Like Rust's DocState::apply_diff, revive every child that a batch
attaches: its event becomes its whole state, recursively, and events are
ordered parent first. A movable-list child that only moved keeps its
delta. Uses #attachedChildren and #reachableThroughParentAt from the
diff() fix this branch is based on.

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

Rust's checkout returns early when the target equals the state's
frontiers, re-attaching when they are the latest frontiers, and also
when the shrunk target equals them. loro.js always detached, so
`doc.checkout(doc.frontiers())` stopped later imports from reaching the
state and made loro.js peers diverge from loro-crdt ones that ran the
same calls.

The old detached-import test relied on this; it now uses detach().

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* test(loro.js): add a multi-peer differential fuzz harness

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(loro.js): match Rust's local tree create and move positions

- Port generate_fi_at's rearrangement: when both neighbors share a
  fractional index, the right neighbor and following siblings with that
  index get new positions (generate_n_evenly) through extra move ops.
  loro.js threw "fractional index bounds are not ordered" instead.
- Like mov_with_txn, a move to the node's current index, including an
  omitted index when it is already last, records nothing.
- Like mov_after/mov_before, count the target index without the moved
  node when it shares the parent.

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

* fix(loro.js): stop the local tree cycle check at deleted nodes

Rust's is_ancestor_of treats a deleted node's parent as the deleted
root. loro.js kept walking a deleted node's old parent, so moving a node
under its own deleted child threw. The walk is also bounded by the node
count: a cycle left by concurrent moves made it loop forever.

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

* fix(loro.js): count siblings in O(1) when checking a same-parent tree move

The no-op check listed every sibling to count them, so each move within a
parent was linear in the sibling count.

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): treat containers under deleted ancestors as deleted

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@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 4b04934 into Superoldman96:main Sep 29, 2026
2 checks passed
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