Summary
When a peer inserts text concurrently at the start boundary of an expand-after mark, two related problems appear after merging:
- 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).
- 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.
Summary
When a peer inserts text concurrently at the start boundary of an expand-
aftermark, two related problems appear after merging:aftermust not grow backward from its start).UndoManagerinverse 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)
Expected vs actual
aftermark start"important"→[[5,14]](matching the local-edit behavior below)[[0,14]]— remote"very "absorbedundo.undo()of the style commit[][[0,5]]— residue on the remote textControls (both behave correctly)
[]. ✅"very "at 0 on the same peer that holds the mark ⇒ bold[[5,14]]— expand-aftercorrectly 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
[[0,14]]merge state and[[0,5]]post-undo residue (positions verified via a separate UTF-16 path, so this is not an index-encoding artifact)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.