From 8519254166fd25384af07bcbc3dfcc7290aeb224 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 28 Sep 2026 04:33:39 +0800 Subject: [PATCH] fix(loro.js): emit concurrently deleted text once in checkout events A version transition collects the elements each delete op touches in a Set. Packed text spans return a new scalar view per lookup, so text that two peers deleted concurrently was collected once per delete. Restoring it emitted the text twice; removing it deleted twice from the event diff and could throw "event diff position N is out of range". Deduplicate the collected elements by ID before building the event. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../loro-js-checkout-duplicate-deletes.md | 5 ++ loro-js/src/runtime/document.ts | 11 ++- loro-js/tests/event-diff.test.ts | 75 +++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 .changeset/loro-js-checkout-duplicate-deletes.md diff --git a/.changeset/loro-js-checkout-duplicate-deletes.md b/.changeset/loro-js-checkout-duplicate-deletes.md new file mode 100644 index 000000000..e407d5746 --- /dev/null +++ b/.changeset/loro-js-checkout-duplicate-deletes.md @@ -0,0 +1,5 @@ +--- +"loro.js": patch +--- + +Fix `loro.js` checkout events for text that several peers deleted concurrently. A checkout that restored such text emitted it once per delete (`bccb` instead of `bc`), could split a surrogate pair in the event, and a checkout that also removed such text could throw `event diff position N is out of range`. The document state was already correct; the events now match `loro-crdt`. diff --git a/loro-js/src/runtime/document.ts b/loro-js/src/runtime/document.ts index 57f9a8a4d..6c0ac0ddc 100644 --- a/loro-js/src/runtime/document.ts +++ b/loro-js/src/runtime/document.ts @@ -4140,7 +4140,7 @@ export class LoroDoc = Record = Record(); + for (const element of collected) unique.set(idKey(element.id), element); + const elements = [...unique.values()]; + const removals = elements .filter((element) => !element.deleted && targetDeleted(container, element)) .map((element) => ({ element, @@ -4170,7 +4175,7 @@ export class LoroDoc = Record element.deleted && !targetDeleted(container, element)) .sort( (left, right) => diff --git a/loro-js/tests/event-diff.test.ts b/loro-js/tests/event-diff.test.ts index 1508556cb..a4d8bd952 100644 --- a/loro-js/tests/event-diff.test.ts +++ b/loro-js/tests/event-diff.test.ts @@ -240,6 +240,81 @@ describe("incremental event diffs", () => { }); }); +describe("checkout events for concurrent deletes of the same text", () => { + const peers = (count: number): LoroDoc[] => + Array.from({ length: count }, (_, index) => { + const doc = new LoroDoc(); + doc.setPeerId(index + 1); + return doc; + }); + + // Two peers delete the same characters. A packed text span returns a new + // scalar view per lookup, so the transition collected each character once + // per delete op and emitted it twice. + // The second value is the counter of the last inserted Unicode scalar. + test.each([ + ["bc", 1], + ["a😀b", 2], + ] as const)("restores %s once when both deletes are undone", (text, lastCounter) => { + const [p1, p2] = peers(2) as [LoroDoc, LoroDoc]; + p1.getText("text").insert(0, text); + p1.commit(); + p2.import(p1.export({ mode: "update" })); + const length = p1.getText("text").length; + p1.getText("text").delete(0, length); + p1.commit(); + p2.getText("text").delete(0, length); + p2.commit(); + + const doc = new LoroDoc(); + doc.import(p1.export({ mode: "update" })); + doc.import(p2.export({ mode: "update" })); + const batches: LoroEventBatch[] = []; + doc.subscribe((batch) => batches.push(batch)); + doc.checkout([{ peer: "1", counter: lastCounter }]); + + expect(doc.getText("text").toString()).toBe(text); + // loro-crdt emits the same single insert. + expect(batches.map((batch) => batch.events.map((event) => event.diff))).toEqual([ + [{ type: "text", diff: [{ insert: text }] }], + ]); + }); + + test("removes a character deleted by two peers once while restoring another", () => { + const [p1, p2, p3, p4] = peers(4) as [LoroDoc, LoroDoc, LoroDoc, LoroDoc]; + p1.getText("text").insert(0, "ab"); + p1.commit(); + for (const peer of [p2, p3, p4]) peer.import(p1.export({ mode: "update" })); + p4.getText("text").delete(0, 1); + p4.commit(); + p2.getText("text").delete(1, 1); + p2.commit(); + p3.getText("text").delete(1, 1); + p3.commit(); + + const doc = new LoroDoc(); + for (const peer of [p1, p2, p3, p4]) doc.import(peer.export({ mode: "update" })); + const diffs: unknown[] = []; + doc.subscribe((batch) => diffs.push(...batch.events.map((event) => event.diff))); + doc.checkout([ + { peer: "1", counter: 1 }, + { peer: "4", counter: 0 }, + ]); + expect(doc.getText("text").toString()).toBe("b"); + // This used to throw "event diff position 1 is out of range". + doc.checkout([ + { peer: "2", counter: 0 }, + { peer: "3", counter: 0 }, + ]); + expect(doc.getText("text").toString()).toBe("a"); + // Same events as loro-crdt. + expect(diffs).toEqual([ + { type: "text", diff: [{ insert: "b" }] }, + { type: "text", diff: [{ delete: 1 }, { insert: "a" }] }, + ]); + }); +}); + function applyTextDelta(original: string, delta: readonly Delta[]): string { let cursor = 0; let output = "";