Repository navigation
Description
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
- Create a headless editor with
registerRichTextand aQuoteNode. - Put the caret at the start of a quote that is the first child of the root.
- Dispatch
DELETE_CHARACTER_COMMANDwithtrue.
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
[lexical] Bug Fix: Backspace at the start of the first block no longe…