Skip to content

[lexical] Bug Fix: $getDocument() should fall back to the global document when there is no active editor#8813

Merged
etrepum merged 1 commit into
facebook:mainfrom
potatowagon:fix-getdocument-headless-fallback
Jul 8, 2026
Merged

[lexical] Bug Fix: $getDocument() should fall back to the global document when there is no active editor#8813
etrepum merged 1 commit into
facebook:mainfrom
potatowagon:fix-getdocument-headless-fallback

Conversation

@potatowagon

Copy link
Copy Markdown
Contributor

Description

#8788 added the $getDocument() API and migrated a number of node createDOM() bodies from the bare document global to it:

export function $getDocument(): Document {
  return getRootOwnerDocument($getEditor()._rootElement);
}

$getEditor() throws ("Unable to find an active editor ... can only be used synchronously during the callback of editor.update(), editor.read(), ...") when there is no active editor. As a result, #8788 silently added a new precondition to every node it migrated: createDOM() / exportDOM() now require an ambient active editor.

Any consumer that serializes nodes headlessly — calling node.exportDOM(editor) / node.createDOM(config, editor) outside editor.read() / editor.update() — now throws. Note the editor is already passed to those methods as an argument, but $getDocument() ignores it and relies on the ambient $getEditor().

This is a backwards-incompatible tightening of public methods with no shim or deprecation. The blast radius is every node migrated by #8788 (ListNode, LinkNode, TableNode, DecoratorBlockNode, TextNode, ParagraphNode, …), and any external consumer doing headless exportDOM is one upgrade away from the same throw.

Fix

Make $getDocument() tolerate the no-active-editor case. It uses the active editor's root-owner document when an editor is present, and otherwise falls back to the global document:

export function $getDocument(): Document {
  const editor = internalGetActiveEditor();
  return getRootOwnerDocument(editor !== null ? editor._rootElement : null);
}

The doc comment for $getDocument() is updated to state that — unlike most $-prefixed helpers — it does not require an ambient active editor, precisely because it must remain callable from createDOM / exportDOM during headless serialization.

Test plan

Added a regression unit test in packages/lexical/src/__tests__/unit/LexicalUtils.test.ts covering the no-active-editor case, alongside the two existing $getDocument tests.

Before

Calling a migrated node's exportDOM outside editor.read()/update() throws:

const editor = createHeadlessEditor({nodes: [ListNode], /* ... */});
let list;
editor.update(() => { list = $createListNode('bullet'); $getRoot().append(list); }, {discrete: true});
list.exportDOM(editor); // ❌ throws: "Unable to find an active editor ..."
FAIL  ListNode.exportDOM(editor) does not throw outside read/update
  → Unable to find an active editor. This method can only be used synchronously
    during the callback of editor.update(), editor.read(), ...

After

✓ $getDocument > returns ownerDocument when rootElement is mounted
✓ $getDocument > returns globalThis.document when rootElement is null
✓ $getDocument > returns globalThis.document when called with no active editor  (new)

Test Files  1 passed (1)
     Tests  50 passed (50)

The headless ListNode.exportDOM(editor) call above returns the expected <ul> element instead of throwing. pnpm tsc, prettier --check, and eslint all pass on the changed files.

…tive editor

PR facebook#8788 migrated node createDOM() bodies from the bare `document` global
to $getDocument(), defined as getRootOwnerDocument($getEditor()._rootElement).
$getEditor() throws when there is no active editor, so this silently added a
new precondition to every migrated node: createDOM()/exportDOM() now require
an ambient active editor.

Consumers that serialize nodes headlessly — calling node.exportDOM(editor)
or createDOM(config, editor) outside editor.read()/update() — now throw
"Unable to find an active editor". This is a backwards-incompatible tightening
of public methods with no shim/deprecation, affecting every node migrated by
facebook#8788 (List, Link, Table, DecoratorBlock, ...).

Make $getDocument() tolerate the no-active-editor case using the existing
non-throwing internalGetActiveEditor() lookup, falling back to the global
document (which getRootOwnerDocument(null) already returns). This keeps the
Shadow DOM / cross-origin iframe benefit when an editor is active and restores
pre-facebook#8788 behavior otherwise.
@vercel

vercel Bot commented Jul 8, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview Jul 8, 2026 7:47am
lexical-playground Ready Ready Preview Jul 8, 2026 7:47am

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 Jul 8, 2026
@mayrang

mayrang commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Thanks for catching this — the $getEditor() call inside $getDocument() was my oversight in #8788. I didn't account for headless exportDOM() calls outside editor.read()/editor.update(). The internalGetActiveEditor() fallback here is the right fix.

@etrepum etrepum left a comment

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.

The jsdoc here isn’t quite correct anymore, but we can fix that later.

@etrepum etrepum added this pull request to the merge queue Jul 8, 2026
Merged via the queue into facebook:main with commit b00510c Jul 8, 2026
46 checks passed
@etrepum etrepum mentioned this pull request Jul 9, 2026
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.

3 participants