Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/yjs-v1-orphaned-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@lexical/yjs': patch
---

[fix] Keep a v1 collaboration editor showing what its document holds when text a collaborator typed lands where no text node is, such as inside nested marks marked concurrently

The v1 binding removes such text from the document, as before, but that removal was read back as a remote change and deleted the node at its index instead (a mark next to the text vanished from the editor while its document kept it), and a text node whose properties were deleted in a merge had its leftover text removed at an offset into the node rather than its index in the element, deleting other text. The text is now removed with the binding as the origin, at its index, and the element's children are read again from the document.

Verify: with three v1 clients on a paragraph whose middle word is commented, mark a letter of the word and type after it on one client, mark the next letter on another that saw only the first mark, deliver everything, and check that every editor matches a fresh read of its own document.
90 changes: 83 additions & 7 deletions packages/lexical-yjs/src/CollabElementNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import type {Binding} from '.';
import type {AbstractType, Map as YMap, XmlElement, XmlText} from 'yjs';
import type {AbstractType, XmlElement, XmlText} from 'yjs';

import invariant from '@lexical/internal/invariant';
import {
Expand All @@ -22,6 +22,7 @@ import {
type NodeKey,
type NodeMap,
} from 'lexical';
import {Map as YMap} from 'yjs';

import {CollabDecoratorNode} from './CollabDecoratorNode';
import {CollabLineBreakNode} from './CollabLineBreakNode';
Expand Down Expand Up @@ -218,6 +219,7 @@ export class CollabElementNode {
const children = this._children;
let currIndex = 0;
let pendingSplitText = null;
let orphaned = false;

for (let i = 0; i < deltas.length; i++) {
const delta = deltas[i];
Expand Down Expand Up @@ -261,8 +263,10 @@ export class CollabElementNode {
// Merge the text node with previous.
prevCollabNode._text += danglingText;
} else {
// No previous text node to merge into, just delete the text.
this._xmlText.delete(offset, danglingText.length);
// No previous text node to merge into: the text is removed
// from the shared type once all the deltas are applied (see
// settleOrphanedText).
orphaned = true;
}
}
} else {
Expand Down Expand Up @@ -291,10 +295,10 @@ export class CollabElementNode {
// never can be dangling text.

// We have a conflict where there was likely a CollabTextNode and
// an Lexical TextNode too, but they were removed in a merge. So
// let's just ignore the text and trigger a removal for it from our
// shared type.
this._xmlText.delete(offset, insertDelta.length);
// an Lexical TextNode too, but they were removed in a merge: the
// text has no text node to go in. It is removed from the shared
// type once all the deltas are applied (see settleOrphanedText).
orphaned = true;
}

currIndex += insertDelta.length;
Expand Down Expand Up @@ -350,6 +354,78 @@ export class CollabElementNode {
throw new Error('Unexpected delta format');
}
}
if (orphaned) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional, non-blocking: an unconsumed pendingSplitText is a third orphan case that never reaches this check. When a remote embed lands inside a text node and no text node follows it in the same delta (lines 327-350), the tail stays in the shared type right after the embed with no properties map before it, so later deltas for this element are indexed against children that are short by its length and land in the wrong text node. Flagging it lets settleOrphanedText handle it the same way (the base has the same gap, so a follow-up is fine too):

Suggested change
if (orphaned) {
if (pendingSplitText !== null) {
orphaned = true;
}
if (orphaned) {

this.settleOrphanedText(binding);
}
}

/**
* Remove the text of this element's shared type that no text node holds
* (no text node's properties map comes before it: that map was deleted in
* a merge, or the text was inserted where no text node is), and build this
* element's collab children again from the shared type. The removal is
* made with the binding as its origin, so it is not read back as a remote
* change: removing it from the shared type at the index it held, and then
* reading that delete against collab children that never held the text,
* deleted another child (a mark next to the text vanished from the editor
* while its document kept it), and removing it at an offset into a text
* node instead of an index into the element deleted other text.
*/
private settleOrphanedText(binding: Binding): void {
const xmlText = this._xmlText;
const orphans: [index: number, length: number][] = [];
let index = 0;
let inText = false;
for (const op of xmlText.toDelta() as {insert: unknown}[]) {
const insert = op.insert;
if (typeof insert === 'string') {
if (!inText) {
orphans.push([index, insert.length]);
}
index += insert.length;
} else {
inText =
insert instanceof YMap &&
getNodeTypeFromSharedType(insert as YMap<unknown>) !== 'linebreak';
index += 1;
}
}
if (orphans.length > 0) {
binding.doc.transact(() => {
for (let i = orphans.length - 1; i >= 0; i--) {
xmlText.delete(orphans[i][0], orphans[i][1]);
}
}, binding);
}
const children: CollabElementNode['_children'] = [];
let text: CollabTextNode | null = null;
for (const op of xmlText.toDelta() as {insert: unknown}[]) {
const insert = op.insert;
if (typeof insert === 'string') {
if (text !== null) {
text._text += insert;
}
continue;
}
const sharedType = insert as XmlText | YMap<unknown> | XmlElement;
if (getNodeTypeFromSharedType(sharedType) === undefined) {
text = null;
continue;
}
const child = $getOrInitCollabNodeFromSharedType(
binding,
sharedType,
this,
);
if (child instanceof CollabTextNode) {
child._text = '';
text = child;
} else {
text = null;
}
children.push(child);
}
this._children = children;
}

syncChildrenFromYjs(binding: Binding): void {
Expand Down
Loading
Loading