Skip to content

fix(loro.js): drop containers deleted before the shallow root, keep tree node metadata - #1128

Merged
zxch3n merged 56 commits into
mainfrom
fix/loro-js-shallow-tree-meta-retention
Sep 28, 2026
Merged

zxch3n merged 56 commits into
mainfrom
fix/loro-js-shallow-tree-meta-retention

Conversation

@zxch3n

@zxch3n zxch3n commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

Stacked on #1127, which is stacked on #1126. Merge #1126 → #1127 → #1128 back to back (see #1126 and #1127).

Summary

This ports Rust #1119 and #1123 to loro.js.

On main, LoroDoc.#buildStateStore writes every container ever created into both the shallow root state and the latest state. So a map child deleted before the cut, such as {"secret":"old"}, ships in every shallow snapshot. Re-exporting an older blob at its own root keeps it too.

Fix

  • #retainedContainerKeys() is the Rust AliveWalk::Retention rule. It walks from the root containers through visible Map/List children, and follows every tree node's meta, including deleted nodes, because a retained move can revive a node with its old meta.
  • Mergeable containers are roots of the walk, as in Rust. Their deterministic id survives a deleted or replaced marker, and re-ensuring the same kind shows their state again. So a hidden mergeable child keeps its data.
  • The root state keeps the walk at the root. The latest state keeps the walk at the root and at the latest version, plus every container whose creation id the root version vector does not include. This mirrors retain_created_after_root.
  • fix: drop dead containers when re-exporting a shallow doc at its cached root #1123 needs no separate code path: loro.js always rebuilds the root state by replay (it has no cached-root reuse branch), so re-exports of old blobs are pruned too.
  • The tree-revival checkout tests found one more retreat gap in the fix(loro.js): keep root-time map values and tree nodes when a shallow doc retreats #1127 fallback: when a retained delete wins but the node's placement op was trimmed, the node stayed alive. It now falls back to the root placement and stays deleted.

context/internal-encoding.md documents this in its retention-set section, with a loro-js paragraph.

Parity with Rust (#1119/#1123 scenarios)

Scenario loro.js on main this PR
Map children deleted before the root (0 / 300 retained ops) kept in root and latest state dropped
Containers created after the root kept kept
Meta of a node revived from a deleted parent, or by a remote Move of a directly deleted node kept (everything was kept) kept
Checkout across revivals vs full history, both directions wrong after the root (retained delete with trimmed placement) matches
Re-export of old Rust blobs (legacy_* from #1123) and of an old loro.js blob dead map survives dropped
Rust pre-#1119 blob without revived meta imports imports (structure only, as in Rust)
Tree diff cache seeded with deleted nodes n/a: loro.js keeps deleted records in its tree state n/a

Validation

  • New loro-js/tests/shallow-snapshot-deleted-containers.test.ts (17 tests) ports every test in shallow_snapshot_deleted_containers.rs except state-only, which loro.js does not implement. It also imports the Rust fixtures from fix: drop containers deleted before the shallow root from shallow and state-only snapshots #1119/fix: drop dead containers when re-exporting a shallow doc at its cached root #1123 and a new blob exported by origin/main loro.js (legacy-shallow-tree-revival-dead-map.ts.blob). On the base branch, 8 tests fail; the revival tests passed before too and now guard against over-pruning.

  • Cross-runtime check: Rust main imports shallow blobs exported by this branch (tree revival + dead map, 0 and 300 filler ops). The deep value equals the loro.js value, has_container(dead map) is false, and a Rust re-export at the same root keeps the same value.

  • pnpm -C loro-js check (format, lint, typecheck, tests, build) passes with 311 tests. shallow-snapshot-deleted-containers.test.ts also covers hidden mergeable children (switched kind and deleted marker, with 0 and 300 retained ops), and Rust reads them the same.

  • Shallow export, 8,192 child Maps with half deleted before the root and 300 retained ops (median of 5 alternating runs, load1 5.8–6.0):

    • main: 138.0 ms, 558,402 B.
    • Round 3 c12ccc99: 110.5 ms, 352,852 B.
    • Now 64d12269: 112.0 ms, 352,852 B.

    The output is 37% smaller. The round-3 review measured a different setup: 387,772 → 267,366 B (−31%).

  • Combined with fix(loro.js): include reattached child containers' state in diff() and revertTo() #1131 ae55ff7a: pnpm -C loro-js check passes with 338 tests. On that combination, the round-2 tree revival repro (B3) gives Rust's exact tree.

Integration notes

Rust: #1119 (merged), #1123 (open). If #1123's final semantics change, this PR should follow.

🤖 Generated with Claude Code

zxch3n and others added 3 commits September 28, 2026 00:47
…mport

Snapshot-hydrated state has no tombstones or op history, so incremental
transitions returned wrong historical state, and a replay could hydrate
lazy latest-state entries on top of replayed ops ("duplicate sequence id").
Rationale: context/loro-js-performance.md (snapshot-hydrated state).

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

A shallow history trims the ops that wrote root-time values, so retreating a
later write dropped the key or node. Fall back to the shallow root state,
indexed lazily per container. Rationale: context/loro-js-performance.md.

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

Port the Rust retention rule (#1119, #1123): only containers a retained op can
reach are exported, and every tree node's meta is kept because a retained move
can revive it. Rationale: context/internal-encoding.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
zxch3n and others added 20 commits September 28, 2026 02:14
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The first checkout after a snapshot import replayed the whole document, up
to three times for the first `diff`, and replaced correct snapshot state
with loro.js replays. Replaying Rust-created rich text lost styles and moved
text, because loro.js positions do not count Rust style anchors.

A transition now hydrates and rebuilds only the Text/List containers it
touches, once, and keeps a snapshot state that the replay does not
reproduce for every version with the same operations on that container.
Checkout events of unread nested containers are correct, and a full
snapshot export keeps unread containers after history is loaded. See
context/loro-js-performance.md.

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

Resolution: the shallow root fallbacks here supersede the base branch's
rebuild when a shallow Map or Tree winner was trimmed, so that guard is
dropped. The root-state lookup reuses the base branch's root entry index
(`#shallowRootEntryIndex`) instead of formatting every root container id
on the first fallback.

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

When a retained delete wins but its node's placement op was trimmed from a
shallow history, the retreat left the node alive. It now takes the root
placement from the shallow root state and stays deleted. This moves the fix
from the next PR in the stack so this one is correct on its own.

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

The retained-delete tree fix from this branch now lives in the base branch;
both sides made the same change, so it merged cleanly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Mergeable children use deterministic root-form IDs. When their marker is
deleted or replaced by another kind before the shallow root, re-ensuring the
same kind must show their state again, so Rust keeps every mergeable cid in
its retention roots. The retention walk skipped mergeable roots and exported
the hidden child as empty, for loro.js and Rust readers alike.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Shallow export rebuilds the root and latest states by full replay, which
replaced snapshot state that the replay does not reproduce (Rust rich text)
in both the export and the live document. The rebuilds now pass the
version they leave, so snapshot states are stashed and reinstated, and
unread lazy sequences are hydrated first. The import builds the root
entry index only when it merges a latest state.

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

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

Resolution: the shallow export keeps this branch's retention sets and
passes the base branch's previous versions to `#rebuildFromHistory`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The check for crossing snapshot operations ran before lazily encoded
containers were hydrated, so an unread nested Text or List skipped its
history rebuild and the checkout fell back to replaying the whole document.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Resolution: a detached snapshot export (#1130) builds its latest state with
the same prepared transition as checkout (#prepareSnapshotTransition,
#settleSnapshotSequences) and encodes it with the lazy-aware state encoder.
Lazily imported documents therefore also use the O(delta) transition instead
of a staging replay, and snapshot states that a replay does not reproduce
stay exact in the export.

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

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

Resolution: `#canTransitionRecords` takes no target here, since the shallow
root fallbacks replace the base branch's rebuild guard.

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

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

zxch3n commented Sep 27, 2026

Copy link
Copy Markdown
Member Author

Follow-up on the review of 9ef23c75 → now 0d358cb7. The updated #1127, #1126, and main are merged in. #1123's Rust paragraph in context/internal-encoding.md sits before the loro.js one.

  • F2: #retainedContainerKeys now treats every mergeable container as a retention root, as Rust's existing_retention_roots does (mergeable cids are root-form). A child hidden by a deleted or different-kind marker kept its deterministic id, but it was exported empty.
    • I tested 8 variants: deleted marker or kind switch, × 0 or 300 retained ops, × loro.js or Rust reader, each with a checkout across the cut.
    • All 8 now restore "hello", the same as main and Rust. Before this change all 8 gave "".
    • Regression tests are in shallow-snapshot-deleted-containers.test.ts, and context/internal-encoding.md and context/mergeable-containers.md are updated.
  • Retained-delete tree fix: it now lives in fix(loro.js): keep root-time map values and tree nodes when a shallow doc retreats #1127. Both sides had identical code, so it merged cleanly. The changeset sentence moved there too.
  • Shallow export keeps snapshot states (from fix(loro.js): correct checkout after a snapshot import #1126). A shallow export of a Rust rich-text snapshot keeps both the live doc and the blob exact. I verified this with a Rust import.
  • Shallow export perf. 8,192 child Maps, half deleted before the root, 300 retained ops, 3 alternating runs:
    • main: 156–167 ms, 558,402 B
    • 9ef23c75: 121–131 ms, 352,852 B
    • now: 122–126 ms, 352,852 B
  • First checkout (inherited from fix(loro.js): correct checkout after a snapshot import #1126): a 262k-op Text drops from 149.26 to 56.58 ms. 32k child Maps drop from 155.85 to 56.94 ms, with peak RSS 283.8 → 235.2 MiB.
  • Checks: pnpm -C loro-js check passes (289 tests). bench:complexity against main flags 0 of 284 cases. The changesets only bump loro.js (patch).
  • Fuzz: in the shallow variant, the failing seeds are a strict subset of main's under the same settings (38 vs 43 of 60 seeds). These are pre-existing loro.js shallow-ordering issues with concurrent histories, and are not addressed here.

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: 3272.76 KB
  • Gzipped size: 1087.19 KB
  • Brotli size: 760.88 KB

zxch3n commented Sep 27, 2026

Copy link
Copy Markdown
Member Author

Round-2 follow-up: 0d358cb7 → c12ccc99. The updated #1127/#1126 and main are merged in.

zxch3n and others added 3 commits September 28, 2026 09:08
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow-root-map-winner

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

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 update: c12ccc99 → b716216c. This only merges #1127 79170146, which brings in #1126 and main 27982a55. No loro.js code changed.

  • Rust recheck with the WASM oracle rebuilt from 27982a55: the F2 repro is unchanged. For both switch-kind and delete-marker, with 0 and 300 retained ops, loro.js and Rust both give {"x":"hello"}.
  • Combined with fix(loro.js): include reattached child containers' state in diff() and revertTo() #1131 ae55ff7a:
    • pnpm -C loro-js check passes with 330 tests.
    • The B1/B2/F1–F4 repros pass, and so does the rich-text fuzz (240/240).
    • In the general fuzz, the shallow variant's failing seeds are a subset of main's.
    • The reviewer's B3 repro matches Rust exactly: 7@1 → 8@1 {k: v}.
  • Shallow export (8,192 child Maps, half deleted before the root, 300 retained ops; median of 5 alternating runs, load1 11.4–12.5):
time size
main 152.1 ms 558,402 B
round 2 c12ccc99 124.5 ms 352,852 B
now b716216c 125.4 ms 352,852 B
  • B4 (main / round-2 / now, ms):
phase main round 2 now
apply 249.0 256.4 245.7
update import 253.7 181.9 182.0

The other phases are within noise. The full table is in #1126's round-3 comment.

zxch3n and others added 3 commits September 28, 2026 22:18
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow-root-map-winner

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
zxch3n and others added 9 commits September 28, 2026 22:33
- forkAt/fork keeps an unreplayable snapshot state only when the fork's
  version includes it; an older fork replays its own operations, as on main.
- A MovableList is never unreplayable: its hydrated state carries Rust
  position ids, so its replay replaces the state, as on main.
- A completion that throws reinstalls the snapshot state; #transitionTo
  prepares inside its try, and diff restores with a full rebuild when it
  or its move back throws.

Rationale and limits: context/loro-js-performance.md (issue #1163 tracks
the concurrent-delete placement bug that main already has).

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Replaying a hydrated MovableList to the current version and retreating
from there carried loro.js's MovableList non-convergence at the latest
version into older versions (random Rust histories: 581 mismatching
versions vs 91 on main). A MovableList is no longer a snapshot sequence:
its hydrated state has no move or value history, so a transition that
crosses its snapshot operations replays to the target, as on main.

See context/loro-js-performance.md.

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

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

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

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

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-up: b716216c → 64d12269. This merges main 29553d5b and #1127, which carries #1126's NEW-B1/NEW-B2/P2 fixes (see #1126's comment). This PR's own code is unchanged.

  • Description: updated with the merge order, the mergeable-root retention (F2), current numbers, and the fix(loro.js): keep rich-text style anchors in the text sequence #1137 integration point: #buildStateStore must take both the retention set and the style redaction.
  • Checks: pnpm -C loro-js check passes with 310 tests.
  • Rust oracle 29553d5b: F2 (hidden mergeable children, switch/delete × 0/300 retained ops, loro.js and Rust readers) and F4 are unchanged.
  • Fuzz:
  • Combined with fix(loro.js): include reattached child containers' state in diff() and revertTo() #1131 ae55ff7a (the same one adjacent-field conflict; I kept both sides):
    • 337 tests pass.
    • B3 gives Rust's exact tree (7@1 → 8@1 {k: v}).
    • NEW-B1/NEW-B2 match main/Rust.
    • The rich-text fuzz passes 240/240.
  • Shallow export, 8,192 child Maps with half deleted before the root and 300 retained ops (median of 5 alternating runs, load1 5.8–6.0):
time size
main 138.0 ms 558,402 B
round 3 110.5 ms 352,852 B
now 112.0 ms 352,852 B
  • B4 update import: main 239.5 ms, round 3 169.7 ms, now 174.0 ms. The other phases are within noise.
  • CI on 64d12269: build and loro-js pass.

zxch3n and others added 3 commits September 29, 2026 00:43
A full rebuild only checked Text whose snapshot state still had styles.
When every marked character was deleted, the mark's anchors still shift
later Rust positions, so the replay (hlXo -> hloX) replaced the snapshot
state and was exported. The check now also covers Text with a mark
operation in its history (#markedTexts) or styles in its shallow root
state; plain text still skips it.

See context/loro-js-performance.md.

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

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

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

zxch3n commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

Round-5 follow-up: 64d12269 → e4b01934. This merges #1127, which carries #1126's R4-B1 fix (see #1126's comment); this PR's own code is unchanged.

  • Checks: pnpm -C loro-js check passes with 311 tests.
  • R4-B1 on this head, which has its own shallow export with retention:
    • The hlXo paths match Rust: checkout → attach, live shallow export and its blob at both versions, and forkAt(latest).
    • The exhaustive checkout → shallow export → attach run with the delete-marked-text variant changes the live state in 0 of 40,174 cases.
    • The 150 random seeds are 0 in both variants.
    • F2 and F4 are unchanged.
  • Combined with fix(loro.js): include reattached child containers' state in diff() and revertTo() #1131 ae55ff7a: 338 tests pass. R4-B1 gives hlXo, B3 gives Rust's exact tree, and the rich-text fuzz passes 240/240.
  • Performance, 5 alternating rounds, load1 5.0–6.0; shallow export / forkAt(latest), in ms:
    • loro.js plain text, 262,144: 268.1 / 75.6 on round 4, 267.4 / 77.6 now.
    • Rust text whose marked characters were deleted: 218.9 / 34.7 on round 4 (wrong), 368.9 / 162.2 now (right).

CI on e4b01934: build and loro-js pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@zxch3n
zxch3n changed the base branch from fix/loro-js-shallow-root-map-winner to main September 28, 2026 23:30
@zxch3n
zxch3n merged commit 4cf1d4a into main Sep 28, 2026
2 checks passed
zxch3n added a commit that referenced this pull request Sep 29, 2026
…-richtext-anchors

Integrates #1126–#1128: `_swapState` also swaps the anchor registry and
keeps `_styleIndex` replaceable with `TextStyleIndex<TextStyle>`; the
shallow export passes #1128's retained-container set and this branch's
style redaction to `#buildStateStore`.

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