diff --git a/.changeset/loro-js-detached-import-delete.md b/.changeset/loro-js-detached-import-delete.md new file mode 100644 index 000000000..8ec2c24f8 --- /dev/null +++ b/.changeset/loro-js-detached-import-delete.md @@ -0,0 +1,5 @@ +--- +"loro.js": patch +--- + +Fix `loro.js` losing Text/List deletions that were imported while the document was detached. After `checkout(...)`, importing an update that deleted characters or list items, and then calling `checkoutToLatest()` or `checkout(...)` to a version that includes the delete, kept the deleted content visible. It now matches `loro-crdt` and a fresh import of the same updates. diff --git a/loro-js/src/runtime/document.ts b/loro-js/src/runtime/document.ts index 57f9a8a4d..8afa28351 100644 --- a/loro-js/src/runtime/document.ts +++ b/loro-js/src/runtime/document.ts @@ -3824,6 +3824,17 @@ export class LoroDoc = Record { expect(doc.getText("text").toString()).toBe("base latest"); }); + // A delete imported while detached was left out of the next transition: + // the transition looked it up in the sequence's deletion index, which only + // attached imports update. loro-crdt applies it. + test.each([ + ["text", "latest"], + ["text", "checkout"], + ["list", "latest"], + ["list", "checkout"], + ] as const)("applies a %s delete imported while detached (%s)", (kind, target) => { + const read = (doc: LoroDoc): unknown => + kind === "text" ? doc.getText("seq").toString() : doc.getList("seq").toArray(); + const source = new LoroDoc(); + source.setPeerId(2); + if (kind === "text") source.getText("seq").insert(0, "hello"); + else for (const value of "hello") source.getList("seq").push(value); + source.commit(); + + const doc = new LoroDoc(); + doc.setPeerId(1); + doc.import(source.export({ mode: "update" })); + if (kind === "text") source.getText("seq").delete(4, 1); + else source.getList("seq").delete(4, 1); + source.commit(); + doc.getCounter("counter").increment(1); + doc.commit(); + + doc.checkout([{ peer: "2", counter: 1 }]); + expect(read(doc)).toEqual(kind === "text" ? "he" : ["h", "e"]); + doc.import(source.export({ mode: "update", from: doc.oplogVersion() })); + if (target === "latest") doc.checkoutToLatest(); + else doc.checkout(doc.oplogFrontiers()); + + expect(read(doc)).toEqual(kind === "text" ? "hell" : ["h", "e", "l", "l"]); + const replay = new LoroDoc(); + replay.import(doc.export({ mode: "update" })); + expect(read(replay)).toEqual(read(doc)); + }); + test("holds causally incomplete updates pending until dependencies arrive", () => { const source = new LoroDoc(); source.setPeerId(1);