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]), }); }