fix: yjs collaboration plugin in react strict mode (#6271)

This commit is contained in:
Meron Ogbai
2024-06-12 04:14:12 +03:00
committed by GitHub
parent 73a92f1109
commit 6a45fc371e
8 changed files with 307 additions and 111 deletions

View File

@ -55,7 +55,7 @@
"../lexical-react/src/LexicalCollaborationContext.ts"
],
"@lexical/react/LexicalCollaborationPlugin": [
"../lexical-react/src/LexicalCollaborationPlugin.ts"
"../lexical-react/src/LexicalCollaborationPlugin.tsx"
],
"@lexical/react/LexicalComposer": [
"../lexical-react/src/LexicalComposer.tsx"

View File

@ -10,7 +10,7 @@ import type {Doc} from 'yjs';
import {createContext, useContext} from 'react';
type CollaborationContextType = {
export type CollaborationContextType = {
clientID: number;
color: string;
isCollabActive: boolean;

View File

@ -1,95 +0,0 @@
/**
* 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 {Doc} from 'yjs';
import {useCollaborationContext} from '@lexical/react/LexicalCollaborationContext';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {ExcludedProperties, Provider} from '@lexical/yjs';
import {useEffect, useMemo} from 'react';
import {InitialEditorStateType} from './LexicalComposer';
import {
CursorsContainerRef,
useYjsCollaboration,
useYjsFocusTracking,
useYjsHistory,
} from './shared/useYjsCollaboration';
type Props = {
id: string;
providerFactory: (
// eslint-disable-next-line no-shadow
id: string,
yjsDocMap: Map<string, Doc>,
) => Provider;
shouldBootstrap: boolean;
username?: string;
cursorColor?: string;
cursorsContainerRef?: CursorsContainerRef;
initialEditorState?: InitialEditorStateType;
excludedProperties?: ExcludedProperties;
// `awarenessData` parameter allows arbitrary data to be added to the awareness.
awarenessData?: object;
};
export function CollaborationPlugin({
id,
providerFactory,
shouldBootstrap,
username,
cursorColor,
cursorsContainerRef,
initialEditorState,
excludedProperties,
awarenessData,
}: Props): JSX.Element {
const collabContext = useCollaborationContext(username, cursorColor);
const {yjsDocMap, name, color} = collabContext;
const [editor] = useLexicalComposerContext();
useEffect(() => {
collabContext.isCollabActive = true;
return () => {
// Reseting flag only when unmount top level editor collab plugin. Nested
// editors (e.g. image caption) should unmount without affecting it
if (editor._parentEditor == null) {
collabContext.isCollabActive = false;
}
};
}, [collabContext, editor]);
const provider = useMemo(
() => providerFactory(id, yjsDocMap),
[id, providerFactory, yjsDocMap],
);
const [cursors, binding] = useYjsCollaboration(
editor,
id,
provider,
yjsDocMap,
name,
color,
shouldBootstrap,
cursorsContainerRef,
initialEditorState,
excludedProperties,
awarenessData,
);
collabContext.clientID = binding.clientID;
useYjsHistory(editor, binding);
useYjsFocusTracking(editor, provider, name, color, awarenessData);
return cursors;
}

View File

@ -0,0 +1,201 @@
/**
* 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 {Doc} from 'yjs';
import {
type CollaborationContextType,
useCollaborationContext,
} from '@lexical/react/LexicalCollaborationContext';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {
Binding,
createBinding,
ExcludedProperties,
Provider,
} from '@lexical/yjs';
import {LexicalEditor} from 'lexical';
import {useEffect, useRef, useState} from 'react';
import {InitialEditorStateType} from './LexicalComposer';
import {
CursorsContainerRef,
useYjsCollaboration,
useYjsFocusTracking,
useYjsHistory,
} from './shared/useYjsCollaboration';
type Props = {
id: string;
providerFactory: (
// eslint-disable-next-line no-shadow
id: string,
yjsDocMap: Map<string, Doc>,
) => Provider;
shouldBootstrap: boolean;
username?: string;
cursorColor?: string;
cursorsContainerRef?: CursorsContainerRef;
initialEditorState?: InitialEditorStateType;
excludedProperties?: ExcludedProperties;
// `awarenessData` parameter allows arbitrary data to be added to the awareness.
awarenessData?: object;
};
export function CollaborationPlugin({
id,
providerFactory,
shouldBootstrap,
username,
cursorColor,
cursorsContainerRef,
initialEditorState,
excludedProperties,
awarenessData,
}: Props): JSX.Element {
const isBindingInitialized = useRef(false);
const isProviderInitialized = useRef(false);
const collabContext = useCollaborationContext(username, cursorColor);
const {yjsDocMap, name, color} = collabContext;
const [editor] = useLexicalComposerContext();
useEffect(() => {
collabContext.isCollabActive = true;
return () => {
// Reseting flag only when unmount top level editor collab plugin. Nested
// editors (e.g. image caption) should unmount without affecting it
if (editor._parentEditor == null) {
collabContext.isCollabActive = false;
}
};
}, [collabContext, editor]);
const [provider, setProvider] = useState<Provider>();
useEffect(() => {
if (isProviderInitialized.current) {
return;
}
isProviderInitialized.current = true;
const newProvider = providerFactory(id, yjsDocMap);
setProvider(newProvider);
return () => {
newProvider.disconnect();
};
}, [id, providerFactory, yjsDocMap]);
const [doc, setDoc] = useState(yjsDocMap.get(id));
const [binding, setBinding] = useState<Binding>();
useEffect(() => {
if (!provider) {
return;
}
if (isBindingInitialized.current) {
return;
}
isBindingInitialized.current = true;
const newBinding = createBinding(
editor,
provider,
id,
doc || yjsDocMap.get(id),
yjsDocMap,
excludedProperties,
);
setBinding(newBinding);
return () => {
newBinding.root.destroy(newBinding);
};
}, [editor, provider, id, yjsDocMap, doc, excludedProperties]);
if (!provider || !binding) {
return <></>;
}
return (
<YjsCollaborationCursors
awarenessData={awarenessData}
binding={binding}
collabContext={collabContext}
color={color}
cursorsContainerRef={cursorsContainerRef}
editor={editor}
id={id}
initialEditorState={initialEditorState}
name={name}
provider={provider}
setDoc={setDoc}
shouldBootstrap={shouldBootstrap}
yjsDocMap={yjsDocMap}
/>
);
}
function YjsCollaborationCursors({
editor,
id,
provider,
yjsDocMap,
name,
color,
shouldBootstrap,
cursorsContainerRef,
initialEditorState,
awarenessData,
collabContext,
binding,
setDoc,
}: {
editor: LexicalEditor;
id: string;
provider: Provider;
yjsDocMap: Map<string, Doc>;
name: string;
color: string;
shouldBootstrap: boolean;
binding: Binding;
setDoc: React.Dispatch<React.SetStateAction<Doc | undefined>>;
cursorsContainerRef?: CursorsContainerRef | undefined;
initialEditorState?: InitialEditorStateType | undefined;
awarenessData?: object;
collabContext: CollaborationContextType;
}) {
const cursors = useYjsCollaboration(
editor,
id,
provider,
yjsDocMap,
name,
color,
shouldBootstrap,
binding,
setDoc,
cursorsContainerRef,
initialEditorState,
awarenessData,
);
collabContext.clientID = binding.clientID;
useYjsHistory(editor, binding);
useYjsFocusTracking(editor, provider, name, color, awarenessData);
return cursors;
}

View File

@ -0,0 +1,95 @@
/**
* 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 * as React from 'react';
import {createRoot, Root} from 'react-dom/client';
import * as ReactTestUtils from 'shared/react-test-utils';
import * as Y from 'yjs';
import {CollaborationPlugin} from '../../LexicalCollaborationPlugin';
import {LexicalComposer} from '../../LexicalComposer';
import {ContentEditable} from '../../LexicalContentEditable';
import {LexicalErrorBoundary} from '../../LexicalErrorBoundary';
import {RichTextPlugin} from '../../LexicalRichTextPlugin';
describe(`LexicalCollaborationPlugin`, () => {
let container: HTMLDivElement;
let reactRoot: Root;
const editorConfig = Object.freeze({
// NOTE: This is critical for collaboration plugin to set editor state to null. It
// would indicate that the editor should not try to set any default state
// (not even empty one), and let collaboration plugin do it instead
editorState: null,
namespace: 'Test editor',
nodes: [],
// Handling of errors during update
onError(error: Error) {
throw error;
},
});
beforeEach(() => {
container = document.createElement('div');
reactRoot = createRoot(container);
document.body.appendChild(container);
});
test(`providerFactory called only once`, () => {
const providerFactory = jest.fn(
(id: string, yjsDocMap: Map<string, Y.Doc>) => {
const doc = new Y.Doc();
yjsDocMap.set(id, doc);
return {
awareness: {
getLocalState: () => null,
getStates: () => new Map(),
off: () => {},
on: () => {},
setLocalState: () => {},
},
connect: () => {},
disconnect: () => {},
off: () => {},
on: () => {},
};
},
);
function MemoComponent() {
return (
<LexicalComposer initialConfig={editorConfig}>
{/* With CollaborationPlugin - we MUST NOT use @lexical/react/LexicalHistoryPlugin */}
<CollaborationPlugin
id="lexical/react-rich-collab"
providerFactory={providerFactory}
// Unless you have a way to avoid race condition between 2+ users trying to do bootstrap simultaneously
// you should never try to bootstrap on client. It's better to perform bootstrap within Yjs server.
shouldBootstrap={false}
/>
<RichTextPlugin
contentEditable={<ContentEditable className="editor-input" />}
placeholder={
<div className="editor-placeholder">Enter some rich text...</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
</LexicalComposer>
);
}
ReactTestUtils.act(() => {
reactRoot.render(
<React.StrictMode>
<MemoComponent />
</React.StrictMode>,
);
});
expect(providerFactory).toHaveBeenCalledTimes(1);
});
});

