From 929d2112ee46cacd1d7c1ac9f86738031d4e54ba Mon Sep 17 00:00:00 2001 From: LeSingh1 Date: Fri, 7 Aug 2026 03:25:49 -0700 Subject: [PATCH] [lexical-yjs][lexical-react] Bug Fix: collab initialEditorState is no longer undoable When CollaborationPlugin bootstraps an empty shared document it writes initialEditorState 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. A non-collab editor applies its initial state with HISTORY_MERGE_TAG and it is never undoable, so collab was inconsistent. Flag the binding while the bootstrap write is in flight and have createUndoManager pass 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. --- .../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/index.ts | 4 + 5 files changed, 152 insertions(+), 2 deletions(-) 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/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]), }); }