Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/loro-js-checkout-current-attached.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"loro.js": patch
---

`checkout(frontiers)` on a `loro.js` document now behaves like `loro-crdt` when `frontiers` is the current version: nothing changes, and the document stays (or becomes) attached when that version is the latest one. Before, `doc.checkout(doc.frontiers())` detached the document, so later imports were not applied to its state. Checking out the latest version from an older one still leaves the document detached, as in Rust.
5 changes: 5 additions & 0 deletions .changeset/loro-js-deleted-ancestors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"loro.js": patch
---

`loro.js` now treats a container as deleted when any ancestor is deleted, as `loro-crdt` does: `isDeleted()`, `getPathToContainer()`, and `getCursorPos()` account for deleted ancestors. Descendants of a deleted tree node are deleted: `getNodes()` omits them, and `isNodeDeleted()`/`LoroTreeNode.isDeleted()` return `true`. `LoroTree.nodes()` returns every node, including deleted ones, and `getNodes()` lists alive nodes breadth-first from the roots, matching Rust.
8 changes: 8 additions & 0 deletions .changeset/loro-js-local-op-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"loro.js": patch
---

`loro.js` now records the same local ops as `loro-crdt` for these API calls, so a document edited through either package gets the same op IDs and the same concurrent outcomes:

- `LoroMap.delete(key)` records a delete even when the key is absent. Like in Rust, that delete can win against a concurrent `set` of the same key.
- `LoroCounter.increment(0)` records an increment, and attaching a detached counter records an increment by its value, even 0.
5 changes: 5 additions & 0 deletions .changeset/loro-js-revived-child-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"loro.js": patch
---

Fix a `loro.js` checkout event for a child container in a List when the checkout falls back to replaying history, for example after updates were imported while the document was detached. The List diff could delete and re-insert an unchanged child container without sending its content, so a listener that starts an inserted child from empty lost that child's content. The child's whole state is now sent, as for any other re-attached child.
5 changes: 5 additions & 0 deletions .changeset/loro-js-shallow-critical-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"loro.js": patch
---