View File

@ -6,13 +6,12 @@
*
*/
import type {Binding, ExcludedProperties, Provider} from '@lexical/yjs';
import type {Binding, Provider} from '@lexical/yjs';
import type {LexicalEditor} from 'lexical';
import {mergeRegister} from '@lexical/utils';
import {
CONNECTED_COMMAND,
createBinding,
createUndoManager,
initLocalState,
setLocalStateFocus,
@ -34,7 +33,7 @@ import {
UNDO_COMMAND,
} from 'lexical';
import * as React from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useCallback, useEffect, useMemo, useRef} from 'react';
import {createPortal} from 'react-dom';
import {Doc, Transaction, UndoManager, YEvent} from 'yjs';
@ -50,18 +49,13 @@ export function useYjsCollaboration(
name: string,
color: string,
shouldBootstrap: boolean,
binding: Binding,
setDoc: React.Dispatch<React.SetStateAction<Doc | undefined>>,
cursorsContainerRef?: CursorsContainerRef,
initialEditorState?: InitialEditorStateType,
excludedProperties?: ExcludedProperties,
awarenessData?: object,
): [JSX.Element, Binding] {
): JSX.Element {
const isReloadingDoc = useRef(false);
const [doc, setDoc] = useState(docMap.get(id));
const binding = useMemo(
() => createBinding(editor, provider, id, doc, docMap, excludedProperties),
[editor, provider, id, docMap, doc, excludedProperties],
);
const connect = useCallback(() => {
provider.connect();
@ -186,6 +180,7 @@ export function useYjsCollaboration(
provider,
shouldBootstrap,
awarenessData,
setDoc,
]);
const cursorsContainer = useMemo(() => {
const ref = (element: null | HTMLElement) => {
@ -222,7 +217,7 @@ export function useYjsCollaboration(
);
}, [connect, disconnect, editor]);
return [cursorsContainer, binding];
return cursorsContainer;
}
export function useYjsFocusTracking(

View File

@ -56,7 +56,7 @@
"./packages/lexical-react/src/LexicalCollaborationContext.ts"
],
"@lexical/react/LexicalCollaborationPlugin": [
"./packages/lexical-react/src/LexicalCollaborationPlugin.ts"
"./packages/lexical-react/src/LexicalCollaborationPlugin.tsx"
],
"@lexical/react/LexicalComposer": [
"./packages/lexical-react/src/LexicalComposer.tsx"

View File

@ -64,7 +64,7 @@
"./packages/lexical-react/src/LexicalCollaborationContext.ts"
],
"@lexical/react/LexicalCollaborationPlugin": [
"./packages/lexical-react/src/LexicalCollaborationPlugin.ts"
"./packages/lexical-react/src/LexicalCollaborationPlugin.tsx"
],
"@lexical/react/LexicalComposer": [
"./packages/lexical-react/src/LexicalComposer.tsx"