From 97cf018ac4ae799a9406ff4106eecb7fc692aabf Mon Sep 17 00:00:00 2001 From: LeSingh1 Date: Sun, 9 Aug 2026 20:56:27 -0700 Subject: [PATCH] [lexical-yjs][lexical-react] Bug Fix: collab sync gaps between the editor state and the yjs doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 #8966, #9007, #9013, #9016, consolidated per the review feedback on #9027 and #9035. --- .../unit/LexicalCollaborationPlugin.test.tsx | 109 ++++++++++ .../src/shared/useYjsCollaboration.tsx | 32 ++- packages/lexical-yjs/flow/LexicalYjs.js.flow | 1 + packages/lexical-yjs/src/Bindings.ts | 8 + packages/lexical-yjs/src/SyncCursors.ts | 32 ++- packages/lexical-yjs/src/Utils.ts | 6 +- .../unit/NodeStateSyncUnknown.test.ts | 147 +++++++++++++ .../unit/SyncCursorsAwarenessRefresh.test.ts | 105 +++++++++ .../unit/SyncCursorsV2ElementPoint.test.ts | 199 ++++++++++++++++++ packages/lexical-yjs/src/index.ts | 4 + 10 files changed, 634 insertions(+), 9 deletions(-) create mode 100644 packages/lexical-yjs/src/__tests__/unit/NodeStateSyncUnknown.test.ts create mode 100644 packages/lexical-yjs/src/__tests__/unit/SyncCursorsAwarenessRefresh.test.ts create mode 100644 packages/lexical-yjs/src/__tests__/unit/SyncCursorsV2ElementPoint.test.ts diff --git a/packages/lexical-react/src/__tests__/unit/LexicalCollaborationPlugin.test.tsx b/packages/lexical-react/src/__tests__/unit/LexicalCollaborationPlugin.test.tsx index e48e0e34c70..67b31244990 100644 --- a/packages/lexical-react/src/__tests__/unit/LexicalCollaborationPlugin.test.tsx +++ b/packages/lexical-react/src/__tests__/unit/LexicalCollaborationPlugin.test.tsx @@ -6,18 +6,70 @@ * */ +import type {Provider} from '@lexical/yjs'; +import type {LexicalEditor} from 'lexical'; + import {LexicalCollaboration} from '@lexical/react/LexicalCollaborationContext'; import {CollaborationPlugin} from '@lexical/react/LexicalCollaborationPlugin'; import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {ContentEditable} from '@lexical/react/LexicalContentEditable'; import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary'; import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + UNDO_COMMAND, +} from 'lexical'; import * as React from 'react'; import {act} from 'react'; import {createRoot, type Root} from 'react-dom/client'; import {beforeEach, describe, expect, test, vi} from 'vitest'; import * as Y from 'yjs'; +/** + * A minimal in-memory {@link Provider} whose `connect()` immediately reports a + * completed sync, which is what drives the `shouldBootstrap` code path. + */ +function createSyncedProvider(): Provider { + const listeners = new Map void>>(); + + return { + awareness: { + getLocalState: () => null, + getStates: () => new Map(), + off: () => {}, + on: () => {}, + setLocalState: () => {}, + setLocalStateField: () => {}, + }, + connect: () => { + const syncListeners = listeners.get('sync'); + if (syncListeners !== undefined) { + for (const cb of Array.from(syncListeners)) { + (cb as (isSynced: boolean) => void)(true); + } + } + }, + disconnect: () => {}, + off: (type: string, cb: (arg: never) => void) => { + const set = listeners.get(type); + if (set !== undefined) { + set.delete(cb); + } + }, + on: (type: string, cb: (arg: never) => void) => { + let set = listeners.get(type); + if (set === undefined) { + set = new Set(); + listeners.set(type, set); + } + set.add(cb); + }, + } as Provider; +} + describe(`LexicalCollaborationPlugin`, () => { let container: HTMLDivElement; let reactRoot: Root; @@ -98,4 +150,61 @@ describe(`LexicalCollaborationPlugin`, () => { expect(providerFactory).toHaveBeenCalledTimes(1); }); + + // https://github.com/facebook/lexical/issues/7110 + test(`the bootstrapped initialEditorState can not be undone`, async () => { + const doc = new Y.Doc(); + const provider = createSyncedProvider(); + let editor: LexicalEditor | null = null; + + function CaptureEditor() { + [editor] = useLexicalComposerContext(); + return null; + } + + function App() { + return ( + + + + { + yjsDocMap.set(id, doc); + return provider; + }} + shouldBootstrap={true} + initialEditorState={() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode('Initial content')); + root.append(paragraph); + }} + /> + } + placeholder={<>} + ErrorBoundary={LexicalErrorBoundary} + /> + + + ); + } + + await act(async () => { + reactRoot.render(); + }); + + const activeEditor = editor!; + const readText = () => + activeEditor.getEditorState().read(() => $getRoot().getTextContent()); + + expect(readText()).toBe('Initial content'); + + await act(async () => { + activeEditor.dispatchCommand(UNDO_COMMAND, undefined); + }); + + expect(readText()).toBe('Initial content'); + }); }); diff --git a/packages/lexical-react/src/shared/useYjsCollaboration.tsx b/packages/lexical-react/src/shared/useYjsCollaboration.tsx index 3db90c93779..0f15cd4b224 100644 --- a/packages/lexical-react/src/shared/useYjsCollaboration.tsx +++ b/packages/lexical-react/src/shared/useYjsCollaboration.tsx @@ -108,7 +108,7 @@ export function useYjsCollaboration( const onBootstrap = useCallback(() => { const {root} = binding; if (shouldBootstrap && root.isEmpty() && root._xmlText._length === 0) { - initializeEditor(editor, initialEditorState); + bootstrapEditor(binding, editor, initialEditorState); } }, [binding, editor, initialEditorState, shouldBootstrap]); @@ -244,7 +244,7 @@ export function useYjsCollaborationV2__EXPERIMENTAL( const onBootstrap = useCallback(() => { const {root} = binding; if (shouldBootstrap && root._length === 0) { - initializeEditor(editor); + bootstrapEditor(binding, editor); } }, [binding, editor, shouldBootstrap]); @@ -635,6 +635,34 @@ function useYjsUndoManager(editor: LexicalEditor, undoManager: UndoManager) { return clearHistory; } +/** + * Write the initial editor state into an empty shared document. The write is + * flagged on the binding so that the Yjs UndoManager created by + * `createUndoManager` skips the resulting transaction: bootstrapping is not a + * user edit and must not be undoable, which matches a non-collab editor where + * the initial state is applied with HISTORY_MERGE_TAG (#7110). + */ +function bootstrapEditor( + binding: BaseBinding, + editor: LexicalEditor, + initialEditorState?: InitialEditorStateType, +): void { + binding.isBootstrapping = true; + try { + initializeEditor(editor, initialEditorState); + } finally { + // `editor.update` commits in a microtask, and the Yjs write happens in the + // update listener during that commit, so the flag has to outlive this call. + // Lexical schedules the commit with `queueMicrotask` from inside + // `editor.update`, so it is already queued ahead of this one. Resetting + // here rather than from the commit itself also means the flag can never get + // stuck when the update turns out to be a no-op. + queueMicrotask(() => { + binding.isBootstrapping = false; + }); + } +} + function initializeEditor( editor: LexicalEditor, initialEditorState?: InitialEditorStateType, diff --git a/packages/lexical-yjs/flow/LexicalYjs.js.flow b/packages/lexical-yjs/flow/LexicalYjs.js.flow index 54a2143a7d8..c15266f6693 100644 --- a/packages/lexical-yjs/flow/LexicalYjs.js.flow +++ b/packages/lexical-yjs/flow/LexicalYjs.js.flow @@ -110,6 +110,7 @@ export type BaseBinding = { editor: LexicalEditor, excludedProperties: ExcludedProperties, id: string, + isBootstrapping: boolean, nodeProperties: Map, }; diff --git a/packages/lexical-yjs/src/Bindings.ts b/packages/lexical-yjs/src/Bindings.ts index e999667cc7c..0af7136cd31 100644 --- a/packages/lexical-yjs/src/Bindings.ts +++ b/packages/lexical-yjs/src/Bindings.ts @@ -39,6 +39,13 @@ export interface BaseBinding { id: string; nodeProperties: Map; // node type to property to default value excludedProperties: ExcludedProperties; + /** + * True only while the initial editor state is being written into an empty + * shared document (the `shouldBootstrap` path). Bootstrapping is not a user + * edit, so the {@link UndoManager} returned by `createUndoManager` does not + * capture transactions produced while this is set. + */ + isBootstrapping: boolean; } export interface Binding extends BaseBinding { @@ -78,6 +85,7 @@ function createBaseBinding( editor, excludedProperties: excludedProperties || new Map(), id, + isBootstrapping: false, nodeProperties: new Map(), }; initializeNodeProperties(binding); diff --git a/packages/lexical-yjs/src/SyncCursors.ts b/packages/lexical-yjs/src/SyncCursors.ts index a55500e930e..73aee77fbe0 100644 --- a/packages/lexical-yjs/src/SyncCursors.ts +++ b/packages/lexical-yjs/src/SyncCursors.ts @@ -242,19 +242,28 @@ function createRelativePositionV2( return createRelativePositionFromTypeIndex(yType, adjustedOffset, assoc); } else if (point.type === 'element') { invariant($isElementNode(node), 'Element point must be an element node'); - let i = 0; + // `offset` counts lexical children, but the index handed to yjs counts + // yjs children, and normalizeNodeContent collapses a run of adjacent + // TextNodes into a single XmlText child. Advance a lexical cursor to + // `offset` while counting each text run as one yjs child, mirroring + // $getNodeAndOffsetV2, which consumes one yjs offset per child and then + // skips the remainder of a text run. + let yIndex = 0; + let lexicalIndex = 0; let child = node.getFirstChild(); - while (child !== null && i < offset) { + while (child !== null && lexicalIndex < offset) { + let nextSibling = child.getNextSibling(); + lexicalIndex++; if ($isTextNode(child)) { - let nextSibling = child.getNextSibling(); while ($isTextNode(nextSibling)) { nextSibling = nextSibling.getNextSibling(); + lexicalIndex++; } } - i++; - child = child.getNextSibling(); + yIndex++; + child = nextSibling; } - return createRelativePositionFromTypeIndex(yType, i, assoc); + return createRelativePositionFromTypeIndex(yType, yIndex, assoc); } return null; } @@ -912,6 +921,17 @@ export function syncCursorPositions( if (cursor === undefined) { cursor = createCursor(name, color); cursors.set(clientID, cursor); + } else if (cursor.name !== name || cursor.color !== color) { + // Awareness is mutable: a peer can rename itself or change colour at + // any time (the React plugin republishes local state whenever its + // `username` / `cursorColor` props change). The name and colour are + // baked into the caret DOM and the ::highlight() rule when the + // selection is built, so drop the stale selection here and let the + // code below rebuild it from the new values. + destroyCursor(binding, cursor); + cursor.name = name; + cursor.color = color; + cursor.selection = null; } if (focusing) { diff --git a/packages/lexical-yjs/src/Utils.ts b/packages/lexical-yjs/src/Utils.ts index 59ee5b0ace4..2397cfd690f 100644 --- a/packages/lexical-yjs/src/Utils.ts +++ b/packages/lexical-yjs/src/Utils.ts @@ -637,7 +637,11 @@ function syncNodeStateFromLexical( : [undefined, new Map()]; if (unknown) { for (const [k, v] of Object.entries(unknown)) { - if (prevUnknown && v !== prevUnknown[k]) { + // `prevUnknown` is undefined when there is no previous state at all (the + // node is being created) and also when the previous state only had known + // keys. Both mean "nothing was synced yet", so every entry is new — the + // known loop below expresses the same thing with its empty-Map default. + if (!prevUnknown || v !== prevUnknown[k]) { stateMap.set(k, v); } } diff --git a/packages/lexical-yjs/src/__tests__/unit/NodeStateSyncUnknown.test.ts b/packages/lexical-yjs/src/__tests__/unit/NodeStateSyncUnknown.test.ts new file mode 100644 index 00000000000..3cf1639322d --- /dev/null +++ b/packages/lexical-yjs/src/__tests__/unit/NodeStateSyncUnknown.test.ts @@ -0,0 +1,147 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type {XmlText} from 'yjs'; + +import { + buildEditorFromExtensions, + type LexicalEditorWithDispose, +} from '@lexical/extension'; +import {createBinding, type Provider} from '@lexical/yjs'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getWritableNodeState, + $setState, + createState, + defineExtension, + type LexicalEditor, +} from 'lexical'; +import {afterEach, assert, describe, expect, test} from 'vitest'; +import {Doc, Map as YMap} from 'yjs'; + +// A state key that IS registered on the node type, used as a control: known +// state already syncs correctly on the create path. +const knownFlagState = createState('knownFlag', { + parse: v => (typeof v === 'string' ? v : ''), +}); + +describe('collab-v1 node state: unknown keys', () => { + const editors: LexicalEditorWithDispose[] = []; + afterEach(() => { + for (const editor of editors) { + editor.dispose(); + } + editors.length = 0; + }); + + function buildBinding() { + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: null, + name: '[node-state-unknown]', + }), + ); + editors.push(editor); + const doc = new Doc(); + const docMap = new Map([['node-state-unknown', doc]]); + const binding = createBinding( + editor, + null as unknown as Provider, + 'node-state-unknown', + doc, + docMap, + ); + return {binding, doc, editor}; + } + + function serialize( + editor: LexicalEditor, + binding: ReturnType, + ) { + editor.read(() => { + binding.doc.transact(() => { + binding.root.syncChildrenFromLexical( + binding, + $getRoot(), + null, + null, + null, + ); + }); + }); + } + + function paragraphStateMap(binding: ReturnType) { + const collab = binding.root._children[0]; + assert('_xmlText' in collab); + const xmlText = collab._xmlText as XmlText; + const state = xmlText.getAttribute('__state') as unknown; + assert(state instanceof YMap); + return state as YMap; + } + + test('unknown state on a newly created node is written to the shared doc', () => { + const {binding, editor} = buildBinding(); + + editor.update( + () => { + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode('hello')); + $getRoot().clear().append(paragraph); + // State written by a plugin that this build does not have registered. + $getWritableNodeState(paragraph).updateFromUnknown('pluginKey', 42); + }, + {discrete: true}, + ); + + serialize(editor, binding); + + expect(paragraphStateMap(binding).get('pluginKey')).toBe(42); + }); + + test('known state on a newly created node is written to the shared doc', () => { + const {binding, editor} = buildBinding(); + + editor.update( + () => { + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode('hello')); + $getRoot().clear().append(paragraph); + $setState(paragraph, knownFlagState, 'on'); + }, + {discrete: true}, + ); + + serialize(editor, binding); + + expect(paragraphStateMap(binding).get('knownFlag')).toBe('on'); + }); + + test('several unknown keys all reach the shared doc', () => { + const {binding, editor} = buildBinding(); + + editor.update( + () => { + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode('hello')); + $getRoot().clear().append(paragraph); + const state = $getWritableNodeState(paragraph); + state.updateFromUnknown('a', 1); + state.updateFromUnknown('b', 'two'); + }, + {discrete: true}, + ); + + serialize(editor, binding); + + const stateMap = paragraphStateMap(binding); + expect(stateMap.get('a')).toBe(1); + expect(stateMap.get('b')).toBe('two'); + }); +}); diff --git a/packages/lexical-yjs/src/__tests__/unit/SyncCursorsAwarenessRefresh.test.ts b/packages/lexical-yjs/src/__tests__/unit/SyncCursorsAwarenessRefresh.test.ts new file mode 100644 index 00000000000..a391fb29ca7 --- /dev/null +++ b/packages/lexical-yjs/src/__tests__/unit/SyncCursorsAwarenessRefresh.test.ts @@ -0,0 +1,105 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + buildEditorFromExtensions, + type LexicalEditorWithDispose, +} from '@lexical/extension'; +import { + createBinding, + type Provider, + syncCursorPositions, + type UserState, +} from '@lexical/yjs'; +import {defineExtension} from 'lexical'; +import {afterEach, assert, describe, expect, test} from 'vitest'; +import {Doc} from 'yjs'; + +const REMOTE_CLIENT_ID = 4242; + +function userState(name: string, color: string): UserState { + return { + anchorPos: null, + awarenessData: {}, + color, + focusPos: null, + focusing: false, + name, + }; +} + +describe('syncCursorPositions awareness refresh', () => { + const editors: LexicalEditorWithDispose[] = []; + afterEach(() => { + for (const editor of editors) { + editor.dispose(); + } + editors.length = 0; + }); + + function buildBinding() { + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: null, + name: '[cursor-awareness]', + }), + ); + editors.push(editor); + const doc = new Doc(); + const docMap = new Map([['cursor-awareness', doc]]); + const binding = createBinding( + editor, + null as unknown as Provider, + 'cursor-awareness', + doc, + docMap, + ); + return {binding, editor}; + } + + function sync( + binding: ReturnType, + state: UserState, + ): void { + syncCursorPositions(binding, null as unknown as Provider, { + getAwarenessStates: () => + new Map([[REMOTE_CLIENT_ID, state]]), + }); + } + + test('a peer that renames itself updates its cursor name', () => { + const {binding} = buildBinding(); + + sync(binding, userState('Bob', '#ff0000')); + const cursor = binding.cursors.get(REMOTE_CLIENT_ID); + assert(cursor !== undefined); + expect(cursor.name).toBe('Bob'); + + sync(binding, userState('Robert', '#ff0000')); + expect(binding.cursors.get(REMOTE_CLIENT_ID)?.name).toBe('Robert'); + }); + + test('a peer that changes colour updates its cursor colour', () => { + const {binding} = buildBinding(); + + sync(binding, userState('Bob', '#ff0000')); + expect(binding.cursors.get(REMOTE_CLIENT_ID)?.color).toBe('#ff0000'); + + sync(binding, userState('Bob', '#0000ff')); + expect(binding.cursors.get(REMOTE_CLIENT_ID)?.color).toBe('#0000ff'); + }); + + test('an unchanged peer keeps the same cursor object', () => { + const {binding} = buildBinding(); + + sync(binding, userState('Bob', '#ff0000')); + const first = binding.cursors.get(REMOTE_CLIENT_ID); + sync(binding, userState('Bob', '#ff0000')); + expect(binding.cursors.get(REMOTE_CLIENT_ID)).toBe(first); + }); +}); diff --git a/packages/lexical-yjs/src/__tests__/unit/SyncCursorsV2ElementPoint.test.ts b/packages/lexical-yjs/src/__tests__/unit/SyncCursorsV2ElementPoint.test.ts new file mode 100644 index 00000000000..79e1b414afb --- /dev/null +++ b/packages/lexical-yjs/src/__tests__/unit/SyncCursorsV2ElementPoint.test.ts @@ -0,0 +1,199 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import { + buildEditorFromExtensions, + type LexicalEditorWithDispose, +} from '@lexical/extension'; +import { + $getAnchorAndFocusForUserState, + createBindingV2__EXPERIMENTAL, + type Provider, + type ProviderAwareness, + type UserState, +} from '@lexical/yjs'; +import { + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getRoot, + $getSelection, + $setSelection, + defineExtension, + type LexicalEditor, +} from 'lexical'; +import { + $createTestDecoratorNode, + TestDecoratorNode, +} from 'lexical/src/__tests__/utils'; +import {afterEach, assert, describe, expect, test} from 'vitest'; +import {Doc} from 'yjs'; + +import {syncLexicalSelectionToYjs} from '../../SyncCursors'; +import {$updateYFragment} from '../../SyncV2'; + +// In collab-v2 a run of adjacent TextNodes is serialized as a single XmlText +// child (see normalizeNodeContent in SyncV2), so a paragraph whose lexical +// children are [Text, Text, Decorator] has only two yjs children: +// [XmlText, XmlElement]. An element-type selection point therefore has to be +// converted from a lexical child offset into a yjs child index. +describe('collab-v2 element selection points', () => { + const editors: LexicalEditorWithDispose[] = []; + afterEach(() => { + for (const editor of editors) { + editor.dispose(); + } + editors.length = 0; + }); + + function createAwareness(): { + awareness: ProviderAwareness; + getState: () => UserState | null; + } { + let localState: UserState | null = { + anchorPos: null, + awarenessData: {}, + color: '#000000', + focusPos: null, + focusing: true, + name: 'test', + }; + return { + awareness: { + getLocalState: () => localState, + getStates: () => new Map(), + off: () => {}, + on: () => {}, + setLocalState: (state: UserState | null) => { + localState = state; + }, + setLocalStateField: (field: string, value: unknown) => { + if (localState !== null) { + localState = {...localState, [field]: value}; + } + }, + } as unknown as ProviderAwareness, + getState: () => localState, + }; + } + + function buildBinding() { + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: null, + name: '[v2-element-point]', + nodes: [TestDecoratorNode], + }), + ); + editors.push(editor); + const doc = new Doc(); + const docMap = new Map([['v2-element-point', doc]]); + const binding = createBindingV2__EXPERIMENTAL( + editor, + 'v2-element-point', + doc, + docMap, + ); + return {binding, doc, editor}; + } + + function serialize( + editor: LexicalEditor, + binding: ReturnType, + ) { + editor.read(() => { + binding.doc.transact(() => { + $updateYFragment( + binding.doc, + binding.root, + $getRoot(), + binding, + new Set(['root']), + ); + }); + }); + } + + /** + * Put a collapsed element-type selection at `offset` inside the paragraph, + * push it through the awareness encoder, and decode it back. The encoded + * form is what remote peers receive, so a mismatch here is a remote cursor + * rendered at the wrong place. + */ + function roundTripElementOffset(offset: number): { + key: null | string; + offset: number; + paragraphKey: string; + } { + const {binding, editor} = buildBinding(); + const {awareness, getState} = createAwareness(); + const provider = {awareness} as unknown as Provider; + + let paragraphKey = ''; + editor.update( + () => { + const paragraph = $createParagraphNode(); + paragraph.append( + $createTextNode('a'), + $createTextNode('b').setFormat('bold'), + $createTestDecoratorNode(), + ); + $getRoot().clear().append(paragraph); + paragraphKey = paragraph.getKey(); + }, + {discrete: true}, + ); + + serialize(editor, binding); + + editor.update( + () => { + const selection = $createRangeSelection(); + selection.anchor.set(paragraphKey, offset, 'element'); + selection.focus.set(paragraphKey, offset, 'element'); + $setSelection(selection); + }, + {discrete: true}, + ); + + editor.read(() => { + syncLexicalSelectionToYjs(binding, provider, null, $getSelection()); + }); + + const state = getState(); + assert(state !== null); + + const decoded = editor.read(() => + $getAnchorAndFocusForUserState(binding, state), + ); + return { + key: decoded.anchorKey, + offset: decoded.anchorOffset, + paragraphKey, + }; + } + + test('an element point before a decorator that follows a text run round trips', () => { + // lexical children: [Text 'a', Text 'b', Decorator]; offset 2 is the + // caret just before the decorator. + const result = roundTripElementOffset(2); + expect(result.key).toBe(result.paragraphKey); + expect(result.offset).toBe(2); + }); + + test('an element point at the start round trips', () => { + const result = roundTripElementOffset(0); + expect(result.key).toBe(result.paragraphKey); + expect(result.offset).toBe(0); + }); + + test('an element point at the end round trips', () => { + const result = roundTripElementOffset(3); + expect(result.key).toBe(result.paragraphKey); + expect(result.offset).toBe(3); + }); +}); diff --git a/packages/lexical-yjs/src/index.ts b/packages/lexical-yjs/src/index.ts index 8a2eaa135bd..c686a38cf8b 100644 --- a/packages/lexical-yjs/src/index.ts +++ b/packages/lexical-yjs/src/index.ts @@ -95,6 +95,10 @@ export function createUndoManager( root: XmlText | XmlElement, ): UndoManager { return new YjsUndoManager(root, { + // Bootstrapping the initial editor state is not a user edit, so it must not + // become an undo entry (matching a non-collab editor, where the initial + // state is applied with HISTORY_MERGE_TAG). See #7110. + captureTransaction: () => !binding.isBootstrapping, trackedOrigins: new Set([binding, null]), }); }