Skip to content

Bug: Backspace at the start of the first block throws "window object not found" in a headless editor #9849

Description

@ttizze

Lexical version: 0.53.0 (the code path is unchanged on main)

Backspace (DELETE_CHARACTER_COMMAND with isBackward = true) at the start of the first block throws Error: window object not found in a headless editor, so the block is never collapsed (e.g. a quote is not turned into a paragraph). The same keystroke works when there is a previous block, because the cross-block merge is resolved in the model.

We hit this running @lexical/headless inside JavaScriptCore (no DOM) as the model behind a native iOS text view; it also reproduces in Node/Bun.

Steps To Reproduce

  1. Create a headless editor with registerRichText and a QuoteNode.
  2. Put the caret at the start of a quote that is the first child of the root.
  3. Dispatch DELETE_CHARACTER_COMMAND with true.

Link to code example:

import { createHeadlessEditor } from "@lexical/headless";
import { $createQuoteNode, QuoteNode, registerRichText } from "@lexical/rich-text";
import {
  $createParagraphNode,
  $createTextNode,
  $getRoot,
  DELETE_CHARACTER_COMMAND,
} from "lexical";

function run(withPreviousParagraph) {
  const editor = createHeadlessEditor({
    nodes: [QuoteNode],
    onError: (error) => {
      throw error;
    },
  });
  registerRichText(editor);
  editor.update(
    () => {
      const quote = $createQuoteNode().append($createTextNode("quote"));
      if (withPreviousParagraph)
        $getRoot().append($createParagraphNode().append($createTextNode("before")));
      $getRoot().append(quote);
      quote.selectStart();
    },
    { discrete: true },
  );
  try {
    editor.update(() => editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true), {
      discrete: true,
    });
    console.log(withPreviousParagraph, editor.read(() => $getRoot().getChildren().map((n) => n.getType())));
  } catch (error) {
    console.log(withPreviousParagraph, String(error));
  }
}

run(false); // false 'Error: window object not found'
run(true);  // true [ 'paragraph' ]

The current behavior

RangeSelection.deleteCharacter finds no adjacent block to merge with, then calls $extendSelectionForDeletion(this, isBackward, 'character'). That function calls getDOMSelection(getWindow(editor)), and getWindow throws window object not found because editor._window is null in a headless editor. The else if (isBackward && anchor.offset === 0) branch that runs $collapseAtStart is never reached.

The expected behavior

The quote collapses to a paragraph, as it does in the browser. $extendSelectionForDeletion already returns false when there is no DOM selection (if (!domSelection || ...) return false;), so returning false when the editor has no window (instead of letting getWindow throw) would let deleteCharacter continue to the model-only $collapseAtStart path.

Impact of fix

Affects any headless use of the rich text commands (server-side processing, tests, native shells that use Lexical as the document model). Backspace at the very start of a document is a common keystroke, and today it throws instead of being a no-op or collapsing the block.

Activity

  1. added a commit that references this issue on Nov 13, 2026
    2d9daab
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions