diff --git a/.changeset/loro-js-detached-snapshot.md b/.changeset/loro-js-detached-snapshot.md new file mode 100644 index 000000000..b349f260b --- /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, 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 9b5decf69..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 { + test("encodes the latest state and keeps the checkout", () => { + 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); + // 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", () => { + 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()); + }); +});