Skip to content

fix: restore deleted mergeable children once on revert/apply_diff/undo - #1134

Merged
zxch3n merged 10 commits into
mainfrom
fix/mergeable-revert-duplicate-content
Sep 28, 2026
Merged

zxch3n merged 10 commits into
mainfrom
fix/mergeable-revert-duplicate-content

Conversation

@zxch3n

@zxch3n zxch3n commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

Summary

After a mergeable child's map key is deleted, going back to a version where the child was visible added its content a second time. revertTo turned "hello" into "hellohello", a Counter 7 into 14, and ['keep'] into ['keep','keep']. The same happened for nested mergeable containers, in undo/redo, and in diff(a,b) + applyDiff on the same doc.

Root cause

A mergeable child that becomes visible again keeps its deterministic cid and its hidden state. Revival reports its full state, and a doc that kept the hidden state applied that full state on top of it.

Contract (backward compatible)

  • Events keep main's shapes. Local transactions report the marker plus their own ops, so a plain re-ensure emits only the parent marker. Import/checkout report full state. Mirroring events therefore still requires the receiver to share the source's hidden state, as on main; to build a mirror from scratch, use diff() or a snapshot.
  • applyDiff is incremental by default, as on main.
  • Opt-in alignment for diff() results. Rust: LoroDoc::diff marks its batch (DiffBatch::is_full_state / set_full_state), and apply_diff aligns only such batches. JS: applyDiff(diff, { fullState: true }) (ApplyDiffOptions). A re-activated mergeable child's full state is then aligned with whatever the target holds at its cid (none, the same, or different):
    • Map: per key.
    • Counter: adds the difference.
    • Text: keeps the common prefix/suffix by content and re-marks style-only differences.
    • List / MovableList: keeps the common prefix/suffix; moved movable-list children keep their id.
    • Tree: by TreeID.
  • When to opt in: when applying diff() to a doc that may keep the deleted child (the same doc, a fork, another history). Without it, such a doc gets main's behavior (appended). A doc without hidden state gets the same result either way.
  • Revert and undo apply the actual change (KeptChange next to revival full states; DiffBatch::from_changes). They write only the real difference and keep char, element and TreeID identity.
  • UndoManager transforms against the actual change, so a peer can still undo its own edit after a remote undo re-activates the child.

Known limitations

These are documented in context/mergeable-containers.md:

  • Alignment is content-based, and each receiver writes its own ops. Two receivers aligning the same batch against the same hidden state and then merging is convergent but not idempotent.
  • Counters stay additive.
  • revert_to treats remote hidden edits like a root container would; undo of a delete keeps them.

Validation

The performance table is in the PR comment.

🤖 Generated with Claude Code

A revival diff carries the child's full state; a re-activated mergeable
child reuses its deterministic cid with the hidden state, so applying it
appended the content ("hello" -> "hellohello", 7 -> 14). `_apply_diff`
now reconciles such children against their current state instead.
Rationale: context/mergeable-containers.md ("Revert, Apply-Diff, And Undo").

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

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

WASM Size Report

  • Original size: 3235.44 KB
  • Gzipped size: 1073.75 KB
  • Brotli size: 751.15 KB

zxch3n and others added 2 commits September 28, 2026 02:48
Replace the apply-side full-state reconcile with a recording-side rule:
while `LoroDoc::diff` and undo record, a mergeable child re-activated by a
container that keeps its id emits its own delta instead of a full-state
revival. `apply_diff` stays incremental, so forwarded local events stay
consistent, and revert keeps char/element/TreeID identity.
Rationale: context/mergeable-containers.md ("Revert, Apply-Diff, And Undo").

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

zxch3n commented Sep 27, 2026

Copy link
Copy Markdown
Member Author

Addressed the adversarial review of ef61c31 by redesigning the fix (b57170c; main, including #1123, is merged in).

What changed

I dropped the apply-side reconcile (handler/full_state.rs) completely. apply_diff is back to main's purely incremental semantics. The fix is now on the recording side:

  • LoroDoc::diff and the undo diff closure record with DocState::mergeable_revival_as_delta set.
  • In that mode, a mergeable child re-activated by the diff of a container that keeps its id (not itself revived) is not revived as full state. Its own calculator delta (or nothing) goes into the batch, because the doc the batch is applied to still holds the hidden state at the same deterministic cid.
  • A mergeable child under a parent that is revived under a fresh id (e.g. a map in a deleted list element) resolves to a fresh cid and still gets full state.
  • Checkout/import events are unchanged.
  • Core diff is about 40 lines in state.rs / loro.rs.

Blocking items

  1. Forwarding local events to applyDiff. The source now writes real CRDT deltas on the hidden state, and apply_diff applies increments. So events forwarded to a doc in the same state reproduce the source. Counter hidden 7→7 gives 7 on both docs; hidden 10→7 gives 7 on both. Text, List, Tree and nested Text are covered too, in Rust and in WASM (subscribe → applyDiff). Every scenario in the shared check harness now also asserts event forwarding.
  2. Tree concurrent meta edit. Revert uses the diff calculator's delta, so it deletes only the extra node and keeps n's TreeID. B's remote meta survives the merge (revert_keeps_tree_node_for_concurrent_meta_edit).

Concurrency and semantics

  • Diff-based restore keeps identity. Hidden hello! → deletes only !; [keep, extra] → deletes only extra. Two peers reverting concurrently merge to the target (Text, List, MovableList, nested Text, Tree with the original TreeID). Counter stays additive (10→7 on two peers gives 4), the same as a root counter. That is documented and asserted in the WASM test.
  • revert_to now behaves like a root container: a remote edit to the hidden child that was already observed is reverted, and one that arrives later merges. Both orders are tested.
  • Undo of a delete only restores the marker, so a remote edit made while the child was hidden survives in either arrival order. I chose this over "reset to the undone version": undo should only revert local operations, and the explicit version target is what revert_to is for.
  • Trade-off: LoroDoc::diff output for a re-activated mergeable child is now a delta relative to its hidden state, not a full state a JSON mirror could use. That matches what local events already carry. It is documented in context/mergeable-containers.md and the changeset. loro-js fix(loro.js): include reattached child containers' state in diff() and revertTo() #1131 should follow the same rule.

Validation

  • mergeable_revert.rs now has 24 tests; the same file fails 21/24 on main and 17/24 on ef61c31.
  • cargo test -p loro -p loro-internal: 1198 passed, 0 failed, 15 ignored.
  • pnpm check: clean.
  • pnpm -C crates/loro-wasm build-dev && test: 382 passed (mergeable 11/11); CJS, tsc, deno and bun pass.
  • The in_batch / full_state_targets bookkeeping is gone, so the regular path has no added per-container work.

Performance

Release build. Median of 5 builds of revert_to + commit; main and the PR were built in separate worktrees with separate CARGO_TARGET_DIRs. Two runs each.

case main ms PR ms main ops PR ops
plain Text, revert 20k inserts 2.88 / 2.45 2.84 / 2.49 20,000 20,000
5k regular Text children restored 27.4 / 21.7 25.8 / 27.0 30,000 30,000
5k mergeable Text children restored 28.0 / 29.0 12.7 / 13.1 30,000 5,000
100k-char Text, hidden equal 0.201 / 0.150 0.010 / 0.008 100,001 1
100k-char Text, extra ! 0.333 / 0.272 0.174 / 0.173 100,001 2
1M-char Text, extra ! 2.28 / 2.24 1.67 / 1.63 1,000,001 2
100k-item List, hidden equal 64.7 / 66.7 0.044 / 0.037 100,001 1
100k-item List, one extra 64.7 / 66.0 0.080 / 0.079 100,001 2

The regular paths vary run to run on both sides; the code they run is unchanged.

🤖 Generated with Claude Code

zxch3n and others added 3 commits September 28, 2026 03:32
Restore the public `diff()`/event contract (a child that becomes visible
carries its full state) and record the actual change of containers that kept
their state (`KeptChange`) next to it. Revert and undo apply the changes;
`apply_diff` aligns full states with the target's hidden state; the undo
manager transforms against the changes; local transactions that re-activate
a mergeable child now emit its full state too.
Rationale: context/mergeable-containers.md.

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

zxch3n commented Sep 27, 2026

Copy link
Copy Markdown
Member Author

Round-2 review of b57170c is addressed in 1605c7b. Main is merged in up to 18924fa.

B1: public diff() must stay portable

You were right that I should not have changed the public contract. Events and public diff() are back to main's shape: a container that becomes visible carries its full state. What changed:

  • KeptChange. A container revived while it kept its state (a re-activated mergeable child, and what it holds) now also records its actual change, in InternalContainerDiff and ContainerDiff::kept.
  • Revert and undo apply DiffBatch::from_changes, which uses the actual change, so they keep the identity-preserving behaviour from round 1. They record with record_changes_only, an optimization that skips building full states nobody reads; the output is the same.
  • Public apply_diff aligns a re-activated mergeable child's full state with whatever the target holds at its cid (handler/full_state.rs):
    • Map: per key.
    • Counter: adds the difference.
    • Text / List / MovableList: keeps the common prefix and suffix (chars with equal styles; values, containers by id) and replaces the middle.
    • Tree: by TreeID. Missing nodes are created, kept ones are moved if needed, the rest are deleted.
    • Kept children are handed down. No entry means empty.
  • Local events keep (a), (b) and (c) consistent. A local transaction that writes a mergeable marker now reports the child's full state (plus the containers it holds), with the transaction's ops as its KeptChange (DocState::revive_mergeable_in_local_diff). So the same event shape as import/checkout holds everywhere, and applyDiff can always treat it as full state. This also fixes a gap on main: ensure_mergeable_* over a deleted key used to emit no child content, so JS mirrors could not follow it.

Your matrix, rerun (6 kinds × source hidden same/modified × target absent/same/different): 36/36 reach the target. Counter hidden 10→7 on a fresh doc gives 7, and nothing goes out of range.

Every scenario in the shared test harness also checks:

  • event forwarding to a fork;
  • event forwarding to a visible-only doc, built with apply_diff(diff([], v)), which has no hidden state;
  • diff applied to a visible-only doc.

B2: a peer can undo its own edit

UndoManager::compose_remote_event now composes ContainerDiff::change(), not the full-state diff. A re-activated child with unchanged content therefore contributes nothing to the transform base, and B's retain 5, delete 1 stays in bounds.

peer_can_undo_own_edit_after_remote_undo_reactivates_child covers Text, List, MovableList and nested Text × both arrival orders. The "delete arrives first" order, which was already broken on main, is fixed too. The WASM test covers the Text case.

Validation

Performance

Release build. Median of 5 builds; main 106d9e5 and the PR were built in separate worktrees with separate CARGO_TARGET_DIRs. Two runs each (ms):

case main PR ops main → PR
plain Text, revert 20k inserts 2.82 / 2.77 2.61 / 2.56 20,000 → 20,000
5k regular Text children 30.2 / 29.2 27.6 / 27.2 30,000 → 30,000
5k mergeable Text children 33.5 / 30.2 14.4 / 14.3 30,000 → 5,000
100k-char Text, hidden equal 0.183 / 0.217 0.008 / 0.009 100,001 → 1
100k-char Text, extra ! 0.282 / 0.283 0.204 / 0.190 100,001 → 2
1M-char Text, extra ! 2.78 / 2.33 1.75 / 2.28 1,000,001 → 2
100k List, hidden equal 66.3 / 67.4 0.047 / 0.047 100,001 → 1
100k List, one extra 65.7 / 66.2 0.081 / 0.085 100,001 → 2
100k List, one extra, revert with subscriber 69.2 / 68.4 4.37 / 4.32 100,001 → 2
100k List, one extra, public diff+apply_diff 68.1 / 69.8 11.0 / 10.7 100,001 → 2
100k-char Text, extra !, public diff+apply_diff 0.283 / 0.287 0.621 / 0.630 100,001 → 2

The subscriber row pays for building the full-state event, which is the contract. Public apply_diff pays an O(n) alignment; for Text that is 0.6 ms against main's 0.28 ms, and main's result was duplicated content. _apply_diff only tracks per-container bookkeeping when alignment is on (public apply_diff); revert and undo skip it.

Trade-offs, also in context/mergeable-containers.md

  • Aligning against a different hidden state is content-based. Identity is kept only for the shared prefix/suffix and for tree nodes by TreeID, and the edits are new ops of the applying doc.
  • Counters stay additive: concurrent 10→7 reverts give 4, like a root counter.
  • revert_to reverts a remote hidden edit it had already received and merges one that arrives later. Undo of a delete keeps remote hidden edits in either order.

🤖 Generated with Claude Code

zxch3n and others added 3 commits September 28, 2026 09:07
…apes

Local events go back to main's shapes (a re-ensure emits only the parent
marker), so mirrors that share the source's hidden state keep working.
`LoroDoc::diff` marks its batch as full-state (`DiffBatch::is_full_state`;
WASM `applyDiff(diff, { fullState: true })`), and only such batches are
aligned with the target's hidden state. Moved movable-list children are
full-state targets, style-only differences are re-marked instead of
rewritten.
Rationale: context/mergeable-containers.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Compare the two texts by bytes and walk style runs for re-marking, so
aligning a 1M-char text costs a byte comparison plus the run count. Adds
emoji/combining-mark/CJK alignment tests in Rust and WASM (UTF-16 units).

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

zxch3n commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

Round 3 is addressed with option 1: alignment is opt-in, and event shapes stay as on main. Commits 6574efd and a3bcbbc; main is merged up to 27982a5, including #1135 and #1138.

Why opt-in

A [cid, diff][] batch cannot say whether a re-activated mergeable child's entry is its full state (diff()) or an increment on shared hidden state (events). For a plain re-ensure, "no entry" means "unchanged" in a local event but "empty" in a full-state diff. I confirmed this before switching designs: aligned apply plus main-shaped events gave a mirror {c: 0, t: "h"} against a source {c: 7, t: "hello"}.

So:

  • applyDiff stays incremental by default.
  • LoroDoc::diff marks its Rust batch (DiffBatch::is_full_state / set_full_state).
  • JS opts in with applyDiff(diff, { fullState: true }). ApplyDiffOptions is exported in the TS types and documented on applyDiff.

B1: MovableList children

On top of #1138 (delete + re-insert of a child becomes a move), the element loss is gone. The remaining duplication (["AA","BB"]) came from moved children not being marked as full-state targets. They are marked now. A plain list recreates its middle children under fresh ids, so only its prefix/suffix children are marked.

The test covers Map/Text children × {swap, rotate, reverse, delete, insert new} × target same/absent/different hidden, plus revert_to. The WASM test covers the reported case.

B2: event shapes

Local events are main's again. revive_mergeable_in_local_diff is removed, so a re-ensure emits only the parent marker.

  • Rust local_reensure_event_keeps_main_shape checks that forwarding such events to a doc sharing the hidden state reproduces the source.
  • The WASM test covers the Counter/Text/List re-ensure mirror.
  • loro-js, reading WASM events with containers encoded as 🦜:cid: re-ensure 3/3 (Counter stays 7, Text hello, List [keep]), local revert 4/4.

Round-2 (b) (source local events → a doc that shares the hidden state) still holds. revert_to/undo write actual changes, so their local events are increments on shared hidden state.

Non-blocking items

  • Quadratic commit cost: gone with the removed local revival (numbers below).
  • Style-only differences: Text alignment now matches content only. Kept chars whose style differs are re-marked (null unmarks), not deleted and re-inserted. Bold over plain hello writes the marker plus one mark, and a concurrent h→H merges to Hello (was helloH).
  • 1M-char text alignment: now compares bytes and walks style runs instead of per-char vectors. Emoji, combining marks and CJK are tested in Rust and in WASM (UTF-16 units).
  • Concurrent alignment is not idempotent: documented as a known limitation in context/mergeable-containers.md. For example, two receivers aligning hello over hXYZo and merging give hellello. To restore once across peers, have one peer apply and sync the updates.

Validation

Performance

Release build, main 27982a5 vs this PR, separate worktrees and CARGO_TARGET_DIRs, interleaved runs; each cell is the median of 5. The machine has 18 cores; other sessions' builds kept loadavg at 9.5–18, as recorded per run in the logs. It never dropped below 4 in about 20 minutes of waiting, so treat small differences as noise.

case main ms PR ms ops main → PR
plain Text, revert 20k inserts 2.40–2.56 2.37–2.62 20,000 → 20,000
5k regular Text children revert 21.5–41.6 20.5–23.4 30,000 → 30,000
5k mergeable Text children revert 24.2–55.1 10.1–11.2 30,000 → 5,000
100k Text, hidden equal, revert 0.146–0.209 0.007–0.010 100,001 → 1
1M Text, extra !, revert 2.19–2.98 1.59–1.69 1,000,001 → 2
100k List, one extra, revert 63.0–77.9 0.078–0.082 100,001 → 2
same, revert with subscriber 65.5–77.0 0.082–0.097 100,001 → 2
100k List, one extra, diff + apply_diff (full state) 63.8–71.0 9.2–11.4 100,001 → 2
100k Text, extra !, diff + apply_diff 0.27–0.35 0.28–0.48 100,001 → 2
1M Text, extra !, diff + apply_diff 2.47–3.43 2.70–3.72 1,000,001 → 2
commit 5k new mergeable children, subscribed 7.1–11.0 7.2–10.8 —
commit 20k new mergeable children, subscribed 36.2–58.2 38.9–46.5 —

The 1M-char diff + apply_diff row went from 6.4–8.6 ms in round 3 to parity with main, and main's result there is duplicated content. The subscribed-commit rows are back to main's cost; round 3 measured 1.26–2.17 s for 20k.

🤖 Generated with Claude Code

…ag use

An empty batch adopts the other side's flag, two full-state batches stay
full-state, mixing modes panics, and `clear` resets the flag. Document
that only `diff()` results (and import/checkout events) may be marked
full-state, never local events.

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

zxch3n commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

Round-4 follow-ups are in 5ff481a. main (27982a5) is already included; I re-fetched and it hasn't moved.

  1. DiffBatch::compose keeps the full-state flag (internal undo.rs):
    • An empty side is the identity, so default().compose(&full) adopts full_state = true. Your hellohello repro now gives hello.
    • Two full-state batches stay full-state. A child the second batch re-activates was hidden where the batches meet, so the first has no entry for it, and the first batch's own revivals followed by increments are still full states.
    • Two incremental batches stay incremental.
    • Mixing a non-empty full-state batch with an incremental one panics with a clear message, because neither mode can apply the result. UndoManager only composes incremental change batches, so it is unaffected.
    • clear resets the flag.
    • Tests: diff_batch_compose_keeps_full_state (empty + full, full + full; the applied result is checked) and diff_batch_compose_rejects_mixed_modes.
  2. Documented which batches may carry the flag, in the set_full_state Rust doc, the ApplyDiffOptions.fullState / applyDiff JS docs, the internal field doc and context/mergeable-containers.md:
    • diff() results may, including after a JSON round trip.
    • Import/checkout events may. I confirmed they revive children with full state; import_events_may_be_applied_as_full_state forwards import events and gets hello with the flag and main's hellohello without it.
    • Local-commit events must never carry it. A re-ensure has no child entry because the child is unchanged, and full-state reads that as "empty" (a counter at 7 becomes 0).

Validation:

  • cargo test -p loro -p loro-internal: 1226 passed, 0 failed.
  • pnpm check: clean.
  • WASM build-dev + test: 393 passed; CJS, tsc, deno and bun pass.

As agreed, the #1142 interaction is left for #1142 to adapt after this merges.

🤖 Generated with Claude Code

@zxch3n
zxch3n merged commit 1eb14b3 into main Sep 28, 2026
1 check passed
zxch3n added a commit that referenced this pull request Sep 28, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
zxch3n added a commit that referenced this pull request Sep 28, 2026
…tainers

With #1134, a full-state batch (`LoroDoc::diff`) aligns a re-activated
mergeable child with the state this doc kept for it. The pre-check looked at
the raw full states, so re-activating a child that still holds an unknown
container was rejected as creating it. Plan the alignment without side
effects (`align_full_state` dry run) and check the edits it produces; the
targets it aligns are never skipped by the apply loop, so they are always
checked.

Rationale: context/unknown-containers.md

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
pull Bot pushed a commit to FreddyZeng/loro that referenced this pull request Sep 28, 2026
…ro-dev#1142)

* fix: reject creating unknown-type containers instead of panicking

`apply_diff` (and `revert_to`/undo, which share `_apply_diff`) hit
`unreachable!()` in `Handler::new_unattached` when a diff inserted a
container whose type is unknown to this version, e.g. from JS
`applyDiff` with "🦜:cid:0@1:Unknown(9)". Local ops have no rollback, so
`_apply_diff` now checks the whole batch before applying anything and
returns `ArgErr`; MovableList moves of existing unknown elements and
mergeable map children still work. Copying a container that is or holds
an unknown container via `insert_container`/`set_container` is rejected
the same way.

Also fixes other panics reachable with unknown containers: import or
checkout of a change that creates and edits an unknown container,
cursor queries on non-sequence containers, `parent()` of a child of an
unknown container, and undo cursors on missing containers.

Rationale: crates/loro-internal/src/state/AGENTS.md (unknown_state entry).

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

* fix: keep apply_diff/undo atomic when they meet unknown containers

Review of loro-dev#1142 found the pre-check could let a batch through that still
failed half way: the apply loop recreates container values under new ids,
revives mergeable children and recreates deleted tree nodes, so diffs it
skipped as unreachable were applied after all. The pre-check now predicts
those changes from the whole batch (fresh / revived / removed), reuses the
MovableList move planner, skips diffs targeting unknown containers, and only
reads the list when a move needs it.

An undo/redo step that would recreate an unknown container now fails as a
whole with ArgErr and is dropped, instead of being applied partially.
loro-crdt event batches keep the events they can convert. Adds a get_parent
regression test and a no-op Diff::compose_ref arm for unknown diffs, and
unifies the error message.

Rationale: context/unknown-containers.md

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

* fix: check the aligned edits of full-state apply_diff for unknown containers

With loro-dev#1134, a full-state batch (`LoroDoc::diff`) aligns a re-activated
mergeable child with the state this doc kept for it. The pre-check looked at
the raw full states, so re-activating a child that still holds an unknown
container was rejected as creating it. Plan the alignment without side
effects (`align_full_state` dry run) and check the edits it produces; the
targets it aligns are never skipped by the apply loop, so they are always
checked.

Rationale: context/unknown-containers.md

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

* test: full-state revival with unknown containers in Rust and loro-crdt

Adds the reviewer's Map/List/MovableList/tree meta cases for full-state
batches, extends the random atomicity check to both full-state and
incremental application, and ships a Rust-generated fixture for the WASM
tests (JSON updates can't carry the binary mergeable markers). JS
`doc.diff()` leaves out the empty entries of unknown containers instead of
throwing.

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

* docs: unknown containers in full-state batches and loro-crdt diff()

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

* test: benchmark full-state revival with and without unknown containers

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

* fix: plan full-state alignment once and keep undo after a rejected step right

Review round 2 of loro-dev#1142 found three ways the unknown container check and
the apply loop disagreed:

- B1: the full-state plan resolved ids with the initial remap only, so the
  mergeable child of a container recreated by the batch was aligned against
  its old hidden state and accepted, while the loop aligned it against a
  new, empty child and failed half way. The plan now treats containers the
  loop may recreate (and their mergeable children) as new.
- B3: the skip prediction built the ancestor chain from the visible path,
  which is empty below a hidden mergeable container. It now follows arena
  parents.
- B2: a rejected undo step already rebased the other steps over itself. The
  rebase now waits until the step is accepted; a rejected step's changes are
  composed into the remote diff of its row instead.

The loop now applies the edits planned for full-state entries instead of
aligning them again, and asserts in debug builds that it never applies an
entry the check predicted to be skipped or checked differently.
`align_full_state` has no side effects any more.

Rationale: context/unknown-containers.md

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

* test: twin oracle for unknown containers and the undo-after-rejection oracle

Random edits on a doc holding unknown containers, compared with a twin
holding Counters; atomicity alone couldn't see an Ok with a wrong state.
Seeds 0-2999 pass (60 run by default).

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

* chore: keep an unrelated undo.rs line as formatted on main

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

* test: JS reproductions of the second loro-dev#1142 review; update unknown container docs

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

* perf: plan full-state alignment only for batches holding unknown containers

Batches without unknown values are aligned by the loop as before, so the
plan adds nothing to them. Adds the review's benchmarks with many small
full-state targets.

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

* fix: check mergeable children where the loop writes them, and skip-aware planning

Third review of loro-dev#1142:

- R3-1/R3-2: the check keyed a mergeable child by the value's id, while the
  loop writes the marker of `mergeable(parent, key)` and remaps the value to
  it. With a parent the undo manager had already remapped (or a value under
  another key) the check looked into the old hidden child and accepted a
  move of U that the loop turned into a creation: undo returned Ok(true)
  half applied. Such a child is now checked as new and empty.
- R3-3: the full-state plan counted marker writes of an entry the loop skips
  (its target unreachable before the batch, in a reordered batch).
- `_apply_diff` asserts in debug builds that an accepted batch never fails
  on creating an unknown container.

Prototype by the reviewer. Rationale: context/unknown-containers.md

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

* test: third-review reproductions and the collaboration twin oracle

Adds the R3-1 (undo into a host the undo manager recreated: list element,
tree meta, nested), R3-2 (mergeable value under another key) and R3-3
(reordered full-state batch) reproductions, a test pinning that undo after a
rejected move behaves like after a remote move, and the reviewer's oracle
with a remote peer, interleaved undo/redo, reordered batches and 3-level
mergeable chains (60 seeds by default; 0-2999 pass with debug assertions).

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

* test: JS reproduction of undo into a recreated host; document rebase limits of undo

A rejected undo step's edits are rebased over like a remote peer's, with the
same limits (a move of an element an earlier step inserted). Documented in
the undo docs, the changeset and context/unknown-containers.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>
zxch3n added a commit that referenced this pull request Sep 29, 2026
…ith Rust #1134

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
zxch3n added a commit that referenced this pull request Sep 29, 2026
…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>
zxch3n added a commit that referenced this pull request Sep 29, 2026
…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>
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