Skip to content

fix(loro.js): revive a List child that a replay-fallback event re-inserts - #1150

Merged
zxch3n merged 32 commits into
mainfrom
fix/loro-js-revived-child-events
Sep 29, 2026
Merged

zxch3n merged 32 commits into
mainfrom
fix/loro-js-revived-child-events

Conversation

@zxch3n

@zxch3n zxch3n commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #1131 (merged as 238988f5); now based on main, and the diff against main is the 9-line change below plus the test.

Summary

#1131 now makes #emit report the whole state of a child container that an import or checkout re-attaches (#completeDiffEntries). That covers most of what this PR originally did, so the original #reviveAttachedChildren is gone. One case is left.

When a checkout falls back to replaying history (for example, after updates were imported while detached), a plain List diff is computed from whole values. It can delete and re-insert an unchanged child container. #completeDiffEntries skips children that were already reachable through that slot at from. That exemption is meant for movable-list moves, but it applies to every sequence. The event therefore re-inserts the child without its content, and a listener that starts an inserted child from empty (as the Rust fuzzer's ContainerTracker does) loses it: ["s", true, "", false] instead of ["s", true, "a", false].

Change

For events only (forEvent), the exemption applies to LoroMovableList parents, where a listener keeps the moved child. diff() and revertTo() keep #1131's behavior. The change is 9 lines in document.ts.

Validation

🤖 Generated with Claude Code

zxch3n and others added 30 commits September 28, 2026 01:06
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>
…le 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>
…ve 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>
…oot 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>
…peated 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>
… 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>
…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>
…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>
…ved-child-content

# Conflicts:
#	loro-js/src/runtime/document.ts
…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>
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>
…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>
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>
…ith Rust #1134

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

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>
…fore 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>
… 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>
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ved-child-content

# Conflicts:
#	loro-js/src/runtime/document.ts
#1131 now revives re-attached children in #emit itself
(#completeDiffEntries), so take its version and drop
#reviveAttachedChildren. One case is left: its reachability exemption
covers every sequence, but a listener only keeps a moved child for a
movable list. For events, apply the exemption to movable lists only, so
a child that a whole-value List diff (the replay fallback) deletes and
re-inserts is revived. diff()/revertTo keep #1131's behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zxch3n zxch3n changed the title fix(loro.js): send the full state of child containers attached by an event fix(loro.js): revive a List child that a replay-fallback event re-inserts Sep 29, 2026
…hild-events

# Conflicts:
#	loro-js/src/runtime/document.ts
@zxch3n
zxch3n changed the base branch from fix/loro-js-diff-revived-child-content to main September 29, 2026 11:15
@github-actions

Copy link
Copy Markdown
Contributor

WASM Size Report

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

@zxch3n
zxch3n merged commit 3435bac into main Sep 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant