Skip to content

[lexical] Bug Fix: insertNodes no longer leaves an empty paragraph after a block inserted at the end of a paragraph - #9099

Merged
etrepum merged 2 commits into
facebook:mainfrom
Om-singhaI:fix/insert-nodes-trailing-empty-paragraph
Aug 27, 2026
Merged

[lexical] Bug Fix: insertNodes no longer leaves an empty paragraph after a block inserted at the end of a paragraph#9099
etrepum merged 2 commits into
facebook:mainfrom
Om-singhaI:fix/insert-nodes-trailing-empty-paragraph

Conversation

@Om-singhaI

Copy link
Copy Markdown
Contributor

Description

Current behavior: RangeSelection.insertNodes treats block content inconsistently at the same caret. With the caret at the end of a paragraph, pasting another paragraph or a quote merges cleanly, but pasting a block DecoratorNode (the reporter's PullQuote, a YouTube embed, an image, a horizontal rule) or a container element (a list, a table) leaves a stray empty paragraph after the pasted node. Pasting the same nodes in the middle of a paragraph is correct. The contradiction lives inside one function: insertNodes first splits the target paragraph with insertParagraph, then tries to merge the paragraph it split off into the last inserted block. That merge requires $isElementNode(lastInsertedBlock), and for a pasted block decorator the $findMatchingParent(nodeToSelect, INTERNAL_$isBlock) walk starts at the decorator itself, so INTERNAL_$isBlock admits it, $isElementNode rejects it, and the cleanup is skipped. A container element such as a ListNode fails the other clause instead: the split paragraph reports canMergeWhenEmpty() false and INTERNAL_$isBlock(lastToInsert) is false because the list's first child is a ListItemNode. The reporter noted it reproduces with "even regular Element nodes", which is this second clause. The result is deterministic structural noise, one empty paragraph after the pasted node, gone with one Backspace, but it should never appear.

This change splits the conditional: when the merge branch does not apply and the paragraph split off by insertParagraph is empty, it is removed. The isEmpty() guard is what keeps the mid paragraph case intact, because there the split paragraph holds the text after the caret and must stay. Pasting at the start of a paragraph is likewise unchanged, since there the split paragraph holds the whole original text. Typing after a paste at the end still works: with the block decorator as the last child of its parent, $transferStartingElementPointToTextPoint creates a paragraph on demand, so removing the stray paragraph strands nothing.

The same shape exists inside list items: pasting a block decorator at the end of a non empty ListItemNode previously left a stray empty list item, and the split conditional removes it the same way.

Two collateral notes:

  1. The SharedHistoryExtension test in packages/lexical-history/src/__tests__/unit/LexicalHistory.test.tsx asserted the stray paragraph. It inserts a block decorator with insertNodes at the end of a paragraph, and three of its HTML assertions included the trailing <p><br></p> that this bug produced. Those assertions now expect the decorator as the last block, followed by the reconciler's DECORATOR_BOUNDARY_ANCHOR_HTML (the invisible zero size <img> parked outside a trailing block decorator since Bug: DecoratorNode resets the selection of all content #8922, which the trailing paragraph previously made unnecessary). The undo and redo sequence the test exercises is untouched.
  2. The HorizontalRule e2e assertions of a trailing paragraph go through a different code path and stay valid. INSERT_HORIZONTAL_RULE_COMMAND calls $insertNodeToNearestRoot (in packages/lexical-extension/src/HorizontalRuleExtension.ts and packages/lexical-react/src/LexicalHorizontalRulePlugin.ts), which never enters insertNodes, and the paste portions of HorizontalRule.spec.mjs target empty paragraphs, where insertNodes creates no split paragraph at all (shouldInsert is false). A scan of the other e2e specs that assert a trailing empty paragraph after a block found only command insertions through $insertNodeToNearestRoot (tables, horizontal rules), markdown shortcut transformers, Enter key behavior, and pastes into empty paragraphs, none of which reach the changed branch.

Closes #9095

Test plan

New unit tests in packages/lexical/src/__tests__/unit/LexicalSelection.test.ts (Regression tests for #9095) cover block decorator and list pastes at the end, middle and start of a paragraph, quote and paragraph pastes at the same end of paragraph caret as controls, plus typing after a paste at the end:

  1. pasting a block decorator at the end of a paragraph leaves no empty paragraph (failed before this change)
  2. pasting a block decorator in the middle of a paragraph keeps the trailing text
  3. pasting a block decorator at the start of a paragraph keeps the paragraph after it
  4. pasting a list at the end of a paragraph leaves no empty paragraph (failed before this change)
  5. pasting a list in the middle of a paragraph keeps the trailing text
  6. pasting a list at the start of a paragraph keeps the paragraph after it
  7. pasting a quote at the end of a paragraph merges into it (control: the existing branch already handled it)
  8. pasting a paragraph at the end of a paragraph merges into it (control)
  9. typing after pasting a block decorator at the end creates a paragraph on demand

The reporter's exact scenario is a new test in packages/lexical-playground/__tests__/unit/PullQuoteNode.test.ts: pasting a PullQuoteNode with the caret at the end of a paragraph leaves no empty paragraph (failed before this change).

Before

The three new end of paragraph tests against the previous LexicalSelection.ts, each expecting two root children and finding three, the third being the stray empty paragraph:

 FAIL  |unit| packages/lexical/src/__tests__/unit/LexicalSelection.test.ts > Regression tests for #9095 > pasting a block decorator at the end of a paragraph leaves no empty paragraph
 FAIL  |unit| packages/lexical/src/__tests__/unit/LexicalSelection.test.ts > Regression tests for #9095 > pasting a list at the end of a paragraph leaves no empty paragraph
AssertionError: expected [ ParagraphNode{ …(17), …(1) }, …(2) ] to have a length of 2 but got 3
      Tests  2 failed | 7 passed
 FAIL  |unit| packages/lexical-playground/__tests__/unit/PullQuoteNode.test.ts > PullQuoteNode atomic decorator host > pastes at the end of a paragraph without leaving an empty paragraph
      Tests  1 failed | 9 passed (10)

The six other matrix tests and the typing test pass before and after, pinning the behavior that must not change.

After

vitest --project unit --project scripts-unit --no-watch:

 Test Files  271 passed (271)
      Tests  5434 passed | 1 skipped (5435)

The one skipped test predates this change. Reverting only the LexicalSelection.ts change (keeping the tests) brings back the three failures above, and the updated SharedHistoryExtension assertions fail as well, since they now expect the decorator on the trailing edge. tsc --noEmit is clean, and eslint and prettier report nothing for the changed files. The browser, integration and e2e suites were not run.

…ter a block inserted at the end of a paragraph
@vercel

vercel Bot commented Aug 26, 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 Aug 27, 2026 3:39pm
lexical-playground Ready Ready Preview Aug 27, 2026 3:39pm

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 Aug 26, 2026
@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Aug 26, 2026
@etrepum
etrepum added this pull request to the merge queue Aug 27, 2026
Merged via the queue into facebook:main with commit ad5904e Aug 27, 2026
46 checks passed
unix-max pushed a commit to unix-max/lexical that referenced this pull request Aug 28, 2026
…ter a block inserted at the end of a paragraph (facebook#9099)

Co-authored-by: Bob Ippolito <bob@redivi.com>
etrepum pushed a commit to etrepum/lexical that referenced this pull request Aug 30, 2026
Resolves the one conflict in `RangeSelection.insertNodes`: `main` split the
`insertedParagraph` merge into a nested `if`/`else if` in facebook#9099, so the
`!shouldPreserveInsertedBlocks` guard this branch added moves onto the inner
`if` and `main`'s new `else if (insertedParagraph.isEmpty())` fallback is kept.
@etrepum etrepum mentioned this pull request Aug 31, 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. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Inserting a node at the end of a paragraph creates an extra paragraph

2 participants