[lexical-yjs][lexical-react] Bug Fix: collab sync gaps between the editor state and the yjs doc - #9057
Open
LeSingh1 wants to merge 1 commit into
Open
[lexical-yjs][lexical-react] Bug Fix: collab sync gaps between the editor state and the yjs doc#9057LeSingh1 wants to merge 1 commit into
LeSingh1 wants to merge 1 commit into
Conversation
…itor state and the yjs doc
## Description
Four places where the yjs binding does not propagate something the local editor
state already carries. Each is a separate one-way gap between what Lexical knows
and what reaches the shared document (or the peers reading it), so they are
fixed together.
- **Unknown node state is dropped when a node is first created** — in
`syncNodeStateFromLexical` (`packages/lexical-yjs/src/Utils.ts`) the unknown-key
loop and the known-key loop disagree about "there is no previous state". The
known loop falls back to an empty `Map`, so on the create path every entry
compares unequal and is written; the unknown loop falls back to `undefined` and
then short-circuits on it, so nothing is written. Since later updates only write
*changed* keys, a value dropped at creation stays dropped forever and peers never
receive it — defeating the purpose of `NodeState.unknownState`, which exists so an
older build cannot erase metadata written by a newer one. The unknown loop now
uses the same "no previous state means everything is new" rule.
- **A remote cursor keeps a stale name and colour** — `syncCursorPositions`
(`packages/lexical-yjs/src/SyncCursors.ts`) reads `name` and `color` from each
peer's awareness state but only applies them on the pass that first creates the
cursor; every later pass updates only `anchor` / `focus`. Both values are baked
into the caret DOM and the `::highlight()` rule when the selection is built, so a
peer that renames itself or changes colour keeps its old label on every other
client until it disconnects. This is reachable through the supported API:
`LexicalCollaborationPlugin` takes `username` / `cursorColor` props and
`useYjsCollaboration` republishes local awareness when they change. When either
field actually changes the stale selection is now destroyed and cleared so the
existing code rebuilds it from the new values on the same pass; an unchanged peer
keeps its cursor object, so ordinary cursor movement causes no rebuild churn.
- **Element selection points are not converted to yjs child indices in collab-v2** —
`createRelativePositionV2` (`packages/lexical-yjs/src/SyncCursors.ts`) hands the
lexical child offset straight to `createRelativePositionFromTypeIndex`, but
`normalizeNodeContent` (`SyncV2`) serializes a run of adjacent `TextNode`s as a
single `XmlText` child, so the two numbers differ. The inner loop walked the text
run and threw the result away. For a `[Text, Text, Decorator]` paragraph, a caret
before the decorator (lexical offset 2) encoded as yjs index 2 — past the
decorator — so remote peers rendered the cursor on the wrong side of it, and at
the end of the paragraph the index went out of range. The encoder now counts each
text run as one yjs child, mirroring its inverse `$getNodeAndOffsetV2`.
- **The bootstrapped `initialEditorState` was undoable** — `CollaborationPlugin`
writes `initialEditorState` into an empty shared document through a normal editor
update, which syncs to yjs under the binding origin. That origin is tracked by the
`UndoManager`, so the very first undo removed the initial content, unlike a
non-collab editor which applies its initial state with `HISTORY_MERGE_TAG`. A new
`binding.isBootstrapping` flag (`packages/lexical-yjs/src/Bindings.ts`, mirrored in
`packages/lexical-yjs/flow/LexicalYjs.js.flow`) is set by `bootstrapEditor`
(`packages/lexical-react/src/shared/useYjsCollaboration.tsx`) while that write is in
flight, and `createUndoManager` (`packages/lexical-yjs/src/index.ts`) passes a
`captureTransaction` that skips transactions produced during it. The flag is
cleared from a microtask because the editor update commits — and therefore syncs
to yjs — in one; clearing it there rather than from the commit means it cannot get
stuck if the bootstrap update is a no-op.
## Test plan
Four new unit tests, one per defect: `NodeStateSyncUnknown.test.ts`,
`SyncCursorsAwarenessRefresh.test.ts` and `SyncCursorsV2ElementPoint.test.ts` under
`packages/lexical-yjs/src/__tests__/unit/`, plus a case added to
`packages/lexical-react/src/__tests__/unit/LexicalCollaborationPlugin.test.tsx`. Each
drives the real binding rather than the internal helper, and each carries control
cases that pass both before and after (known state on the create path, an unchanged
peer keeping the same cursor object, element offsets 0 and 3) so the failure is
pinned to the specific gap.
### Before
```
$ npx vitest run --project unit packages/lexical-yjs packages/lexical-react
× the bootstrapped initialEditorState can not be undone 34ms
× unknown state on a newly created node is written to the shared doc 13ms
× several unknown keys all reach the shared doc 1ms
× a peer that renames itself updates its cursor name 8ms
× a peer that changes colour updates its cursor colour 1ms
× an element point before a decorator that follows a text run round trips 12ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 6 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected '' to be 'Initial content' // Object.is equality
AssertionError: expected undefined to be 42 // Object.is equality
AssertionError: expected undefined to be 1 // Object.is equality
AssertionError: expected 'Bob' to be 'Robert' // Object.is equality
AssertionError: expected '#ff0000' to be '#0000ff' // Object.is equality
AssertionError: expected 3 to be 2 // Object.is equality
Test Files 4 failed | 33 passed (37)
Tests 6 failed | 260 passed (266)
```
### After
```
$ npx vitest run --project unit packages/lexical-yjs packages/lexical-react
Test Files 37 passed (37)
Tests 266 passed (266)
$ npx tsc --noEmit -p .
(clean, exit 0)
```
The remote-cursor DOM rebuild itself is not exercised by the unit test — jsdom has
no layout, so `updateCursor` returns before touching the caret — and it was not
verified in a real browser.
Supersedes facebook#8966, facebook#9007, facebook#9013, facebook#9016, consolidated per the review feedback on
facebook#9027 and facebook#9035.
LeSingh1
requested review from
acywatson,
etrepum,
fantactuka,
ivailop7,
potatowagon and
zurfyx
as code owners
August 10, 2026 04:16
|
@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 was referenced Aug 10, 2026
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
Four places where the yjs binding does not propagate something the local editor
state already carries. Each is a separate one-way gap between what Lexical knows
and what reaches the shared document (or the peers reading it), so they are
fixed together.
Unknown node state is dropped when a node is first created — in
syncNodeStateFromLexical(packages/lexical-yjs/src/Utils.ts) the unknown-keyloop and the known-key loop disagree about "there is no previous state". The
known loop falls back to an empty
Map, so on the create path every entrycompares unequal and is written; the unknown loop falls back to
undefinedandthen short-circuits on it, so nothing is written. Since later updates only write
changed keys, a value dropped at creation stays dropped forever and peers never
receive it — defeating the purpose of
NodeState.unknownState, which exists so anolder build cannot erase metadata written by a newer one. The unknown loop now
uses the same "no previous state means everything is new" rule.
A remote cursor keeps a stale name and colour —
syncCursorPositions(
packages/lexical-yjs/src/SyncCursors.ts) readsnameandcolorfrom eachpeer's awareness state but only applies them on the pass that first creates the
cursor; every later pass updates only
anchor/focus. Both values are bakedinto the caret DOM and the
::highlight()rule when the selection is built, so apeer that renames itself or changes colour keeps its old label on every other
client until it disconnects. This is reachable through the supported API:
LexicalCollaborationPlugintakesusername/cursorColorprops anduseYjsCollaborationrepublishes local awareness when they change. When eitherfield actually changes the stale selection is now destroyed and cleared so the
existing code rebuilds it from the new values on the same pass; an unchanged peer
keeps its cursor object, so ordinary cursor movement causes no rebuild churn.
Element selection points are not converted to yjs child indices in collab-v2 —
createRelativePositionV2(packages/lexical-yjs/src/SyncCursors.ts) hands thelexical child offset straight to
createRelativePositionFromTypeIndex, butnormalizeNodeContent(SyncV2) serializes a run of adjacentTextNodes as asingle
XmlTextchild, so the two numbers differ. The inner loop walked the textrun and threw the result away. For a
[Text, Text, Decorator]paragraph, a caretbefore the decorator (lexical offset 2) encoded as yjs index 2 — past the
decorator — so remote peers rendered the cursor on the wrong side of it, and at
the end of the paragraph the index went out of range. The encoder now counts each
text run as one yjs child, mirroring its inverse
$getNodeAndOffsetV2.The bootstrapped
initialEditorStatewas undoable —CollaborationPluginwrites
initialEditorStateinto an empty shared document through a normal editorupdate, which syncs to yjs under the binding origin. That origin is tracked by the
UndoManager, so the very first undo removed the initial content, unlike anon-collab editor which applies its initial state with
HISTORY_MERGE_TAG. A newbinding.isBootstrappingflag (packages/lexical-yjs/src/Bindings.ts, mirrored inpackages/lexical-yjs/flow/LexicalYjs.js.flow) is set bybootstrapEditor(
packages/lexical-react/src/shared/useYjsCollaboration.tsx) while that write is inflight, and
createUndoManager(packages/lexical-yjs/src/index.ts) passes acaptureTransactionthat skips transactions produced during it. The flag iscleared from a microtask because the editor update commits — and therefore syncs
to yjs — in one; clearing it there rather than from the commit means it cannot get
stuck if the bootstrap update is a no-op.
Test plan
Four new unit tests, one per defect:
NodeStateSyncUnknown.test.ts,SyncCursorsAwarenessRefresh.test.tsandSyncCursorsV2ElementPoint.test.tsunderpackages/lexical-yjs/src/__tests__/unit/, plus a case added topackages/lexical-react/src/__tests__/unit/LexicalCollaborationPlugin.test.tsx. Eachdrives the real binding rather than the internal helper, and each carries control
cases that pass both before and after (known state on the create path, an unchanged
peer keeping the same cursor object, element offsets 0 and 3) so the failure is
pinned to the specific gap.
Before
After
The remote-cursor DOM rebuild itself is not exercised by the unit test — jsdom has
no layout, so
updateCursorreturns before touching the caret — and it was notverified in a real browser.
Supersedes #8966, #9007, #9013, #9016, consolidated per the review feedback on
#9027 and #9035.