Skip to content

[lexical-selection] Bug Fix: Wrap a block leaf in an element that is not a block container on its own - #9664

Merged
zurfyx merged 2 commits into
mainfrom
zurfyx/selection-wrap-block-leaf
Oct 31, 2026
Merged

zurfyx merged 2 commits into
mainfrom
zurfyx/selection-wrap-block-leaf

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Description

Closes #9638.

$wrapNodes groups the selected leaves by the block that holds them and
moves each block's content into a new element. A block leaf (a block
decorator, such as a horizontal rule) in an element that is not a block
container (a box that declares no structure, without
NodeStructureExtension) took that element for its block: all the box's
children moved into one new element, those before the range included, and
the new elements were then inserted next to one of them (the paragraph
before the range), inside that new element. The tree had a cycle and the
update never returned.

Such a leaf is now wrapped on its own, as it already is in a block
container, where its parent holds blocks only (the shape of this issue),
and where the range starts at it the new elements go where it is. Where the
parent also holds text directly, the parent stays the block of that text and
of the leaf, as before (doing otherwise anchored the new elements on a rule
the text's unit then moved). Block containers and inline content are
unchanged.

With text directly in a box next to its blocks, $wrapNodes can reorder
text or never return on main too: filed as #9665, and pinned here as a
test.fails property (main fails 8 of its 500 documents, this change 7, all
among main's).

Changeset

.changeset/selection-wrap-block-leaf.md (@lexical/selection patch, [fix] with a Verify: paragraph).

Test plan

WrapNodesBlockLeaf.test.ts (a guard turns a parent cycle into an error,
so a hang fails the test instead of the run):

  • the issue's shape: a range from inside "bb" to the end of "cc" across a
    rule in a box keeps "aa", "bb", the rule and "cc" in order, wraps "bb",
    the rule and "cc", and the rule stays in the box;
  • a range that starts at the rule;
  • a box holding text, two rules and text, from the second rule (forward and
    backward): every text and rule stays;
  • controls: the same range without the rule; a rule at the root;
  • a property over 500 random documents of paragraphs, rules (adjacent ones
    too) and boxes (nested up to twice) and random ranges between text and
    element points: $wrapNodes returns and keeps every text, in order; the
    same with text directly in a box is pinned (Bug: [lexical-selection] $wrapNodes over a box that holds text directly next to its blocks can reorder text or never return #9665).

Before

main (c0c7e42): the issue's repro never returns. With the guard:

Tests  3 failed | 2 passed (5)
  × a range across a horizontal rule in a box keeps every paragraph
  × a range that starts at the rule
  × property: random box, rule and paragraph mixes

After

$ vitest --run --project unit packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts
Tests  7 passed | 1 expected fail (8)

All unit tests: 24,722 passed, 421 expected fail, 0 failed. pnpm run ci-check passes.

…not a block container on its own

## Description

Closes #9638.

`$wrapNodes` groups the selected leaves by the block that holds them and
moves each block's content into a new element. A block leaf (a block
decorator, such as a horizontal rule) in an element that is not a block
container (a box that declares no structure, without
`NodeStructureExtension`) took that element for its block: all the box's
children moved into one new element, those before the range included, and
the new elements were then inserted next to one of them (the paragraph
before the range), inside that new element. The tree had a cycle and the
update never returned.

Such a leaf is now wrapped on its own, as it already is in a block
container, and where the range starts at it the new elements go where it
is. Block containers and inline content are unchanged.

## Test plan

`WrapNodesBlockLeaf.test.ts` (a guard turns a parent cycle into an error,
so a hang fails the test instead of the run):

- the issue's shape: a range from inside "bb" to the end of "cc" across a
  rule in a box keeps "aa", "bb", the rule and "cc" in order, wraps "bb",
  the rule and "cc", and the rule stays in the box;
- a range that starts at the rule;
- controls: the same range without the rule; a rule at the root;
- a property over 300 random documents of paragraphs, rules and boxes
  (nested up to twice) and random ranges between text and element points:
  `$wrapNodes` returns and keeps every text, in order.

### Before

main (c0c7e42): the issue's repro never returns. With the guard:

```
Tests  3 failed | 2 passed (5)
  × a range across a horizontal rule in a box keeps every paragraph
  × a range that starts at the rule
  × property: random box, rule and paragraph mixes
```

### After

```
$ vitest --run --project unit packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts
Tests  5 passed (5)
```

All unit tests: 24,722 passed, 421 expected fail, 0 failed. `pnpm run
ci-check` passes.
@vercel

vercel Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
lexical Ready Ready Preview Oct 31, 2026 6:00am UTC
lexical-playground Ready Ready Preview Oct 31, 2026 6:00am UTC

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 Oct 31, 2026
@meta-code-review

meta-code-review Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 31, 2026, 6:12 AM UTC

@pkg-pr-new

pkg-pr-new Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
lexical

npm i https://pkg.pr.new/lexical@9664

@lexical/a11y

npm i https://pkg.pr.new/@lexical/a11y@9664

@lexical/clipboard

npm i https://pkg.pr.new/@lexical/clipboard@9664

@lexical/code

npm i https://pkg.pr.new/@lexical/code@9664

@lexical/code-core

npm i https://pkg.pr.new/@lexical/code-core@9664

@lexical/code-prism

npm i https://pkg.pr.new/@lexical/code-prism@9664

@lexical/code-shiki

npm i https://pkg.pr.new/@lexical/code-shiki@9664

@lexical/compiler

npm i https://pkg.pr.new/@lexical/compiler@9664

@lexical/devtools-core

npm i https://pkg.pr.new/@lexical/devtools-core@9664

@lexical/dragon

npm i https://pkg.pr.new/@lexical/dragon@9664

@lexical/eslint-plugin

npm i https://pkg.pr.new/@lexical/eslint-plugin@9664

@lexical/extension

npm i https://pkg.pr.new/@lexical/extension@9664

@lexical/fast-check

npm i https://pkg.pr.new/@lexical/fast-check@9664

@lexical/file

npm i https://pkg.pr.new/@lexical/file@9664

@lexical/hashtag

npm i https://pkg.pr.new/@lexical/hashtag@9664

@lexical/headless

npm i https://pkg.pr.new/@lexical/headless@9664

@lexical/history

npm i https://pkg.pr.new/@lexical/history@9664

@lexical/html

npm i https://pkg.pr.new/@lexical/html@9664

@lexical/internal

npm i https://pkg.pr.new/@lexical/internal@9664

@lexical/link

npm i https://pkg.pr.new/@lexical/link@9664

@lexical/list

npm i https://pkg.pr.new/@lexical/list@9664

@lexical/mark

npm i https://pkg.pr.new/@lexical/mark@9664

@lexical/markdown

npm i https://pkg.pr.new/@lexical/markdown@9664

@lexical/mdast

npm i https://pkg.pr.new/@lexical/mdast@9664

@lexical/offset

npm i https://pkg.pr.new/@lexical/offset@9664

@lexical/overflow

npm i https://pkg.pr.new/@lexical/overflow@9664

@lexical/plain-text

npm i https://pkg.pr.new/@lexical/plain-text@9664

@lexical/react

npm i https://pkg.pr.new/@lexical/react@9664

@lexical/rich-text

npm i https://pkg.pr.new/@lexical/rich-text@9664

@lexical/selection

npm i https://pkg.pr.new/@lexical/selection@9664

@lexical/table

npm i https://pkg.pr.new/@lexical/table@9664

@lexical/tailwind

npm i https://pkg.pr.new/@lexical/tailwind@9664

@lexical/text

npm i https://pkg.pr.new/@lexical/text@9664

@lexical/utils

npm i https://pkg.pr.new/@lexical/utils@9664

@lexical/y

npm i https://pkg.pr.new/@lexical/y@9664

@lexical/yjs

npm i https://pkg.pr.new/@lexical/yjs@9664

commit: 0a49882

…its parent holds blocks only

## Description

Review of the first commit found a new hang: a box holding text directly, two rules and more text, with the range from the second rule to the end. The second rule became its own unit and the anchor for the new elements was the first rule, but the text's unit (the box, the block of its direct text) then moved all the box's children, the first rule included, into a new element: the elements were inserted next to a node inside that element.

A block leaf is now its own unit only where its parent holds no inline content directly (a box of blocks, the shape of #9638). Where the parent holds text directly, the parent is the block of that text and of the leaf, as before.

## Test plan

`WrapNodesBlockLeaf.test.ts` adds the review's case (forward and backward; both hang on the previous head, pass here), and the property generates adjacent rules. With text directly in a box, `$wrapNodes` reorders text or never returns in some documents on main too: that property is pinned as `test.fails` and filed as #9665 (main fails 8 of its 500 documents, this change 7, all among main's).

All unit tests pass (`DisposeMemory.test.ts`'s heap bound failed once under 16 workers and passes alone). `pnpm run ci-check` passes.

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this looks good. $wrapNodes now treats a horizontal rule in a box that holds only blocks the same way it already treats one at the root, and the new tests, including the seeded property, fail on main and pass here. The suggestions below are optional and non-blocking.

  1. Optional: also check that the parent is a block (packages/lexical-selection/src/range-selection.ts:304-312). $isBlockLeafInElement checks only that the parent is not a block container and has no inline children. Take a block decorator inside an inline element: p("x", link(<hr>)), p("cc"), with the range starting at the link. The rule becomes the insertion point, and both quotes land inside the paragraph after "x", giving p("x", quote(<hr>), quote("cc")). On main the same range gives quote("x", link(<hr>)), quote("cc"). A block inside an inline element isn't a valid structure to begin with, but adding INTERNAL_isInlineContent(parent) to the early return would keep such a leaf on the existing $wrappedBlock path and leave the issue's shape unchanged.
  2. Optional nit: a paragraph holding only a block decorator loses its format and indent. p[center, indent 2](<hr>) now takes the run path, which skips the format and indent copy (range-selection.ts:642-645). The new quote therefore no longer carries the paragraph's alignment and indent, as it did on main.
  3. Optional: changeset wording (.changeset/selection-wrap-block-leaf.md). The entry is valid under .changeset/README.md, which asks each entry to describe the change from the user's side. Callers whose box shape didn't hang will still see two differences: the rule is now wrapped in its own element, and siblings after the range stay in the box. A clause such as "the rule is wrapped on its own, as at the root" in the summary would tell changelog readers about it.

parent === null ||
!$isLeafNode(node) ||
INTERNAL_isInlineContent(node) ||
$isBlockContainer(parent)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: this never checks that parent is a block. For a block decorator inside an inline element (p("x", link(<hr>)), with the range starting at the link), the rule becomes the insertion point, and the quotes land inside the paragraph after "x". Adding INTERNAL_isInlineContent(parent) || here would keep that case on the existing $wrappedBlock path without affecting #9638's shape.

'@lexical/selection': patch
---

[fix] `$wrapNodes` over a range across a block decorator (a horizontal rule) in an element that is not a block container returns, and keeps every paragraph

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: callers whose box shape didn't hang will still see that the rule is now wrapped in its own element and that siblings after the range stay in the box. A clause such as "the rule is wrapped on its own, as at the root" would tell them about it.

@zurfyx
zurfyx added this pull request to the merge queue Oct 31, 2026
Merged via the queue into main with commit 21bcf62 Oct 31, 2026
57 checks passed

This branch was successfully deployed

2 active deployments
Preview – lexical — 0a498829 Deployed Oct 31, 2026 by vercel[bot]
Preview – lexical-playground — 0a498829 Deployed Oct 31, 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: [lexical-selection] $wrapNodes never returns for a range across a horizontal rule inside a block element that is not a container

1 participant