[lexical-yjs] Bug Fix: convert element selection points to yjs child indices in collab-v2 - #9016
Closed
LeSingh1 wants to merge 1 commit into
Closed
[lexical-yjs] Bug Fix: convert element selection points to yjs child indices in collab-v2#9016LeSingh1 wants to merge 1 commit into
LeSingh1 wants to merge 1 commit into
Conversation
…indices in collab-v2
## Description
In collab-v2, `normalizeNodeContent` (`SyncV2`) serializes a run of adjacent
`TextNode`s as a *single* `XmlText` child. A paragraph whose lexical children
are `[Text, Text, Decorator]` therefore has only two yjs children:
`[XmlText, XmlElement]`. Lexical child offsets and yjs child indices are not
the same number.
`createRelativePositionV2` walks one lexical child per step and hands the
lexical offset straight to `createRelativePositionFromTypeIndex`:
```js
let i = 0;
let child = node.getFirstChild();
while (child !== null && i < offset) {
if ($isTextNode(child)) {
let nextSibling = child.getNextSibling();
while ($isTextNode(nextSibling)) {
nextSibling = nextSibling.getNextSibling(); // computed, then discarded
}
}
i++;
child = child.getNextSibling();
}
return createRelativePositionFromTypeIndex(yType, i, assoc);
```
The inner loop walks the text run but throws the result away — `child` and `i`
still advance one lexical child at a time. Its exact inverse,
`$getNodeAndOffsetV2`, does the collapse correctly: it consumes one yjs offset
per child and then skips the rest of a text run. The discarded `nextSibling` is
the fossil of the same collapse that was meant to happen here.
Consequence: for the paragraph above, a caret *before* the decorator is lexical
offset 2, which encodes as yjs index 2 — past the decorator. Remote peers
render the cursor on the wrong side of the decorator. At the end of the
paragraph the encoded index is out of range and collapses to "end of type".
This advances a lexical cursor to `offset` while counting each text run as one
yjs child, so the encoder mirrors the decoder.
## Test plan
New unit test `packages/lexical-yjs/src/__tests__/unit/SyncCursorsV2ElementPoint.test.ts`.
It builds a real V2 binding, serializes a `[Text, Text, Decorator]` paragraph,
pushes an element-type selection through `syncLexicalSelectionToYjs` (the
awareness encoder remote peers actually receive) and decodes it back with
`$getAnchorAndFocusForUserState`. Offsets 0 and 3 are included as controls —
they already round trip, so the failure is pinned to a point that sits after a
text run and before another child.
### Before
```
$ npx vitest run packages/lexical-yjs/src/__tests__/unit/SyncCursorsV2ElementPoint.test.ts
× an element point before a decorator that follows a text run round trips 14ms
AssertionError: expected 3 to be 2 // Object.is equality
Tests 1 failed | 2 passed (3)
```
### After
```
$ npx vitest run packages/lexical-yjs/src/__tests__/unit/SyncCursorsV2ElementPoint.test.ts
Test Files 1 passed (1)
Tests 3 passed (3)
$ npx vitest run packages/lexical-yjs
Test Files 6 passed (6)
Tests 81 passed (81)
$ npx vitest run packages/lexical-react
Test Files 31 passed (31)
Tests 182 passed (182)
```
LeSingh1
requested review from
acywatson,
etrepum,
fantactuka,
ivailop7,
potatowagon and
zurfyx
as code owners
August 9, 2026 05:22
|
@LeSingh1 is attempting to deploy a commit to the Meta Open Source Team on Vercel. A member of the Team first needs to authorize it. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
In collab-v2,
normalizeNodeContent(SyncV2) serializes a run of adjacentTextNodes as a singleXmlTextchild. A paragraph whose lexical childrenare
[Text, Text, Decorator]therefore has only two yjs children:[XmlText, XmlElement]. Lexical child offsets and yjs child indices are notthe same number.
createRelativePositionV2walks one lexical child per step and hands thelexical offset straight to
createRelativePositionFromTypeIndex:The inner loop walks the text run but throws the result away —
childandistill advance one lexical child at a time. Its exact inverse,
$getNodeAndOffsetV2, does the collapse correctly: it consumes one yjs offsetper child and then skips the rest of a text run. The discarded
nextSiblingisthe fossil of the same collapse that was meant to happen here.
Consequence: for the paragraph above, a caret before the decorator is lexical
offset 2, which encodes as yjs index 2 — past the decorator. Remote peers
render the cursor on the wrong side of the decorator. At the end of the
paragraph the encoded index is out of range and collapses to "end of type".
This advances a lexical cursor to
offsetwhile counting each text run as oneyjs child, so the encoder mirrors the decoder.
Test plan
New unit test
packages/lexical-yjs/src/__tests__/unit/SyncCursorsV2ElementPoint.test.ts.It builds a real V2 binding, serializes a
[Text, Text, Decorator]paragraph,pushes an element-type selection through
syncLexicalSelectionToYjs(theawareness encoder remote peers actually receive) and decodes it back with
$getAnchorAndFocusForUserState. Offsets 0 and 3 are included as controls —they already round trip, so the failure is pinned to a point that sits after a
text run and before another child.
Before
After