mirror of
https://github.com/facebook/lexical.git
synced 2025-05-17 15:18:47 +08:00
fix: yjs collaboration plugin in react strict mode (#6271)
This commit is contained in:
@ -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"
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
201
packages/lexical-react/src/LexicalCollaborationPlugin.tsx
Normal file
201
packages/lexical-react/src/LexicalCollaborationPlugin.tsx
Normal 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;
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
@ -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(
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
Reference in New Issue
Block a user