`loro.js` shallow snapshots now pick the same root as `loro-crdt`: the latest single-head critical version of the requested frontiers and the latest version (loro-dev/loro#1095). Before, `loro.js` used the requested version (or the meet of its heads) even when a retained op was concurrent with it. Checking out retained versions of such a snapshot and returning to the latest version could reorder concurrent Text/List elements, and the snapshot disagreed with a Rust export of the same document.
10 changes: 10 additions & 0 deletions .changeset/loro-js-tree-local-positions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"loro.js": patch
---

Fix `loro.js` local tree moves and creates to match `loro-crdt`:

- Creating or moving a node between two siblings that share a fractional index (for example two roots created concurrently at index 0) threw `fractional index bounds are not ordered`. It now gives the following siblings with that index new positions in the same transaction, as Rust does.
- Moving a node to the position it already has, including `move(node, parent)` without an index when it is already last, `node.move(parent)`, and `moveAfter`/`moveBefore` its current neighbor, no longer records an op.
- `moveAfter` and `moveBefore` within the same parent counted the moved node itself, so moving a node after a later sibling threw `tree index … is out of range` or landed one slot off.
- Moving a node under one of its own deleted descendants threw `cannot move a tree node below itself or its descendant`. A deleted node's parent is the deleted root, as in Rust, so the move is allowed (and the node becomes deleted).
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ Loro is a Rust CRDT workspace with JS/WASM packaging and a MoonBit codec.
work around: [context/loro-js-rust-differential.md](context/loro-js-rust-differential.md).
- loro.js rich-text style anchors, entity positions, and Rust's insert/delete/mark
position rules: [context/loro-js-richtext-anchors.md](context/loro-js-richtext-anchors.md).
- loro.js multi-peer differential fuzzing against replay, checkout, event, and
Rust/WASM oracles: [context/loro-js-differential-fuzz.md](context/loro-js-differential-fuzz.md).
- WASM panic/OOM reporting channels, `__wbindgen_start` glue invariant, and
trap-testing recipes:
[context/wasm-error-reporting.md](context/wasm-error-reporting.md).
Expand Down
135 changes: 135 additions & 0 deletions context/loro-js-differential-fuzz.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# loro.js Differential Fuzzing

Verified against code 2026-09-29.

`loro-js/tests/fuzz/differential.ts` drives several `loro.js` peers through
random edits, update exchanges, and checkouts, and checks them against
independent oracles. `loro-js/tests/differential-fuzz.test.ts` runs a fixed,
CI-sized seed set by default and larger runs through environment variables.

## Scenario model

A scenario is a list of abstract actions: a local edit (`op`), an update
exchange (`sync`), or a checkout. Each action carries random 32-bit parameters
that are resolved against the acting document when it runs (container, position,
value). Removing actions therefore keeps the rest meaningful, which is what
`minimizeScenario` (delta debugging on the action list) relies on.

- Containers: root Map/List/Text/Tree/Counter/MovableList, and child
containers in maps, lists, and tree metadata (`FuzzFeatures`).
MovableList and rich-text marks are off in `CORE_FEATURES`.
- Syncs: `update` from the receiver's oplog version, full updates, one blob per
missing change in a shuffled order (`import` or `importBatch`), and optionally
full snapshots into a non-empty document.
- Checkouts go to a random op or to the union of two ops, and either return to
the latest version or leave the peer detached until its next edit.

## Oracles

- Every peer, after a final full exchange, equals a document that imports all
updates at once, and one that imports each change separately in a shuffled
order.
- `checkout(F)` equals a fresh document that imports only the changes in `F`
(`updates-in-range` from counter 0), for every op of a small history.
- A full snapshot and three shallow snapshots equal the full-history document,
including checkouts into the retained range and back.
- `EventMirror` applies every event batch to a model keyed by container ID and
must reproduce `toJSON()` after each commit, import, and checkout. It follows
the Rust fuzzer's `ContainerTracker` (`crates/fuzz/src/value.rs`): attaching a
container value restarts that child from an empty state, a movable-list move
keeps the moved child's state, tree `delete` removes the subtree, and every
sequence index must be valid when the items are applied in order.

## Rust/WASM reference

With `FUZZ_WASM=1` (or a path to `loro_wasm.js`), each peer also has a
`loro-crdt` twin with the same peer ID. In lockstep mode (default when WASM is
loaded) every concrete edit, sync, and checkout runs on both, and the values
are compared after each action. The first mismatch names the action where the
implementations diverge. After the scenario, a Rust replay, Rust checkouts, and
shallow snapshots exchanged in both directions are compared too, including the
shallow root each side picks. `FUZZ_VALIDATE_HARNESS=1` checks only Rust's own
event mirror; use it after changing `EventMirror`.

`FUZZ_WASM=1` loads the same reference as the other differential suites
(`tests/support/rust-reference.ts`, `LORO_WASM_NODEJS` overrides the path).
Lockstep requires both implementations to emit the same local ops for the same
API calls; an op-generation difference shows up as `lockstep` or
`lockstep-throws` on the first affected action. On `main` without #1145 and #1143,
most lockstep seeds diverge at the first delete of an absent map key, zero
counter increment, or checkout of the current version. With #1145 alone, 49 of
50 Map/List/Text/Counter seeds matched Rust step by step; the remaining one is
the #1143 case.

## Running

```sh
cd loro-js
pnpm vitest run tests/differential-fuzz.test.ts # fixed CI seeds
FUZZ_SEEDS=0-499 FUZZ_FEATURES=core FUZZ_WASM=1 FUZZ_MINIMIZE=1 \
FUZZ_OUT=/tmp/report.json pnpm vitest run tests/differential-fuzz.test.ts
```

`FUZZ_CHECKS` narrows the oracles (`events,checkout,snapshot,shallow`),
`FUZZ_LOCKSTEP=0` compares with Rust only after the scenario, and `FUZZ_TRACE=1`
prints each action as it runs, which finds hangs. The report groups failures by
signature (oracle plus first differing root or normalized error) and stores the
minimized action list and a trace written as `loro.js` API calls.

CI runs 120 fixed seeds of Map/List/Text/Counter, and 120 more with nested
child containers and snapshot imports, with the replay, checkout,
full-snapshot, and event oracles. A seed that fails on `main` for a known
reason goes into `pending` with that reason until its fix lands; both lists are
empty on 2026-09-29 (`main` at #1131).

## Divergences found (2026-09-28/29)

Fixed on `main`: Fugue insert order after the origin's last child, doubled
checkout events for concurrent deletes, and text delete run order (#1136);
deletes imported while detached (#1126).

Open PRs: a `checkout(currentFrontiers)` that detached (#1143), shallow roots
that were not critical versions (#1144; without it shallow snapshots still
give wrong values after retained checkouts, sometimes also at the latest
version), absent-key map deletes and zero counter increments (#1145),
liveness of containers under deleted ancestors (#1147), local tree positions
and no-op moves (#1148), and a List child that a replay-fallback event
re-inserts (#1150). With nested children and snapshot imports, seeds 297,
315, 344, 422, 452, and 559 of 0–599 fail on `main` in the event oracle;
#1150 fixes all six.

Still open, so `CI_RUNS` does not enable them yet:

- Tree convergence: `loro.js` applies tree moves per node, last writer wins,
with no cycle check. Two peers that move `x` under `y` and `y` under `x`
converge to a cycle, so both nodes disappear (Rust keeps `y` as a root with
child `x`). Rust applies a tree's ops in (lamport, peer) order and skips a
move whose new parent descends from the target (`TreeCacheForDiff::apply`,
`diff_calc/tree.rs`). A port needs, per tree, the ops in that order with an
effective flag. A new op applies directly when it is the newest; otherwise
the suffix after it is retreated and reapplied. Version transitions replay
the suffix from the lowest changed op, and the shallow-root placement
fallback from #1127 applies when a node's retained ops are all retreated.
- Tree events: items are a before/after snapshot diff that lists final
indexes in op order. Importing two roots created at index 0 emits
`create 0@2 @1, create 1@2 @0` (Rust: `@0, @0`); creating a child and
deleting its parent in one commit emits the delete first; reviving a node
omits `create` for its children. Rust records an item per effective op at
the moment it is applied, plus a `create` for each child of a revived node
(`TreeState::apply_diff_and_convert`). This belongs with the port above.
- Events for containers that are unreachable after the batch: Rust drops them
(`DocState::get_path`); `loro.js` still sends them. Needs #1147's liveness.
- Shallow snapshots in the harness need #1144 (the root check fails first on
every seed without it).
- MovableList: `loro.js` skips a movable-list `set` to the current value,
which Rust records.

## Rust-side findings

Found while comparing; all four reproduce on the 1.16 release and are fixed
on `main` (checked on 2026-09-29 at `c0ff1255`, after #1135, #1152, and
#1153): an `Op/hint length mismatch` panic when deleting across an astral
character after `checkout` + `attach` with a subscriber; a `tree_state.rs`
panic in a subscribed checkout sequence; a tree node kept by a checkout path
between concurrent versions; and tree metadata left marked deleted after a
local move revived it.
3 changes: 3 additions & 0 deletions loro-js/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ the same binary and JSON data as Rust (`loro-crdt`) and give the same results.
[context/loro-js-performance.md](../context/loro-js-performance.md).
- Randomized Text comparison with Rust (build the nodejs WASM package first):
[context/loro-js-rust-differential.md](../context/loro-js-rust-differential.md).
- Multi-peer fuzzing of all containers against replay, checkout, event, and
shallow-snapshot oracles, optionally in lockstep with Rust:
[context/loro-js-differential-fuzz.md](../context/loro-js-differential-fuzz.md).
- Rich text: style anchors live in the Text sequence and op positions count
them. Read [context/loro-js-richtext-anchors.md](../context/loro-js-richtext-anchors.md)
before changing Text positions, marks, or Text snapshot state.
Expand Down
106 changes: 90 additions & 16 deletions loro-js/src/runtime/containers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { bytesToHex } from "../codec/bytes";
import { bytesEqual, bytesToHex } from "../codec/bytes";
import {
containerTypeFromHistoricalByte,
containerTypeToHistoricalByte,
Expand All @@ -7,7 +7,7 @@ import { PostcardReader, PostcardWriter } from "../codec/postcard";
import type { ContainerId as CodecContainerId, Id as CodecId } from "../codec/types";
import { formatContainerId, formatTreeId, parseContainerId } from "./ids";
import type { LoroDoc } from "./document";
import { fractionalIndexBetween } from "./fractional-index";
import { fractionalIndexBetween, fractionalIndexesBetween } from "./fractional-index";
import { OrderedIndex } from "./ordered-index";
import { SequenceIndex, SequenceSpan } from "./sequence-index";
import type { SequenceIdRun } from "./sequence-index";
Expand Down Expand Up @@ -149,7 +149,7 @@ export abstract class LoroContainer {
}

isDeleted(): boolean {
return this._doc?._isContainerDeleted(this) ?? false;
return this._doc?._isContainerUnreachable(this) ?? false;
}

subscribe(listener: (event: LoroEventBatch) => void): Subscription {
Expand Down Expand Up @@ -2916,7 +2916,20 @@ export class LoroTree<

isNodeDeleted(target: TreeID): boolean {
this._ensureHydrated();
return this._nodes.get(target)?.deleted ?? false;
const record = this._nodes.get(target);
return record !== undefined && this._isNodeHidden(record);
}

/** Whether the node or one of its ancestors is deleted. */
_isNodeHidden(record: TreeNodeRecord): boolean {
let current: TreeNodeRecord | undefined = record;
// A parent chain longer than the node count can only be a cycle.
for (let steps = 0; steps <= this._nodes.size; steps += 1) {
if (current === undefined || current.deleted) return true;
if (current.parent === undefined) return false;
current = this._nodes.get(formatTreeId(current.parent));
}
return true;
}

enableFractionalIndex(jitter = 0): void {
Expand All @@ -2940,15 +2953,34 @@ export class LoroTree<
return record === undefined ? undefined : new LoroTreeNode(this, record.id);
}

/**
* Alive nodes in breadth-first order from the roots, like Rust. With
* `withDeleted`, the deleted nodes and their subtrees follow.
*/
getNodes(options: { withDeleted?: boolean } = {}): LoroTreeNode<T>[] {
this._ensureHydrated();
return [...this._nodes.values()]
.filter((record) => options.withDeleted === true || !record.deleted)
.map((record) => new LoroTreeNode<T>(this, record.id));
const records = this._subtreeRecords(this._childrenOf(undefined));
if (options.withDeleted === true) {
const deleted = [...this._nodes.values()].filter((record) => record.deleted);
for (const record of this._subtreeRecords(deleted)) records.push(record);
}
return records.map((record) => new LoroTreeNode<T>(this, record.id));
}

/** Every node, including deleted ones, as in Rust. */
nodes(): LoroTreeNode<T>[] {
return this.getNodes();
this._ensureHydrated();
return [...this._nodes.values()].map(
(record) => new LoroTreeNode<T>(this, record.id),
);
}

_subtreeRecords(starts: readonly TreeNodeRecord[]): TreeNodeRecord[] {
const output = [...starts];
for (let index = 0; index < output.length; index += 1) {
for (const child of this._childrenOf(output[index]!.id)) output.push(child);
}
return output;
}

roots(): LoroTreeNode<T>[] {
Expand Down Expand Up @@ -3028,11 +3060,18 @@ export class LoroTree<
);
}

/**
* Port of Rust's `generate_fi_at`: the position for a node placed at
* `index` among `parent`'s children, not counting `exclude`. When both
* neighbors share a position there is no index between them, so the right
* neighbor and every following sibling with that position get new
* positions in `rearranged`, in order.
*/
_positionFor(
parent: CodecId | undefined,
index?: number,
exclude?: CodecId,
): Uint8Array {
): { position: Uint8Array; rearranged: { id: CodecId; position: Uint8Array }[] } {
const children = this._children.get(treeParentKey(parent));
const excluded =
exclude === undefined ? undefined : this._nodes.get(formatTreeId(exclude));
Expand All @@ -3053,10 +3092,29 @@ export class LoroTree<
? siblingIndex + 1
: siblingIndex,
);
return fractionalIndexBetween(
position === 0 ? undefined : siblingAt(position - 1)!.position,
position === length ? undefined : siblingAt(position)!.position,
);
const left = position === 0 ? undefined : siblingAt(position - 1)!.position;
const right = position === length ? undefined : siblingAt(position)!;
if (left === undefined || right === undefined || !bytesEqual(left, right.position)) {
return { position: fractionalIndexBetween(left, right?.position), rearranged: [] };
}
const reset = [right];
let nextRight: Uint8Array | undefined;
for (let sibling = position + 1; sibling < length; sibling += 1) {
const record = siblingAt(sibling)!;
if (!bytesEqual(record.position, left)) {
nextRight = record.position;
break;
}
reset.push(record);
}
const positions = fractionalIndexesBetween(left, nextRight, reset.length + 1);
return {
position: positions[0]!,
rearranged: reset.map((record, offset) => ({
id: record.id,
position: positions[offset + 1]!,
})),
};
}

_setRecord(record: TreeNodeRecord): void {
Expand Down Expand Up @@ -3198,12 +3256,28 @@ export class LoroTreeNode<T extends Record<string, unknown> = Record<string, unk
}

moveAfter(target: LoroTreeNode<T>): void {
// Rust's mov_after: the index is counted without this node.
const parent = target.parent();
this.move(parent, target.index() + 1);
let index = target.index() + 1;
if (this.#hasParent(parent) && this.index() < index) index -= 1;
this.move(parent, index);
}

moveBefore(target: LoroTreeNode<T>): void {
this.move(target.parent(), target.index());
const parent = target.parent();
let index = target.index();
if (this.#hasParent(parent) && index >= 1 && this.index() < index) index -= 1;
this.move(parent, index);
}

#hasParent(parent: LoroTreeNode<T> | undefined): boolean {
const record = this.#record();
return (
!record.deleted &&
(parent === undefined
? record.parent === undefined
: record.parent !== undefined && formatTreeId(record.parent) === parent.id)
);
}

parent(): LoroTreeNode<T> | undefined {
Expand Down Expand Up @@ -3233,7 +3307,7 @@ export class LoroTreeNode<T extends Record<string, unknown> = Record<string, unk
}

isDeleted(): boolean {
return this.#record().deleted;
return this.#tree._isNodeHidden(this.#record());
}

getLastMoveId(): { peer: string; counter: number } {
Expand Down
Loading
Loading