From ab4200e381207222117f53656140895e33ad20af Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 28 Sep 2026 04:41:44 +0800 Subject: [PATCH] fix(loro.js): record the same local ops as loro-crdt Three API calls produced different ops than Rust, so the same edits got different op IDs, and a concurrent map set/delete could resolve differently: - Map.delete of an absent key recorded nothing; Rust records a delete. - Counter.increment(0) and attaching a detached counter with value 0 recorded nothing; Rust records the increment. - A text delete across several ID runs was emitted left to right; Rust's TextHandler::delete emits the runs right to left. The old "no semantic no-op edits" test asserted the loro.js behavior; it now asserts Rust's. MovableList set-to-same-value (Rust records it) is left to the MovableList rework. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/loro-js-local-op-parity.md | 9 ++++ loro-js/src/runtime/document.ts | 19 +++++--- loro-js/tests/runtime.test.ts | 66 ++++++++++++++++++++++----- 3 files changed, 77 insertions(+), 17 deletions(-) create mode 100644 .changeset/loro-js-local-op-parity.md diff --git a/.changeset/loro-js-local-op-parity.md b/.changeset/loro-js-local-op-parity.md new file mode 100644 index 000000000..eb069791c --- /dev/null +++ b/.changeset/loro-js-local-op-parity.md @@ -0,0 +1,9 @@ +--- +"loro.js": patch +--- + +`loro.js` now records the same local ops as `loro-crdt` for three API calls, so a document edited through either package gets the same op IDs and the same concurrent outcomes: + +- `LoroMap.delete(key)` records a delete even when the key is absent. Like in Rust, that delete can win against a concurrent `set` of the same key. +- `LoroCounter.increment(0)` records an increment, and attaching a detached counter records an increment by its value, even 0. +- A `LoroText.delete` that spans several ID runs emits one delete per run from right to left, as Rust does. Checking out a version in the middle of such a delete now shows the same text in both packages. diff --git a/loro-js/src/runtime/document.ts b/loro-js/src/runtime/document.ts index 57f9a8a4d..6ab582126 100644 --- a/loro-js/src/runtime/document.ts +++ b/loro-js/src/runtime/document.ts @@ -2393,8 +2393,8 @@ export class LoroDoc = Record = Record = Record= 0; index -= 1) { + const run = runs[index]!; + const start = end - run.length; this.#appendAndApply( container, { type: "text-delete", - position, + position: start, length: BigInt(run.length), startId: run.start, }, run.length, ); this.#mergeTrailingTextDeletes(); + end = start; } } @@ -7720,7 +7726,8 @@ function restoreBlueprint(container: Container, blueprint: ContainerBlueprint): } else if (container instanceof LoroText) { container.applyDelta(blueprint.value as never); } else if (container instanceof LoroCounter) { - if ((blueprint.value as number) !== 0) container.increment(blueprint.value as number); + // Rust's CounterHandler::attach increments by the detached value, even 0. + container.increment(blueprint.value as number); } else if (container instanceof LoroList) { for (const value of blueprint.value as unknown[]) { if (isContainer(value)) container.pushContainer(value); diff --git a/loro-js/tests/runtime.test.ts b/loro-js/tests/runtime.test.ts index 616eedf6c..cf61b32b0 100644 --- a/loro-js/tests/runtime.test.ts +++ b/loro-js/tests/runtime.test.ts @@ -1697,22 +1697,66 @@ describe("loro-wasm-compatible runtime", () => { expect(() => root.ensureMergeableMap("regular")).toThrow(/non-mergeable value/u); }); - test("does not record semantic no-op edits", () => { + // loro-crdt skips setting a map key to its current value, but records a + // delete of an absent key and a zero counter increment. Matching it keeps op + // IDs and concurrent outcomes the same for the same API calls. + test("records the same local ops as loro-crdt for no-op-looking edits", () => { const doc = new LoroDoc(); const map = doc.getMap("map"); map.set("value", { nested: [1, true, null] }); - const movable = doc.getMovableList("movable"); - movable.push("same"); - doc.commit(); - const opCount = doc.opCount(); - - map.set("value", { nested: [1, true, null] }); - map.delete("missing"); - movable.set(0, "same"); - doc.getCounter("counter").increment(0); doc.commit(); + const count = (edit: () => void): number => { + const before = doc.opCount(); + edit(); + doc.commit(); + return doc.opCount() - before; + }; - expect(doc.opCount()).toBe(opCount); + expect(count(() => map.set("value", { nested: [1, true, null] }))).toBe(0); + expect(count(() => map.delete("missing"))).toBe(1); + expect(count(() => doc.getCounter("counter").increment(0))).toBe(1); + // Attaching a detached counter increments it by its value, even 0. + expect(count(() => map.setContainer("child", new LoroCounter()))).toBe(2); + }); + + test("lets a delete of an absent map key win against a concurrent set", () => { + const a = new LoroDoc(); + a.setPeerId(1); + const b = new LoroDoc(); + b.setPeerId(2); + a.getMap("map").set("key", 1); + a.commit(); + b.getMap("map").delete("key"); + b.commit(); + a.import(b.export({ mode: "update" })); + b.import(a.export({ mode: "update" })); + // Same lamport; the larger peer's delete wins, as in loro-crdt. + expect(a.toJSON()).toEqual({ map: {} }); + expect(b.toJSON()).toEqual({ map: {} }); + }); + + test("deletes text ID runs from right to left like loro-crdt", () => { + const peers = [1, 2, 3].map((peer) => { + const doc = new LoroDoc(); + doc.setPeerId(peer); + return doc; + }); + const [p1, p2, p3] = peers as [LoroDoc, LoroDoc, LoroDoc]; + p1.getText("text").insert(0, "ab"); + p1.commit(); + p2.import(p1.export({ mode: "update" })); + p2.getText("text").insert(1, "X"); + p2.commit(); + p3.import(p2.export({ mode: "update" })); + // Three ID runs: a, X, b. + p3.getText("text").delete(0, 3); + p3.commit(); + + const texts = [0, 1, 2].map((counter) => { + p3.checkout([{ peer: "3", counter }]); + return p3.getText("text").toString(); + }); + expect(texts).toEqual(["aX", "a", ""]); }); test("resurfaces preserved state and switches mergeable kinds", () => {