mirror of
https://github.com/facebook/lexical.git
synced 2025-08-06 00:21: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/src/LexicalCollaborationContext.ts"
|
||||||
],
|
],
|
||||||
"@lexical/react/LexicalCollaborationPlugin": [
|
"@lexical/react/LexicalCollaborationPlugin": [
|
||||||
"../lexical-react/src/LexicalCollaborationPlugin.ts"
|
"../lexical-react/src/LexicalCollaborationPlugin.tsx"
|
||||||
],
|
],
|
||||||
"@lexical/react/LexicalComposer": [
|
"@lexical/react/LexicalComposer": [
|
||||||
"../lexical-react/src/LexicalComposer.tsx"
|
"../lexical-react/src/LexicalComposer.tsx"
|
||||||
|
@ -10,7 +10,7 @@ import type {Doc} from 'yjs';
|
|||||||
|
|
||||||
import {createContext, useContext} from 'react';
|
import {createContext, useContext} from 'react';
|
||||||
|
|
||||||
type CollaborationContextType = {
|
export type CollaborationContextType = {
|
||||||
clientID: number;
|
clientID: number;
|
||||||
color: string;
|
color: string;
|
||||||
isCollabActive: boolean;
|
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 type {LexicalEditor} from 'lexical';
|
||||||
|
|
||||||
import {mergeRegister} from '@lexical/utils';
|
import {mergeRegister} from '@lexical/utils';
|
||||||
import {
|
import {
|
||||||
CONNECTED_COMMAND,
|
CONNECTED_COMMAND,
|
||||||
createBinding,
|
|
||||||
createUndoManager,
|
createUndoManager,
|
||||||
initLocalState,
|
initLocalState,
|
||||||
setLocalStateFocus,
|
setLocalStateFocus,
|
||||||
@ -34,7 +33,7 @@ import {
|
|||||||
UNDO_COMMAND,
|
UNDO_COMMAND,
|
||||||
} from 'lexical';
|
} from 'lexical';
|
||||||
import * as React from 'react';
|
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 {createPortal} from 'react-dom';
|
||||||
import {Doc, Transaction, UndoManager, YEvent} from 'yjs';
|
import {Doc, Transaction, UndoManager, YEvent} from 'yjs';
|
||||||
|
|
||||||
@ -50,18 +49,13 @@ export function useYjsCollaboration(
|
|||||||
name: string,
|
name: string,
|
||||||
color: string,
|
color: string,
|
||||||
shouldBootstrap: boolean,
|
shouldBootstrap: boolean,
|
||||||
|
binding: Binding,
|
||||||
|
setDoc: React.Dispatch<React.SetStateAction<Doc | undefined>>,
|
||||||
cursorsContainerRef?: CursorsContainerRef,
|
cursorsContainerRef?: CursorsContainerRef,
|
||||||
initialEditorState?: InitialEditorStateType,
|
initialEditorState?: InitialEditorStateType,
|
||||||
excludedProperties?: ExcludedProperties,
|
|
||||||
awarenessData?: object,
|
awarenessData?: object,
|
||||||
): [JSX.Element, Binding] {
|
): JSX.Element {
|
||||||
const isReloadingDoc = useRef(false);
|
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(() => {
|
const connect = useCallback(() => {
|
||||||
provider.connect();
|
provider.connect();
|
||||||
@ -186,6 +180,7 @@ export function useYjsCollaboration(
|
|||||||
provider,
|
provider,
|
||||||
shouldBootstrap,
|
shouldBootstrap,
|
||||||
awarenessData,
|
awarenessData,
|
||||||
|
setDoc,
|
||||||
]);
|
]);
|
||||||
const cursorsContainer = useMemo(() => {
|
const cursorsContainer = useMemo(() => {
|
||||||
const ref = (element: null | HTMLElement) => {
|
const ref = (element: null | HTMLElement) => {
|
||||||
@ -222,7 +217,7 @@ export function useYjsCollaboration(
|
|||||||
);
|
);
|
||||||
}, [connect, disconnect, editor]);
|
}, [connect, disconnect, editor]);
|
||||||
|
|
||||||
return [cursorsContainer, binding];
|
return cursorsContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useYjsFocusTracking(
|
export function useYjsFocusTracking(
|
||||||
|
@ -56,7 +56,7 @@
|
|||||||
"./packages/lexical-react/src/LexicalCollaborationContext.ts"
|
"./packages/lexical-react/src/LexicalCollaborationContext.ts"
|
||||||
],
|
],
|
||||||
"@lexical/react/LexicalCollaborationPlugin": [
|
"@lexical/react/LexicalCollaborationPlugin": [
|
||||||
"./packages/lexical-react/src/LexicalCollaborationPlugin.ts"
|
"./packages/lexical-react/src/LexicalCollaborationPlugin.tsx"
|
||||||
],
|
],
|
||||||
"@lexical/react/LexicalComposer": [
|
"@lexical/react/LexicalComposer": [
|
||||||
"./packages/lexical-react/src/LexicalComposer.tsx"
|
"./packages/lexical-react/src/LexicalComposer.tsx"
|
||||||
|
@ -64,7 +64,7 @@
|
|||||||
"./packages/lexical-react/src/LexicalCollaborationContext.ts"
|
"./packages/lexical-react/src/LexicalCollaborationContext.ts"
|
||||||
],
|
],
|
||||||
"@lexical/react/LexicalCollaborationPlugin": [
|
"@lexical/react/LexicalCollaborationPlugin": [
|
||||||
"./packages/lexical-react/src/LexicalCollaborationPlugin.ts"
|
"./packages/lexical-react/src/LexicalCollaborationPlugin.tsx"
|
||||||
],
|
],
|
||||||
"@lexical/react/LexicalComposer": [
|
"@lexical/react/LexicalComposer": [
|
||||||
"./packages/lexical-react/src/LexicalComposer.tsx"
|
"./packages/lexical-react/src/LexicalComposer.tsx"
|
||||||
|
Reference in New Issue
Block a user