From 429d6dc2a55c93e94c72473e66364775ba34cdbf Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 28 Sep 2026 01:03:49 +0800 Subject: [PATCH 1/2] fix(loro.js): export the latest state in a snapshot of a detached document 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) --- .changeset/loro-js-detached-snapshot.md | 5 +++ loro-js/src/runtime/document.ts | 21 ++++++++-- loro-js/tests/detached-snapshot.test.ts | 56 +++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 .changeset/loro-js-detached-snapshot.md create mode 100644 loro-js/tests/detached-snapshot.test.ts diff --git a/.changeset/loro-js-detached-snapshot.md b/.changeset/loro-js-detached-snapshot.md new file mode 100644 index 000000000..69d9cf74e --- /dev/null +++ b/.changeset/loro-js-detached-snapshot.md @@ -0,0 +1,5 @@ +--- +"loro.js": patch +--- + +`export({ mode: "snapshot" })` on a detached `loro.js` document now encodes the latest state, as Rust and `loro-crdt` do. Before, it wrote the checked-out state and version next to the full history. The resulting snapshot showed an old state as the latest version, and checking out on the importing side failed with "snapshot version does not match its history". The exporting document keeps its checkout. diff --git a/loro-js/src/runtime/document.ts b/loro-js/src/runtime/document.ts index 9b5decf69..c162a58fa 100644 --- a/loro-js/src/runtime/document.ts +++ b/loro-js/src/runtime/document.ts @@ -5296,17 +5296,32 @@ export class LoroDoc = Record { + test("encodes the latest state and keeps the checkout", () => { + const { doc, first, latest, latestValue } = buildHistory(); + doc.checkout(first); + const firstValue = doc.toJSON(); + + const bytes = doc.export({ mode: "snapshot" }); + expect(doc.isDetached()).toBe(true); + expect(doc.frontiers()).toEqual(first); + expect(doc.toJSON()).toEqual(firstValue); + + const loaded = new LoroDoc(); + loaded.import(bytes); + expect(loaded.toJSON()).toEqual(latestValue); + expect(loaded.frontiers()).toEqual(latest); + expect(loaded.oplogFrontiers()).toEqual(latest); + loaded.checkout(first); + expect(loaded.toJSON()).toEqual(firstValue); + }); + + test("includes updates imported while detached", () => { + const { doc, first } = buildHistory(); + const other = new LoroDoc(); + other.setPeerId(2); + other.import(doc.export({ mode: "update" })); + other.getMap("map").set("b", 3); + other.commit(); + + doc.checkout(first); + doc.import(other.export({ mode: "update", from: doc.oplogVersion() })); + const loaded = new LoroDoc(); + loaded.import(doc.export({ mode: "snapshot" })); + expect(loaded.toJSON()).toEqual(other.toJSON()); + expect(loaded.oplogFrontiers()).toEqual(other.oplogFrontiers()); + }); +}); From 52710c1121b12f3f018f180635f842971f0c35b4 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 28 Sep 2026 02:03:30 +0800 Subject: [PATCH 2/2] fix(loro.js): cover lazy snapshot export and avoid replays for detached 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) --- .changeset/loro-js-detached-snapshot.md | 2 +- loro-js/src/runtime/document.ts | 59 ++++++++++++++++++------- loro-js/tests/detached-snapshot.test.ts | 36 ++++++++++++++- 3 files changed, 79 insertions(+), 18 deletions(-) diff --git a/.changeset/loro-js-detached-snapshot.md b/.changeset/loro-js-detached-snapshot.md index 69d9cf74e..b349f260b 100644 --- a/.changeset/loro-js-detached-snapshot.md +++ b/.changeset/loro-js-detached-snapshot.md @@ -2,4 +2,4 @@ "loro.js": patch --- -`export({ mode: "snapshot" })` on a detached `loro.js` document now encodes the latest state, as Rust and `loro-crdt` do. Before, it wrote the checked-out state and version next to the full history. The resulting snapshot showed an old state as the latest version, and checking out on the importing side failed with "snapshot version does not match its history". The exporting document keeps its checkout. +`export({ mode: "snapshot" })` on a detached `loro.js` document now encodes the latest state, version, and frontiers, as Rust and `loro-crdt` do. This includes a document imported lazily from a snapshot that received updates while detached. Before, the export wrote the checked-out state and version next to the full history, so an importer saw an old state as the latest version and a later checkout failed. The exporting document keeps its checkout. diff --git a/loro-js/src/runtime/document.ts b/loro-js/src/runtime/document.ts index c162a58fa..b2c5fa253 100644 --- a/loro-js/src/runtime/document.ts +++ b/loro-js/src/runtime/document.ts @@ -5247,7 +5247,13 @@ export class LoroDoc = Record = Record = Record = Record = Record { const { doc, first, latest, latestValue } = buildHistory(); doc.checkout(first); const firstValue = doc.toJSON(); + const text = doc.getMap("map").get("text") as LoroText; const bytes = doc.export({ mode: "snapshot" }); expect(doc.isDetached()).toBe(true); expect(doc.frontiers()).toEqual(first); expect(doc.toJSON()).toEqual(firstValue); + expect(text.toString()).toBe("hello"); + doc.checkoutToLatest(); + expect(doc.toJSON()).toEqual(latestValue); + doc.checkout(first); const loaded = new LoroDoc(); loaded.import(bytes); expect(loaded.toJSON()).toEqual(latestValue); expect(loaded.frontiers()).toEqual(latest); expect(loaded.oplogFrontiers()).toEqual(latest); - loaded.checkout(first); - expect(loaded.toJSON()).toEqual(firstValue); + // Retreating snapshot-imported state over a delete is covered by the + // snapshot-checkout tests; here, check that history reaches the latest. + loaded.checkout(latest); + expect(loaded.toJSON()).toEqual(latestValue); + }); + + test("includes updates imported while detached into a lazily imported document", () => { + const source = new LoroDoc(); + source.setPeerId(1); + source.getMap("m").set("x", 1); + source.commit(); + const version = source.oplogVersion(); + const target = new LoroDoc(); + target.import(source.export({ mode: "snapshot" })); + target.detach(); + source.getMap("m").set("x", 2); + source.commit(); + target.import(source.export({ mode: "update", from: version })); + + const loaded = new LoroDoc(); + loaded.import(target.export({ mode: "snapshot" })); + expect(loaded.toJSON()).toEqual({ m: { x: 2 } }); + expect(loaded.oplogFrontiers()).toEqual(source.frontiers()); + loaded.checkout(source.frontiers()); + expect(loaded.toJSON()).toEqual({ m: { x: 2 } }); + // The exporting document keeps its detached state. + expect(target.isDetached()).toBe(true); + expect(target.toJSON()).toEqual({ m: { x: 1 } }); }); test("includes updates imported while detached", () => {