diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 3cdca9a713d..ef196542cca 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -11,10 +11,12 @@ import type {KeyDownShortcut} from './LexicalEvents'; import type {CompiledKeyboardShortcuts} from './LexicalKeyboardShortcuts'; import type {ElementNode} from './nodes/LexicalElementNode'; +import devInvariant from '@lexical/internal/devInvariant'; import invariant from '@lexical/internal/invariant'; import {LEXICAL_VERSION} from '@lexical/internal/version'; import { + $createParagraphNode, $getRoot, $getSelection, $isElementNode, @@ -1742,12 +1744,7 @@ export class LexicalEditor { * @param options - options for the update. */ setEditorState(editorState: EditorState, options?: EditorSetOptions): void { - if (editorState.isEmpty()) { - invariant( - false, - "setEditorState: the editor state is empty. Ensure the editor state's root node never becomes empty.", - ); - } + const isEmptyEditorState = editorState.isEmpty(); // Ensure that we have a writable EditorState so that transforms can run // during a historic operation @@ -1790,6 +1787,20 @@ export class LexicalEditor { if (tag) { this._updateTags.add(tag); } + if (isEmptyEditorState) { + // A root with no children is not the canonical empty document: it + // reconciles to a contenteditable with no block element to place a + // caret in. It still arrives from outside the editor, because + // content persisted while the editor was empty round-trips to + // `{"root":{"children":[]}}`, so recover rather than leave the + // editor unusable. Reusing the wording of the invariant this + // replaces keeps the existing error code. + devInvariant( + false, + "setEditorState: the editor state is empty. Ensure the editor state's root node never becomes empty.", + ); + $getRoot().append($createParagraphNode()); + } if (editorState._parsed) { for (const [key, node] of writableEditorState._nodeMap.entries()) { // Mark all nodes as dirty with a freshly parsed EditorState diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index 893942400a8..1cfee970ef5 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx @@ -3013,6 +3013,47 @@ describe('LexicalEditor tests', () => { ]); }); + // A root with no children is not the canonical empty document, but it is a + // shape that reaches `setEditorState` from outside the editor: content + // persisted while the editor was empty serializes to + // `{"root":{"children":[]}}` and comes back through `parseEditorState` on + // rehydrate. + const emptySerializedEditorState = JSON.stringify({ + root: { + children: [], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }); + + it('setEditorState reports an empty editor state outside production', () => { + const onError = vi.fn(); + init(onError); + editor.update( + () => { + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode('before')); + $getRoot().append(paragraph); + }, + {discrete: true}, + ); + + editor.setEditorState(editor.parseEditorState(emptySerializedEditorState)); + + // `devInvariant` throws outside production, so the empty state is reported + // through the editor's error handling and the update is rolled back — + // leaving the content that was already there. In production it only warns, + // and the recovery covered by LexicalEditorEmptyState.test.ts applies. + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0].message).toContain( + 'the editor state is empty', + ); + expect(editor.read(() => $getRoot().getTextContent())).toBe('before'); + }); + it('mutation listeners does not trigger when other node types are mutated', async () => { init(); diff --git a/packages/lexical/src/__tests__/unit/LexicalEditorEmptyState.test.ts b/packages/lexical/src/__tests__/unit/LexicalEditorEmptyState.test.ts new file mode 100644 index 00000000000..12a4172fd6d --- /dev/null +++ b/packages/lexical/src/__tests__/unit/LexicalEditorEmptyState.test.ts @@ -0,0 +1,114 @@ +/** + * 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 {$getRoot, $isParagraphNode} from 'lexical'; +import {createTestEditor} from 'lexical/src/__tests__/utils'; +import {describe, expect, it, vi} from 'vitest'; + +// `setEditorState` reports an empty editor state with `devInvariant`, which +// throws outside production — rolling the recovery update back before it can +// be observed. Stubbing it out is how this file exercises the production arm +// (`formatProdWarningMessage`, which only warns) from a development test run. +vi.mock('@lexical/internal/devInvariant', () => ({default: vi.fn()})); + +// Content persisted while the editor was empty: the shape `setEditorState` +// used to throw on. +const EMPTY_SERIALIZED_EDITOR_STATE = JSON.stringify({ + root: { + children: [], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1, + }, +}); + +describe('setEditorState with an empty editor state (production arm)', () => { + it('recovers to an empty paragraph rather than leaving the root childless', () => { + const onError = vi.fn(); + const editor = createTestEditor({onError}); + const rootElement = document.createElement('div'); + document.body.appendChild(rootElement); + editor.setRootElement(rootElement); + + editor.setEditorState( + editor.parseEditorState(EMPTY_SERIALIZED_EDITOR_STATE), + ); + + expect(onError).not.toHaveBeenCalled(); + editor.read(() => { + const root = $getRoot(); + + expect(root.getChildrenSize()).toBe(1); + expect($isParagraphNode(root.getFirstChild())).toBe(true); + expect(root.getTextContent()).toBe(''); + }); + // A childless root reconciles to a contenteditable with no block element + // to place a caret in; the recovered document renders one. + expect(rootElement.innerHTML).toContain(' { + $getRoot().selectEnd().insertText('typed'); + }, + {discrete: true}, + ); + + expect(editor.read(() => $getRoot().getTextContent())).toBe('typed'); + }); + + it('leaves a non-empty editor state untouched', () => { + const onError = vi.fn(); + const editor = createTestEditor({onError}); + const rootElement = document.createElement('div'); + document.body.appendChild(rootElement); + editor.setRootElement(rootElement); + + editor.setEditorState( + editor.parseEditorState( + JSON.stringify({ + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'hello', + type: 'text', + version: 1, + }, + ], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1, + }, + ], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }), + ), + ); + + expect(onError).not.toHaveBeenCalled(); + editor.read(() => { + // No stray paragraph appended alongside the parsed content. + expect($getRoot().getChildrenSize()).toBe(1); + expect($getRoot().getTextContent()).toBe('hello'); + }); + }); +});