Skip to content
Open
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
32 changes: 26 additions & 6 deletions packages/lexical-yjs/src/SyncCursors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion packages/lexical-yjs/src/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
Loading
Loading