From 48ba6f4dcac4bd2b7315935bac4cf9e915bfd6ba Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 6 May 2024 13:41:24 -0700 Subject: [PATCH 1/3] [lexical-react] Bug Fix: Use useState(...)[0] instead of useMemo when creating LexicalEditor for React 19 StrictMode correctness --- .../lexical-react/src/LexicalComposer.tsx | 80 ++++++------- .../src/LexicalNestedComposer.tsx | 110 +++++++++--------- .../src/shared/usePlainTextSetup.ts | 3 - .../src/shared/useRichTextSetup.ts | 3 - packages/lexical/src/LexicalEditor.ts | 2 +- 5 files changed, 91 insertions(+), 107 deletions(-) diff --git a/packages/lexical-react/src/LexicalComposer.tsx b/packages/lexical-react/src/LexicalComposer.tsx index c40568b7d6d..ece0c44b926 100644 --- a/packages/lexical-react/src/LexicalComposer.tsx +++ b/packages/lexical-react/src/LexicalComposer.tsx @@ -6,7 +6,10 @@ * */ -import type {LexicalComposerContextType} from '@lexical/react/LexicalComposerContext'; +import type { + LexicalComposerContextType, + LexicalComposerContextWithEditor, +} from '@lexical/react/LexicalComposerContext'; import { createLexicalComposerContext, @@ -25,7 +28,7 @@ import { LexicalNode, LexicalNodeReplacement, } from 'lexical'; -import {useMemo} from 'react'; +import {useRef, useState} from 'react'; import * as React from 'react'; import {CAN_USE_DOM} from 'shared/canUseDOM'; import useLayoutEffect from 'shared/useLayoutEffect'; @@ -54,55 +57,48 @@ type Props = React.PropsWithChildren<{ }>; export function LexicalComposer({initialConfig, children}: Props): JSX.Element { - const composerContext: [LexicalEditor, LexicalComposerContextType] = useMemo( - () => { - const { - theme, + const initialConfigRef = useRef(initialConfig); + const composerContext = useState(() => { + const { + theme, + namespace, + editable, + editor__DEPRECATED: initialEditor, + nodes, + onError, + editorState: initialEditorState, + html, + } = initialConfigRef.current; + + const context: LexicalComposerContextType = createLexicalComposerContext( + null, + theme, + ); + + let editor = initialEditor || null; + + if (editor === null) { + const newEditor = createEditor({ + editable, + html, namespace, - editor__DEPRECATED: initialEditor, nodes, - onError, - editorState: initialEditorState, - html, - } = initialConfig; - - const context: LexicalComposerContextType = createLexicalComposerContext( - null, + onError: (error) => onError(error, newEditor), theme, - ); - - let editor = initialEditor || null; + }); + initializeEditor(newEditor, initialEditorState); - if (editor === null) { - const newEditor = createEditor({ - editable: initialConfig.editable, - html, - namespace, - nodes, - onError: (error) => onError(error, newEditor), - theme, - }); - initializeEditor(newEditor, initialEditorState); - - editor = newEditor; - } - - return [editor, context]; - }, + editor = newEditor; + } - // We only do this for init - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); + return [editor, context]; + })[0]; useLayoutEffect(() => { - const isEditable = initialConfig.editable; + const isEditable = initialConfigRef.current.editable; const [editor] = composerContext; editor.setEditable(isEditable !== undefined ? isEditable : true); - - // We only do this for init - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [composerContext]); return ( diff --git a/packages/lexical-react/src/LexicalNestedComposer.tsx b/packages/lexical-react/src/LexicalNestedComposer.tsx index 8aa9326e429..6cf1675a93c 100644 --- a/packages/lexical-react/src/LexicalNestedComposer.tsx +++ b/packages/lexical-react/src/LexicalNestedComposer.tsx @@ -6,7 +6,10 @@ * */ -import type {LexicalComposerContextType} from '@lexical/react/LexicalComposerContext'; +import type { + LexicalComposerContextType, + LexicalComposerContextWithEditor, +} from '@lexical/react/LexicalComposerContext'; import type {KlassConstructor, Transform} from 'lexical'; import {useCollaborationContext} from '@lexical/react/LexicalCollaborationContext'; @@ -21,8 +24,7 @@ import { LexicalNode, LexicalNodeReplacement, } from 'lexical'; -import * as React from 'react'; -import {ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; +import {ReactNode, useContext, useEffect, useRef, useState} from 'react'; import invariant from 'shared/invariant'; function getTransformSetFromKlass( @@ -56,69 +58,61 @@ export function LexicalNestedComposer({ const [parentEditor, {getTheme: getParentTheme}] = parentContext; - const composerContext: [LexicalEditor, LexicalComposerContextType] = useMemo( - () => { - const composerTheme: EditorThemeClasses | undefined = - initialTheme || getParentTheme() || undefined; + const composerContext = useState(() => { + const composerTheme: EditorThemeClasses | undefined = + initialTheme || getParentTheme() || undefined; - const context: LexicalComposerContextType = createLexicalComposerContext( - parentContext, - composerTheme, - ); + const context: LexicalComposerContextType = createLexicalComposerContext( + parentContext, + composerTheme, + ); - if (composerTheme !== undefined) { - initialEditor._config.theme = composerTheme; - } + if (composerTheme !== undefined) { + initialEditor._config.theme = composerTheme; + } - initialEditor._parentEditor = parentEditor; - - if (!initialNodes) { - const parentNodes = (initialEditor._nodes = new Map( - parentEditor._nodes, - )); - for (const [type, entry] of parentNodes) { - initialEditor._nodes.set(type, { - exportDOM: entry.exportDOM, - klass: entry.klass, - replace: entry.replace, - replaceWithKlass: entry.replaceWithKlass, - transforms: getTransformSetFromKlass(entry.klass), - }); - } - } else { - for (let klass of initialNodes) { - let replace = null; - let replaceWithKlass = null; - - if (typeof klass !== 'function') { - const options = klass; - klass = options.replace; - replace = options.with; - replaceWithKlass = options.withKlass || null; - } - const registeredKlass = initialEditor._nodes.get(klass.getType()); - - initialEditor._nodes.set(klass.getType(), { - exportDOM: registeredKlass ? registeredKlass.exportDOM : undefined, - klass, - replace, - replaceWithKlass, - transforms: getTransformSetFromKlass(klass), - }); + initialEditor._parentEditor = parentEditor; + + if (!initialNodes) { + const parentNodes = (initialEditor._nodes = new Map(parentEditor._nodes)); + for (const [type, entry] of parentNodes) { + initialEditor._nodes.set(type, { + exportDOM: entry.exportDOM, + klass: entry.klass, + replace: entry.replace, + replaceWithKlass: entry.replaceWithKlass, + transforms: getTransformSetFromKlass(entry.klass), + }); + } + } else { + for (let klass of initialNodes) { + let replace = null; + let replaceWithKlass = null; + + if (typeof klass !== 'function') { + const options = klass; + klass = options.replace; + replace = options.with; + replaceWithKlass = options.withKlass || null; } + const registeredKlass = initialEditor._nodes.get(klass.getType()); + + initialEditor._nodes.set(klass.getType(), { + exportDOM: registeredKlass ? registeredKlass.exportDOM : undefined, + klass, + replace, + replaceWithKlass, + transforms: getTransformSetFromKlass(klass), + }); } + } - initialEditor._config.namespace = parentEditor._config.namespace; - - initialEditor._editable = parentEditor._editable; + initialEditor._config.namespace = parentEditor._config.namespace; - return [initialEditor, context]; - }, + initialEditor._editable = parentEditor._editable; - // We only do this for init - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); + return [initialEditor, context]; + })[0]; // If collaboration is enabled, make sure we don't render the children until the collaboration subdocument is ready. const {isCollabActive, yjsDocMap} = useCollaborationContext(); diff --git a/packages/lexical-react/src/shared/usePlainTextSetup.ts b/packages/lexical-react/src/shared/usePlainTextSetup.ts index d02b61084b1..9af3eef6ed9 100644 --- a/packages/lexical-react/src/shared/usePlainTextSetup.ts +++ b/packages/lexical-react/src/shared/usePlainTextSetup.ts @@ -19,8 +19,5 @@ export function usePlainTextSetup(editor: LexicalEditor): void { registerPlainText(editor), registerDragonSupport(editor), ); - - // We only do this for init - // eslint-disable-next-line react-hooks/exhaustive-deps }, [editor]); } diff --git a/packages/lexical-react/src/shared/useRichTextSetup.ts b/packages/lexical-react/src/shared/useRichTextSetup.ts index 45972eeb217..99d8e70eb5c 100644 --- a/packages/lexical-react/src/shared/useRichTextSetup.ts +++ b/packages/lexical-react/src/shared/useRichTextSetup.ts @@ -19,8 +19,5 @@ export function useRichTextSetup(editor: LexicalEditor): void { registerRichText(editor), registerDragonSupport(editor), ); - - // We only do this for init - // eslint-disable-next-line react-hooks/exhaustive-deps }, [editor]); } diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 468cfbd413a..98821fcf479 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -447,7 +447,7 @@ export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor { // Ensure custom nodes implement required methods. if (__DEV__) { const name = klass.name; - if (name !== 'RootNode') { + if (name !== 'RootNode' && name !== 'ArtificialNode__DO_NOT_USE') { const proto = klass.prototype; ['getType', 'clone'].forEach((method) => { // eslint-disable-next-line no-prototype-builtins From e6aeac156fc3ae994678ed73354e531b4b460b98 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 6 May 2024 15:27:05 -0700 Subject: [PATCH 2/3] unit test --- .../__tests__/unit/LexicalComposer.test.tsx | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/lexical-react/src/__tests__/unit/LexicalComposer.test.tsx b/packages/lexical-react/src/__tests__/unit/LexicalComposer.test.tsx index 2a79c9bc636..ab743b3ad52 100644 --- a/packages/lexical-react/src/__tests__/unit/LexicalComposer.test.tsx +++ b/packages/lexical-react/src/__tests__/unit/LexicalComposer.test.tsx @@ -7,13 +7,14 @@ */ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {LexicalEditor} from 'lexical'; import * as React from 'react'; import {createRoot, Root} from 'react-dom/client'; import * as ReactTestUtils from 'react-dom/test-utils'; import {LexicalComposer} from '../../LexicalComposer'; -describe('LexicalNodeHelpers tests', () => { +describe('LexicalComposer tests', () => { let container: HTMLDivElement | null = null; let reactRoot: Root; @@ -59,4 +60,42 @@ describe('LexicalNodeHelpers tests', () => { reactRoot.render(); }); }); + + describe('LexicalComposerContext editor identity', () => { + ( + [ + {name: 'StrictMode', size: 2}, + {name: 'Fragment', size: 1}, + ] as const + ).forEach(({name, size}) => { + const Wrapper = React[name]; + const editors = new Set(); + function App() { + return ( + { + throw Error(); + }, + }} + /> + ); + } + it(`renders ${size} editors under ${name}`, async () => { + await ReactTestUtils.act(async () => { + reactRoot.render( + + + , + ); + }); + expect(editors.size).toBe(size); + }); + }); + }); }); From 6db72b4aaa680a5fb22ad4786c355ec909e0c7a3 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 6 May 2024 15:38:57 -0700 Subject: [PATCH 3/3] test contents of editor too --- .../__tests__/unit/LexicalComposer.test.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/lexical-react/src/__tests__/unit/LexicalComposer.test.tsx b/packages/lexical-react/src/__tests__/unit/LexicalComposer.test.tsx index ab743b3ad52..145192b1287 100644 --- a/packages/lexical-react/src/__tests__/unit/LexicalComposer.test.tsx +++ b/packages/lexical-react/src/__tests__/unit/LexicalComposer.test.tsx @@ -7,7 +7,12 @@ */ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {LexicalEditor} from 'lexical'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + LexicalEditor, +} from 'lexical'; import * as React from 'react'; import {createRoot, Root} from 'react-dom/client'; import * as ReactTestUtils from 'react-dom/test-utils'; @@ -76,6 +81,11 @@ describe('LexicalComposer tests', () => { initialConfig={{ editorState(editor) { editors.add(editor); + editor.update(() => { + const p = $createParagraphNode(); + p.append($createTextNode('initial state')); + $getRoot().append(p); + }); }, namespace: '', nodes: [], @@ -95,6 +105,12 @@ describe('LexicalComposer tests', () => { ); }); expect(editors.size).toBe(size); + [...editors].forEach((editor, i) => { + expect([ + i, + editor.getEditorState().read(() => $getRoot().getTextContent()), + ]).toEqual([i, 'initial state']); + }); }); }); });