Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Set<(arg: never) => 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;
Expand Down Expand Up @@ -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 (
<LexicalCollaboration>
<LexicalComposer initialConfig={editorConfig}>
<CaptureEditor />
<CollaborationPlugin
id="main"
providerFactory={(id, yjsDocMap) => {
yjsDocMap.set(id, doc);
return provider;
}}
shouldBootstrap={true}
initialEditorState={() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
paragraph.append($createTextNode('Initial content'));
root.append(paragraph);
}}
/>
<RichTextPlugin
contentEditable={<ContentEditable />}
placeholder={<></>}
ErrorBoundary={LexicalErrorBoundary}
/>
</LexicalComposer>
</LexicalCollaboration>
);
}

await act(async () => {
reactRoot.render(<App />);
});

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');
});
});
32 changes: 30 additions & 2 deletions packages/lexical-react/src/shared/useYjsCollaboration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/lexical-yjs/flow/LexicalYjs.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export type BaseBinding = {
editor: LexicalEditor,
excludedProperties: ExcludedProperties,
id: string,
isBootstrapping: boolean,
nodeProperties: Map<string, {[property: string]: unknown}>,
};

Expand Down
8 changes: 8 additions & 0 deletions packages/lexical-yjs/src/Bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export interface BaseBinding {
id: string;
nodeProperties: Map<string, {[property: string]: unknown}>; // 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 {
Expand Down Expand Up @@ -78,6 +85,7 @@ function createBaseBinding(
editor,
excludedProperties: excludedProperties || new Map(),
id,
isBootstrapping: false,
nodeProperties: new Map(),
};
initializeNodeProperties(binding);
Expand Down
4 changes: 4 additions & 0 deletions packages/lexical-yjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
});
}
Expand Down
Loading