diff --git a/packages/lexical-react/src/LexicalExtensionEditorComposer.tsx b/packages/lexical-react/src/LexicalExtensionEditorComposer.tsx index e5fb088d669..de03c04290c 100644 --- a/packages/lexical-react/src/LexicalExtensionEditorComposer.tsx +++ b/packages/lexical-react/src/LexicalExtensionEditorComposer.tsx @@ -8,11 +8,16 @@ import {getExtensionDependencyFromEditor} from '@lexical/extension'; import {ReactExtension} from '@lexical/react/ReactExtension'; import {LexicalEditorWithDispose} from 'lexical'; -import {useEffect} from 'react'; export interface LexicalExtensionEditorComposerProps { /** - * Your root extension, typically defined with {@link defineExtension} + * Your root extension, typically defined with {@link defineExtension}. + * The lifecycle of this editor is not owned by this component, + * you are responsible for calling `initialEditor.dispose()` if needed. + * Note also that any LexicalEditor can only be rendered to one root + * element, so if you try and use it from multiple components + * simultaneously then it will only be managed correctly by the last one + * to render. */ initialEditor: LexicalEditorWithDispose; /** @@ -33,18 +38,6 @@ export function LexicalExtensionEditorComposer({ initialEditor: editor, children, }: LexicalExtensionEditorComposerProps) { - useEffect(() => { - // Strict mode workaround - let didMount = false; - queueMicrotask(() => { - didMount = true; - }); - return () => { - if (didMount) { - editor.dispose(); - } - }; - }, [editor]); const {Component} = getExtensionDependencyFromEditor( editor, ReactExtension, diff --git a/packages/lexical-react/src/__tests__/unit/LexicalExtensionEditorComposer.test.tsx b/packages/lexical-react/src/__tests__/unit/LexicalExtensionEditorComposer.test.tsx index 753b62e5623..1f26115b92c 100644 --- a/packages/lexical-react/src/__tests__/unit/LexicalExtensionEditorComposer.test.tsx +++ b/packages/lexical-react/src/__tests__/unit/LexicalExtensionEditorComposer.test.tsx @@ -14,6 +14,7 @@ import {LexicalExtensionComposer} from '@lexical/react/LexicalExtensionComposer' import {LexicalExtensionEditorComposer} from '@lexical/react/LexicalExtensionEditorComposer'; import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; import {ReactExtension} from '@lexical/react/ReactExtension'; +import {ReactPluginHostExtension} from '@lexical/react/ReactPluginHostExtension'; import {ReactProviderExtension} from '@lexical/react/ReactProviderExtension'; import {toHaveNoViolations} from 'jest-axe'; import { @@ -25,10 +26,13 @@ import { $getState, $getStateChange, $setState, + COMMAND_PRIORITY_EDITOR, + createCommand, createState, DecoratorNode, defineExtension, EditorConfig, + LexicalCommand, LexicalEditor, LexicalEditorWithDispose, StateConfigValue, @@ -192,4 +196,93 @@ describe('LexicalExtensionEditorComposer', () => { await Promise.resolve(); }); }); + + test('does not dispose the editor on unmount', async () => { + using editor = buildEditorFromExtensions({ + dependencies: [ReactPluginHostExtension, RichTextPlugin], + name: '[root]', + }); + const TestCommand: LexicalCommand = createCommand('TestCommand'); + const handled: number[] = []; + editor.registerCommand( + TestCommand, + (payload) => { + handled.push(payload); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ); + + await ReactTestUtils.act(async () => { + reactRoot.render( + , + ); + }); + expect(editor.getRootElement()).not.toBe(null); + expect(editor.dispatchCommand(TestCommand, 1)).toBe(true); + expect(handled).toEqual([1]); + + await ReactTestUtils.act(async () => { + reactRoot.render(null); + await Promise.resolve(); + }); + + // The command registration must survive the composer unmount — + // LexicalExtensionEditorComposer no longer calls editor.dispose(). + expect(editor.dispatchCommand(TestCommand, 2)).toBe(true); + expect(handled).toEqual([1, 2]); + }); + + test('can remount with the same editor after unmount', async () => { + using editor = buildEditorFromExtensions({ + dependencies: [ReactPluginHostExtension, RichTextPlugin], + name: '[root]', + }); + editor.update( + () => + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode('nested'))), + {discrete: true}, + ); + + // First mount + await ReactTestUtils.act(async () => { + reactRoot.render( + , + ); + }); + expect(container?.textContent).toBe('nested'); + const firstRoot = editor.getRootElement(); + expect(firstRoot).not.toBe(null); + + // Unmount + await ReactTestUtils.act(async () => { + reactRoot.render(null); + await Promise.resolve(); + }); + + // Remount with the same editor instance — this mirrors the image-caption + // open/close/open cycle that previously broke because the editor was + // disposed on first unmount. + await ReactTestUtils.act(async () => { + reactRoot.render( + , + ); + }); + expect(container?.textContent).toBe('nested'); + expect(editor.getRootElement()).not.toBe(null); + + // Editor still functions for updates after the remount. + await ReactTestUtils.act(async () => { + editor.update( + () => + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode('updated'))), + {discrete: true}, + ); + }); + expect(container?.textContent).toBe('updated'); + }); }); diff --git a/packages/lexical-react/src/__tests__/unit/useExtensionSignalValue.test.tsx b/packages/lexical-react/src/__tests__/unit/useExtensionSignalValue.test.tsx index d703dc6fbe7..78cd98c4606 100644 --- a/packages/lexical-react/src/__tests__/unit/useExtensionSignalValue.test.tsx +++ b/packages/lexical-react/src/__tests__/unit/useExtensionSignalValue.test.tsx @@ -192,7 +192,7 @@ describe('useExtensionSignalValue', () => { name: 'test', }); - const editor = buildEditorFromExtensions({ + using editor = buildEditorFromExtensions({ dependencies: [ReactPluginHostExtension, TestExtension], name: '[root]', }); @@ -284,7 +284,7 @@ describe('useExtensionSignalValue', () => { name: 'test', }); - const editor = buildEditorFromExtensions({ + using editor = buildEditorFromExtensions({ dependencies: [ReactPluginHostExtension, TestExtension], name: '[root]', }); @@ -337,7 +337,7 @@ describe('useExtensionSignalValue', () => { name: 'test', }); - const editor = buildEditorFromExtensions({ + using editor = buildEditorFromExtensions({ dependencies: [ReactPluginHostExtension, TestExtension], name: '[root]', }); @@ -380,7 +380,7 @@ describe('useExtensionSignalValue', () => { name: 'test', }); - const editor = buildEditorFromExtensions({ + using editor = buildEditorFromExtensions({ dependencies: [ReactPluginHostExtension, TestExtension], name: '[root]', });