Skip to content

[lexical] Bug Fix: recover from an empty editor state instead of throwing - #9183

Open
potatowagon wants to merge 1 commit into
facebook:mainfrom
potatowagon:sw/error-38-recover
Open

potatowagon wants to merge 1 commit into
facebook:mainfrom
potatowagon:sw/error-38-recover

Conversation

@potatowagon

@potatowagon potatowagon commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Description

editor.setEditorState() currently throws an invariant (minified error #38, "setEditorState: the editor state is empty") whenever it is handed an EditorState whose root has no children. The check is right that such a state is not the canonical empty document — it reconciles to a contenteditable with no block element in it at all, so there is nowhere to place a caret, and everything downstream that assumes root.getFirstChild() exists has to special-case it — but throwing is a harsh response to a shape that routinely arrives from outside the editor: content that was persisted while the editor was empty round-trips to {"root":{"children":[]}} and comes back through parseEditorState() on rehydrate. In production that turns recoverable bad input into a crash of the host application.

This change keeps the condition loud where it is actionable and recoverable where it is not:

  • It is reported through the editor's warn-level hook (onWarn), whose default throws in development — so the dev-time behaviour of setEditorState is unchanged — and only console.warns in production. Embedders can pass their own onWarn to route it to telemetry, or to restore the old throw-everywhere behaviour.
  • The state is then recovered to the canonical empty document (a root with a single empty paragraph) inside the update that applies it, so the editor that comes out the other side is a normal empty editor.

This is the pattern the update-recursion guard in LexicalUpdates already uses, and onWarn is documented as being for conditions the editor has already recovered from — hence the recovery rather than a bare warning. The warning is raised with a direct editor._onWarn(...) call rather than an invariant, because transform-error-messages rewrites invariant call sites into formatProdErrorMessage(...) and the warning would never reach the hook in a built artifact. Error code 38 is left in scripts/error-codes/codes.json untouched.

No API changes; setEditorState keeps its signature and its development-time behaviour.

Test plan

Two tests added to packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx: an empty parsed state is recovered to one empty paragraph, is reported once to onWarn, renders a block in the DOM and accepts text; and the default onWarn still throws in development.

Before

$ npx vitest run --project unit \
    packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx -t 'empty editor state'

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  |unit| packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx > LexicalEditor tests > setEditorState recovers an empty editor state and reports it to onWarn
Error: setEditorState: the editor state is empty. Ensure the editor state's root node never becomes empty.
 ❯ invariant packages/lexical-internal/src/invariant.ts:28:9
 ❯ LexicalEditor.setEditorState packages/lexical/src/LexicalEditor.ts:1737:7
 ❯ packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx:3030:12

 Test Files  1 failed (1)
      Tests  1 failed | 1 passed | 92 skipped (94)

After

$ npx vitest run --project unit \
    packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx -t 'empty editor state'
 ✓ |unit| packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx (94 tests | 92 skipped) 156ms

 Test Files  1 passed (1)
      Tests  2 passed | 92 skipped (94)

$ pnpm run test-unit
 Test Files  257 passed (257)
      Tests  5134 passed | 1 skipped (5135)

$ pnpm run tsc        # clean
$ pnpm run flow       # No errors!
$ pnpm run prettier   # no differences
$ pnpm run lint       # clean

…wing

## Description

`editor.setEditorState()` currently throws an invariant (minified error facebook#38,
"setEditorState: the editor state is empty") whenever it is handed an
`EditorState` whose root has no children. The check is right that such a state
is not the canonical empty document — it reconciles to a contenteditable with
no block element in it at all, so there is nowhere to place a caret, and
everything downstream that assumes `root.getFirstChild()` exists has to
special-case it — but throwing is a harsh response to a shape that routinely
arrives from *outside* the editor: content that was persisted while the editor
was empty round-trips to `{"root":{"children":[]}}` and comes back through
`parseEditorState()` on rehydrate. In production that turns recoverable bad
input into a crash of the host application.

This change keeps the condition loud where it is actionable and recoverable
where it is not:

- It is reported through the editor's warn-level hook (`onWarn`), whose
  default throws in development — so the dev-time behaviour of
  `setEditorState` is unchanged — and only `console.warn`s in production.
  Embedders can pass their own `onWarn` to route it to telemetry, or to
  restore the old throw-everywhere behaviour.
- The state is then recovered to the canonical empty document (a root with a
  single empty paragraph) inside the update that applies it, so the editor
  that comes out the other side is a normal empty editor.

This is the pattern the update-recursion guard in `LexicalUpdates` already
uses, and `onWarn` is documented as being for conditions the editor has
already recovered from — hence the recovery rather than a bare warning. The
warning is raised with a direct `editor._onWarn(...)` call rather than an
`invariant`, because `transform-error-messages` rewrites invariant call sites
into `formatProdErrorMessage(...)` and the warning would never reach the hook
in a built artifact. Error code 38 is left in `scripts/error-codes/codes.json`
untouched.

No API changes; `setEditorState` keeps its signature and its development-time
behaviour.

## Test plan

Two tests added to `packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx`:
an empty parsed state is recovered to one empty paragraph, is reported once to
`onWarn`, renders a block in the DOM and accepts text; and the default
`onWarn` still throws in development.

### Before

```
$ npx vitest run --project unit \
    packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx -t 'empty editor state'

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  |unit| packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx > LexicalEditor tests > setEditorState recovers an empty editor state and reports it to onWarn
Error: setEditorState: the editor state is empty. Ensure the editor state's root node never becomes empty.
 ❯ invariant packages/lexical-internal/src/invariant.ts:28:9
 ❯ LexicalEditor.setEditorState packages/lexical/src/LexicalEditor.ts:1737:7
 ❯ packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx:3030:12

 Test Files  1 failed (1)
      Tests  1 failed | 1 passed | 92 skipped (94)
```

### After

```
$ npx vitest run --project unit \
    packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx -t 'empty editor state'
 ✓ |unit| packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx (94 tests | 92 skipped) 156ms

 Test Files  1 passed (1)
      Tests  2 passed | 92 skipped (94)

$ pnpm run test-unit
 Test Files  257 passed (257)
      Tests  5134 passed | 1 skipped (5135)

$ pnpm run tsc        # clean
$ pnpm run flow       # No errors!
$ pnpm run prettier   # no differences
$ pnpm run lint       # clean
```

E2E and browser-mode tests were not run in this environment (no browser
runner available); the change is covered by the unit tests above.
@vercel

vercel Bot commented Sep 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
lexical Ready Ready Preview Sep 21, 2026 7:37am UTC
lexical-playground Ready Ready Preview Sep 21, 2026 7:37am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Sep 21, 2026
@potatowagon potatowagon changed the title [lexical] Bug Fix: recover from an empty editor state instead of thro… [lexical] Bug Fix: recover from an empty editor state instead of throwing Sep 21, 2026
@potatowagon

Copy link
Copy Markdown
Contributor Author

Some archaeology on where this invariant came from, since "why was it a throw in the first place?" is the obvious question to ask of this PR.

Origin

It arrived in #669 ("Add further invariants", Sep 2021), back when it read setViewModel: the view model is empty. The PR body in full is "Adds further invariants around empty view models." — no linked issue, no review discussion. It looks like a batch of defensive assertions rather than a considered API contract.

The twin invariant was already removed

#669 added the check in two places:

  • setViewModel"the view model is empty"
  • the update path — "updateEditor: the pending view model is empty. Ensure the root not never becomes empty from an update."

The second one was removed in #1316 ("Make root selectable", Feb 2022), by the same author. That is likely the original rationale, and it has expired: before root was selectable, an empty root meant there was nowhere for a selection to live. Once root became selectable that hazard went away, and the update-path check went with it. The setEditorState door was simply never revisited.

The result today is an asymmetry — Lexical produces a state it then refuses to accept:

editor.update(() => { $getRoot().clear(); }, {discrete: true});
// does not throw
editor.getEditorState().isEmpty();          // true
JSON.stringify(editor.getEditorState());
// {"root":{"children":[],"direction":null,"format":"","indent":0,"type":"root","version":1}}
// ^ exactly the payload setEditorState rejects

So root.clear() is legal, persisting the result is legal, and only rehydrating it throws — in production, as minified error #38, inside the host application.

This has surfaced repeatedly

What that means for this change

The condition is worth reporting — a childless root is not the canonical empty document, and code downstream that assumes root.getFirstChild() exists has to special-case it. But the response is what this PR changes: report through onWarn (throws in dev, console.warns in prod, overridable) and recover to a root with one empty paragraph, instead of throwing in production.

Risks I can see, for reviewers to weigh:

  1. Collaboration. Bug: editorState null serializes to empty editor state and can no longer be loaded #5079's "intentional" ruling was about editorState: null meaning the Yjs document owns the content. If an app passes an explicitly empty serialized state while using collab, the recovery inserts a paragraph the Yjs doc does not have, which could propagate. Narrow — the documented collab bootstrap passes null and never reaches setEditorState — but it is the case where the throw was arguably protective.
  2. Masking a broken persistence path. The throw is a tripwire; in production this now only warns. Embedders who want the old behaviour can pass an onWarn that re-throws, and anyone relying on the signal should route onWarn to telemetry rather than leaving it at console.warn.
  3. Round-trip identity. setEditorState(s) followed by getEditorState() no longer returns s for this one degenerate input — relevant to @lexical/history, where an undo onto a recorded empty state now lands on an empty paragraph rather than throwing.

Happy to gate the recovery behind an opt-in, or to warn without recovering, if either fits the project's intent better. I went with recover-by-default because a childless root reconciles to a contenteditable with no block element in it at all, so a bare warning leaves the editor in a state with nowhere to place a caret.

/**
* Imperatively set the EditorState. Triggers reconciliation like an update.
*
* An empty EditorState (a root with no children) is not the canonical empty

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think this edge case should be in the documentation of the function

// bundle, dropping the editor reference, so the warning would never
// reach `_onWarn` in a built artifact.
this._onWarn(
new Error(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This long string doesn’t get reduced in the codes map. It’s probably not even worth a warning, especially in prod

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also the whole rationale for using a bare message is moot if the warning is moved to the same location where the workaround occurs (inside if (isEmptyEditorState) { … })

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants