Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions packages/lexical/src/LexicalEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
114 changes: 114 additions & 0 deletions packages/lexical/src/__tests__/unit/LexicalEditorEmptyState.test.ts
Original file line number Diff line number Diff line change
@@ -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('<p');

editor.update(
() => {
$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');
});
});
});
Loading