Skip to content

Richtext: expand-after mark absorbs a concurrent insert at its start boundary; undoing the mark leaves style residue on the merged remote text #1009

Description

@leotumwattana

Summary

When a peer inserts text concurrently at the start boundary of an expand-after mark, two related problems appear after merging:

  1. The mark absorbs the concurrently-inserted remote text — even though the same insert performed locally correctly stays outside the mark (expand-after must not grow backward from its start).
  2. Undoing the style commit leaves residue on the remote text: the UndoManager inverse removes the style from the originally-marked characters only, so the absorbed remote text stays styled — the user ends up with formatting on text they never touched, and no way to undo it.

Text content is unaffected in all cases; only style state diverges from expectations. Reproduced on loro-crdt 1.13.1 (JS, Node 23) and loro-swift 1.10.3 (iOS). Local-only undo and local-insert-at-boundary both behave correctly — the bug is specific to the concurrent-merge path.

Minimal repro (loro-crdt@1.13.1, Node)

import { LoroDoc, UndoManager } from "loro-crdt";

const boldRanges = (text) => {
  const ranges = [];
  let offset = 0;
  for (const op of text.toDelta()) {
    const len = [...op.insert].length; // unicode scalars
    if (op.attributes?.bold === true) ranges.push([offset, offset + len]);
    offset += len;
  }
  return ranges;
};

const docA = new LoroDoc();
docA.setPeerId(1n);
const undo = new UndoManager(docA, { mergeInterval: 0 });
const tA = docA.getText("t");
tA.insert(0, "important note");
docA.commit();

const docB = new LoroDoc();
docB.setPeerId(2n);
docB.import(docA.export({ mode: "snapshot" }));
const tB = docB.getText("t");

// A bolds "important" while B concurrently inserts "very " at position 0.
tA.mark({ start: 0, end: 9 }, "bold", true);
docA.commit();
tB.insert(0, "very ");
docB.commit();

docA.import(docB.export({ mode: "update", from: docA.version() }));
console.log("MERGED bold (pre-undo):", JSON.stringify(boldRanges(tA)));
// → [[0,14]]   (bold covers "very important" — absorbed the remote insert)

undo.undo();
console.log("POST-UNDO text:", JSON.stringify(tA.toString()));
// → "very important note"   (text correct)
console.log("POST-UNDO bold:", JSON.stringify(boldRanges(tA)));
// → [[0,5]]   (bold residue on "very " — text peer A never marked)

Expected vs actual

Step Expected Actual (1.13.1)
Merge: concurrent insert at expand-after mark start bold stays on "important"[[5,14]] (matching the local-edit behavior below) bold = [[0,14]] — remote "very " absorbed
undo.undo() of the style commit bold removed entirely → [] bold = [[0,5]] — residue on the remote text

Controls (both behave correctly)

  • Local-only undo: insert → mark → undo ⇒ bold []. ✅
  • Local insert at the mark start: insert "very " at 0 on the same peer that holds the mark ⇒ bold [[5,14]] — expand-after correctly does not grow backward. ✅ (This is the behavior the concurrent merge should match.)

The asymmetry between the local and concurrent cases suggests the mark's start anchor resolves differently when the insertion arrives via merge than when it is applied locally. The undo residue then follows mechanically: the inverse un-styles the original character span, while the absorbed span keeps the style.

Environment

Context

We hit this evaluating Loro for per-paragraph collaborative text in a production iOS app (chosen over alternatives in part for the shallow-snapshot model — which has been excellent). We're shipping with a workaround (style commits tagged with an excluded origin prefix, so style undo is handled app-side), so this isn't blocking us — but the merge-absorption half affects any rich-text user even without UndoManager. Happy to test a fix against either binding, or provide the Swift repro as well.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions