[lexical] Bug Fix: $removeTextFromCaretRange no longer leaves a childless list or table as the root's only child by Om-singhaI · Pull Request #9116 · facebook/lexical · GitHub
Skip to content

[lexical] Bug Fix: $removeTextFromCaretRange no longer leaves a childless list or table as the root's only child - #9116

Merged
etrepum merged 1 commit into
facebook:mainfrom
Om-singhaI:fix/empty-container-after-select-all-delete
Sep 8, 2026
Merged

etrepum merged 1 commit into
facebook:mainfrom
Om-singhaI:fix/empty-container-after-select-all-delete

Conversation

@Om-singhaI

Copy link
Copy Markdown
Contributor

Description

Current behavior: in a document whose first block is a block decorator and whose last is a list, Cmd+A then Backspace leaves the root holding a ListNode with no children, and the next inline insert throws Expected node ListNode of type list to have a block ElementNode ancestor out of insertNodes. The invariant is uncaught, so it reaches the application's onError.

Expected, and what this produces: root[paragraph], with the next Shift+Enter inserting normally.

Mechanism

$removeTextFromCaretRange removes the emptied focus block, then walks up removing ancestors emptied with it. The walk breaks when removing parent would empty the root:

if (grandparent && $isRootNode(grandparent) && grandparent.getChildrenSize() <= 1) {
  break;
}

That applies unconditionally, so it stops on a container that has declared it cannot be empty. The fix adds && parent.canBeEmpty().

Three things say the guard is stale. Main has no policy of preserving lists, only one about the root's child count: the same keystrokes over [paragraph, list] remove the list today, because the root then has two children and the guard never fires. The sibling branch of the same function does the same climb with the comment // remove empty parent node even if parent node is canBeEmpty and no guard. And the case the guard protects is now handled below it by $restoreEmptyContainerParagraph, which arrived in #9058 two months after the guard arrived in #8751.

This is not specific to lists

TableNode and the playground's LayoutContainerNode also return canBeEmpty(): false and can sit under the root, so [block decorator, table] goes from root[table[]] to root[paragraph[]] too. The table failure is quieter than the list one: it does not throw, because insertNodes catches an element point on a shadow root first, and the content is spliced into the TableNode instead, giving a table holding a paragraph where its rows belong. One rule looks right for both and both are tested, but happy to split the table half out.

This is visible to consumers: any third party ElementNode returning canBeEmpty(): false that can be a direct child of the root now disappears on this path instead of surviving empty.

Test plan

Six tests in LexicalCaret.test.ts. Four cover the changed branch, a list and a table each replaced with an empty paragraph plus the follow up insert for each. Two are controls that pass in either state.

a container that may be empty is kept is the control that bears on the guard: TestElementNode at the same depth reaches the guard and takes the preserved branch, and replacing the new conjunct with false flips it. Nothing else pins that branch, Issue8745Repro.test.ts from #8751 still passes all 14 with the break neutralized, which is part of why the guard reads as stale.

Reverting only LexicalCaretUtils.ts: 4 failed, 179 passed. Restoring it: 183 passed. Neighbouring suites each pass unchanged, including Issue8745Repro 14, LexicalSelection 100, LexicalLinkNode 74 and LexicalTableNode 42. tsc --noEmit, eslint and prettier --check clean.

I did not run the browser project, so Issue8745DeleteLine.test.ts from #8751 is unexercised. Every element registered there returns canBeEmpty() true, so the added conjunct is a no op and the break expression is identical to main, but that is an argument from the node definitions rather than a run.

…less list or table as the root's only child

The empty ancestor walk stopped at the root's last child even when that
child declared canBeEmpty() false, so select all delete over [block
decorator, list] left root[list[]] and the next inline insert threw out
of insertNodes. I added canBeEmpty() to the break condition, so the
container is removed and the restoration below supplies the paragraph.
Tables change too; an empty one took the insert as a child, not rows.
@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 4, 2026
@vercel

vercel Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

@etrepum
etrepum added this pull request to the merge queue Sep 8, 2026
Merged via the queue into facebook:main with commit 3536912 Sep 8, 2026
50 checks passed
@etrepum etrepum mentioned this pull request Sep 17, 2026

This branch was successfully deployed

2 active deployments
Preview – lexical — ba9747b7 Deployed Sep 4, 2026 by vercel[bot]
Preview – lexical-playground — ba9747b7 Deployed Sep 4, 2026 by vercel[bot]
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. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants