Skip to content

[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
facebook:mainfrom
LeSingh1:consol/yjs-sync
Open

[lexical-yjs][lexical-react] Bug Fix: collab sync gaps between the editor state and the yjs doc#9057
LeSingh1 wants to merge 1 commit into
facebook:mainfrom
LeSingh1:consol/yjs-sync

Conversation

@LeSingh1

Copy link
Copy Markdown
Contributor

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 coloursyncCursorPositions
    (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 TextNodes 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 undoableCollaborationPlugin
    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 #8966, #9007, #9013, #9016, consolidated per the review feedback on
#9027 and #9035.

…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.
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant