Skip to content

fix(loro.js): export the latest state in a snapshot of a detached document - #1130

Merged
zxch3n merged 2 commits into
mainfrom
fix/loro-js-detached-snapshot-export
Sep 27, 2026
Merged

zxch3n merged 2 commits into
mainfrom
fix/loro-js-detached-snapshot-export

Conversation

@zxch3n

@zxch3n zxch3n commented Sep 27, 2026

Copy link
Copy Markdown
Member

Summary

Found while auditing loro.js against the recent Rust snapshot fixes: some probes exported a snapshot after a checkout.

On main, export({ mode: "snapshot" }) on a detached loro.js document writes the checked-out state, the checked-out version vector, and frontiers next to the full history. The resulting blob is inconsistent:

  • The importer reports the old state and old frontiers as latest, for example {"m":{"a":1}} with frontiers 0@1 while the history goes up to 1@1.
  • checkout on the importer throws snapshot version does not match its history.
  • Updates imported while detached are missing from the exported state.

Rust's encode_snapshot_inner checks out the latest version, encodes, and checks the old version back out. loro.js now does the same: it encodes the latest state, writes the oplog version vector and frontiers, and then restores the checkout. The exporting document stays detached at the same version.

Validation

  • New loro-js/tests/detached-snapshot.test.ts: the export keeps the checkout; the importer sees the latest value and frontiers and can check out the old version; updates imported while detached are included. Both tests fail on main.
  • Cross-runtime check: Rust main imports a loro.js snapshot exported while detached and gets the latest value {"m":{"a":2},"t":"x"} and frontiers 2@1.
  • pnpm --dir loro-js test (18 files, 242 tests), typecheck, lint, format:check all pass.
  • The non-detached path is unchanged. B4 (bench:b4 259778 3) snapshot export is 118.9 ms here vs 112.2 ms on main, with overlapping samples (109–123 vs 107–122 ms) and identical output (309,780 bytes). Only a detached export pays for rebuilding the latest state and restoring the checkout, which is O(history), as in Rust's two checkouts.

The test history avoids a list delete, because retreating a delete on snapshot-imported state is a separate bug fixed in #1126.

🤖 Generated with Claude Code

…ument

A detached document wrote its checked-out state and version alongside the
full history, producing an inconsistent snapshot. Match Rust's
`encode_snapshot_inner`: encode the latest state, then restore the checkout.

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

Copy link
Copy Markdown
Contributor

WASM Size Report

  • Original size: 3200.65 KB
  • Gzipped size: 1058.59 KB
  • Brotli size: 741.14 KB

…ed snapshots

The lazily imported snapshot branch returned early with the checked-out
version and stale lazy state. Take it only when the state is at the latest
version. Build the latest state with the same incremental transition that
diff() uses and transition back; fall back to one replay on a staging doc.

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 in 52710c1.

P1: the lazy snapshot branch returned early. #encodeSnapshot now computes latestVersion and detachedState first. The lazy (deferred) branch runs only when the state is already at the latest version, and it writes latestVersion and the oplog frontiers instead of this.version() / #frontiersCodec(). Your repro (import snapshot → detach() → import x=2 → export snapshot) is now a regression test in tests/detached-snapshot.test.ts. The importer gets {m:{x:2}} with frontier 1@1, and checkout of the latest version succeeds. The exporter stays detached at {m:{x:1}}. Rust main imports the same blob as {"m":{"x":2}} / 1@1, and checkout succeeds. The test fails on the old head 429d6dc.

No more double replay. The new #latestStateStore() uses the same incremental transition as diff(): go forward to the latest version, build the store, and go back to the checkout, which is O(version delta). If the transition is refused, or the state is still lazily encoded (which #buildStateStore cannot enumerate), it replays once on an isolated staging doc (forkAt(oplogFrontiers())). The exporter's containers and handles are never rebuilt.

10k map keys, 100 edits after the checkout, median of 21 runs per process, 3 alternating rounds (ms):

main (wrong output when detached) old head 429d6dc 52710c1
attached 18.46 / 18.81 / 18.66 18.54 / 18.19 / 18.49 18.04 / 18.17 / 19.17
detached 18.14 / 18.14 / 17.83 34.47 / 33.83 / 35.12 18.52 / 18.78 / 19.54

The export test now also deletes text, checks that a held LoroText handle keeps its checkout value, and that checkoutToLatest() still works after the export. The importer-side historical checkout across that delete depends on #1126. On the combined tree (main + #1126→#1127→#1128 + #1130 + #1131), loro.js and the Rust main nodejs WASM build both import the detached export at the latest value and check out the old value correctly.

Checks: pnpm --dir loro-js test (18 files, 243 tests), typecheck, lint, and format:check all pass. The combined tree passes 22 files, 284 tests. The changeset text is updated.

@zxch3n
zxch3n merged commit 106d9e5 into main Sep 27, 2026
1 check passed
zxch3n added a commit that referenced this pull request Sep 27, 2026
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>
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