From fbda9b2653afd70985e25e5a78b64b1f1cca0b8a Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 28 Sep 2026 04:36:08 +0800 Subject: [PATCH] fix(loro.js): apply Text/List deletes imported while detached Importing while detached only adds records to the history. The next checkout or checkoutToLatest found no runs "deleted by" such a delete in the sequence's deletion index and skipped it, so the deleted content stayed visible. Inserts imported while detached already fell back to a replay because #canTransitionRecords checks them. Check deletes the same way: fall back unless the sequence has recorded every element the delete removes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/loro-js-detached-import-delete.md | 5 +++ loro-js/src/runtime/document.ts | 11 ++++++ loro-js/tests/runtime.test.ts | 38 ++++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 .changeset/loro-js-detached-import-delete.md 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);