Skip to content

[lexical-selection] Bug Fix: Preserve mixed-block content when wrapping selections - #9668

Open
wozhenbang2004 wants to merge 6 commits into
meta:mainfrom
wozhenbang2004:fix/wrap-mixed-blocks
Open

wozhenbang2004 wants to merge 6 commits into
meta:mainfrom
wozhenbang2004:fix/wrap-mixed-blocks

Conversation

@wozhenbang2004

@wozhenbang2004 wozhenbang2004 commented Oct 31, 2026 •

Copy link
Copy Markdown

Description

When a block holds text directly next to nested blocks, $wrapNodes can assign overlapping wrapping units. Moving a nested unit before its containing block can reorder text or leave the insertion anchor inside a new wrapper, producing a parent cycle.

Resolve each unit to the outermost block whose children will move, cache those ancestor lookups, and choose the insertion anchor from the resolved unit. Covered empty blocks stay in the moved subtree. An uncovered empty first block keeps its own insertion anchor so preceding siblings stay in place. The existing handling of declared block containers and inline runs is retained.

Addresses issue #9665

Changeset

.changeset/tidy-box-wrap.md adds a patch entry for @lexical/selection, with verification steps.

Test plan

Before

The new regression tests against main 21bcf62:

pnpm exec vitest run --project unit packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts --maxWorkers=2
Tests 6 failed | 7 passed (13)

Both directions of the cycle case fail with getParent called more than a million times: a cycle. Both text-order cases fail, the nested empty-block case fails, and the existing 500-document property reports seven failures, including three cycles.

The review follow-up adds six focused empty-first-block cases. Against published head 0024dfcd, the same WrapNodesBlockLeaf.test.ts --maxWorkers=1 command reports 4 failed | 15 passed (19): the Box/list forward/backward cases reproduce t0, t3, t1 instead of t0, t1, t3. Both covered-empty cases already pass.

After

node_modules/.bin/vitest run --project unit packages/lexical-selection/src/__tests__/unit --maxWorkers=1
Test Files 9 passed (9)
Tests 1888 passed (1888)

This includes forward/backward selections, horizontal rules, nested empty blocks, a wrapping element, attached selection points with valid offsets, and the existing seeded property test now enabled as a normal test. The six new cases also check the empty quote position and covered empty-node identity, parent and sibling order. Independent follow-up QA ran WrapNodesBlockLeaf and WrapNodesInlineContent: 2 files / 1535 tests passed. These runs contain no skipped or expected-failure tests.

These checks were rerun for the follow-up and exited 0:

  • node_modules/.bin/tsc --noEmit
  • node_modules/.bin/tsc -p tsconfig.test.json --noEmit
  • node scripts/check-flow-types.mjs (Found 0 errors)
  • ESLint on the changed production and test files
  • Prettier on the three changed files
  • node scripts/changesets/check.mjs (Changeset check passed: 68 pending changeset(s) valid.)

The combined pnpm run ci-check attempt hit killed TypeScript processes (exit 137); the production/test type checks above were then run separately. The full unit/scripts-unit attempt was stopped (exit 130), so no full-suite result is claimed. Browser E2E tests were not run.

…rlapping

## Description
Resolve nested wrapping units to the outer block whose children are moved, and derive the insertion anchor from that unit. This keeps text in order and prevents inserting a wrapper into its own descendant for mixed inline/block content.

## Test plan
### Before
With the regression tests prepared for meta#9665 on main 21bcf62:
```text
pnpm exec vitest run --project unit packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts --maxWorkers=2
Tests 6 failed | 7 passed (13)
```
### After
```text
node_modules/.bin/vitest run --project unit packages/lexical-selection/src/__tests__/unit --maxWorkers=1
Test Files 9 passed (9)
Tests 1882 passed (1882)
```
The regression tests follow in a separate commit. Targeted ESLint passed. Full ci-check hit a killed TypeScript process (exit 137); browser E2E tests were not run.
## Description
Add forward and backward cases for the parent cycle and text reordering from meta#9665, plus nested empty-block, horizontal-rule, wrapping-element and selection checks. Enable the existing 500-document property test.

## Test plan
### Before
On main 21bcf62 with these tests:
```text
pnpm exec vitest run --project unit packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts --maxWorkers=2
Tests 6 failed | 7 passed (13)
```
### After
```text
node_modules/.bin/vitest run --project unit packages/lexical-selection/src/__tests__/unit --maxWorkers=1
Test Files 9 passed (9)
Tests 1882 passed (1882)
```
Targeted ESLint passed. Browser E2E tests were not run.
## Description
Document the corrected wrapping behavior and the upgrade verification steps for meta#9665.

## Test plan
### Before
```text
node scripts/changesets/check.mjs
Changeset check passed: 67 pending changeset(s) valid.
```
### After
```text
node scripts/changesets/check.mjs
Changeset check passed: 68 pending changeset(s) valid.
```
Prettier check passed for the changed source, tests and changeset.
@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 9:31am UTC
lexical-playground Ready Ready Preview Oct 31, 2026 9:31am UTC

Request Review

@meta-cla

meta-cla Bot commented Oct 31, 2026

Copy link
Copy Markdown

Hi @wozhenbang2004!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@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, 9:44 AM UTC

@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 for working on #9665! Resolving each wrapping unit to the outermost block whose children move, and picking the insertion anchor from that unit, fixes both cases from the issue, and the new tests fail on main and pass here.

One regression needs a fix before this can merge.

An empty first block inside a non-container element is now wrapped before its parent (packages/lexical-selection/src/range-selection.ts:639)

firstUnit is read from units. For an empty block whose parent is not a block container, that unit belongs to the parent ({block: parent, run: null}), so the anchor becomes the parent element instead of the empty block, and the new elements are inserted before the whole parent. On main the anchor was the empty block itself. A stock list reproduces it:

// root: p("t0"), list(li("t1"), li(), li("t3"))
// selection: anchor on the empty li at offset 0 (element), focus at the end of "t3"
$wrapNodes(selection, () => $createQuoteNode());
// main:    p("t0"), list(li("t1")), quote(), quote("t3")    -> t0, t1, t3
// this PR: p("t0"), quote(), quote("t3"), list(li("t1"))    -> t0, t3, t1

box(p("tA"), p(), p("tB")) with the range starting on the empty paragraph does the same thing (the result reads tB, tA). The "Backwards Compatibility" section of AGENTS.md asks that existing public APIs keep their behavior for current callers, and keeping text in order is exactly what this PR restores elsewhere.

Here is a minimal fix that keeps covered empty blocks working (they are already removed from emptyElements a few lines above):

const firstUnit = emptyElements.has(firstNode.getKey())
  ? null
  : units[nodes.indexOf(firstNode)];

With this change, both cases above keep their order and the existing @lexical/selection unit tests still pass.

Suggestions

  • Please add a regression test for the empty-first-block case next to the new ones in WrapNodesBlockLeaf.test.ts. You could also let wrapFailures generate empty paragraphs and empty boxes: with them, the seeded property catches this regression on the current head.
  • Optional: name the API in the changeset summary (.changeset/tidy-box-wrap.md), for example [fix] `$wrapNodes` keeps nested content in order when a block holds text directly next to its blocks. .changeset/README.md asks for a summary that describes the change from the user's side.

}
}

const firstUnit = units[nodes.indexOf(firstNode)];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For an empty block whose parent is not a block container (an empty list item, an empty paragraph in a box), units[i] is the parent's unit, so target becomes the parent and the new elements are inserted before it. Wrapping p("t0"), list(li("t1"), li(), li("t3")) from the empty item to "t3" reads t0, t3, t1 here, and t0, t1, t3 on main. Covered empty blocks have already left emptyElements, so this keeps them working:

Suggested change
const firstUnit = units[nodes.indexOf(firstNode)];
const firstUnit = emptyElements.has(firstNode.getKey())
? null
: units[nodes.indexOf(firstNode)];

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in be1edd8, with six focused regressions in f9f800b. On published head 0024dfc, the Box/list cases fail in both directions with t0,t3,t1 (4 failed / 15 passed); both covered-empty cases already pass. With the guard, the selection package passes 9 files / 1888 tests, including the empty quote position, covered empty-node identity/parent/sibling order, and attached selection. Independent QA passes 2 related files / 1535 tests. Production/test TypeScript, Flow, targeted ESLint, Prettier and changeset validation also pass. The changeset now names $wrapNodes. The PR description records the exact checks and the earlier incomplete full-suite/ci-check attempts.

expectAttachedSelection(editor);
});

test('property: with text directly in a box (#9665)', () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Consider letting wrapFailures emit empty paragraphs and empty boxes too: with them, this property reports order failures on the current head (the empty-first-block case on range-selection.ts:639) and passes with the one-line guard there.

Comment thread .changeset/tidy-box-wrap.md Outdated
'@lexical/selection': patch
---

[fix] Keep nested content in order when wrapping blocks that also hold text directly

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: name the API so changelog readers know what changed, e.g. [fix] `$wrapNodes` keeps nested content in order when a block holds text directly next to its blocks.

## Description

Keep an uncovered empty first block as the insertion anchor rather than promoting it to its parent. Covered empty blocks continue to move with their enclosing wrapping unit.

## Test plan

### Before

On published head 0024dfc with six new focused regressions:

```text
node_modules/.bin/vitest run --project unit packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts --maxWorkers=1
Tests 4 failed | 15 passed (19)
```

### After

```text
node_modules/.bin/vitest run --project unit packages/lexical-selection/src/__tests__/unit --maxWorkers=1
Test Files 9 passed (9)
Tests 1888 passed (1888)
```

Production/test TypeScript, Flow (0 errors), targeted ESLint, Prettier and changeset validation passed. The tests follow in the next commit. The full unit suite and combined ci-check were not rerun; their earlier incomplete results remain documented in the PR.
## Description

Add forward/backward empty-first-block regressions for stock lists and custom boxes, including the empty wrapper position and attached selection. Add both directions where an empty first block is covered by a whole mixed block, asserting its identity, parent and sibling order.

## Test plan

### Before

On published head 0024dfc with these tests:

```text
node_modules/.bin/vitest run --project unit packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts --maxWorkers=1
Tests 4 failed | 15 passed (19)
```

### After

```text
node_modules/.bin/vitest run --project unit packages/lexical-selection/src/__tests__/unit --maxWorkers=1
Test Files 9 passed (9)
Tests 1888 passed (1888)
```

Independent QA: WrapNodesBlockLeaf and WrapNodesInlineContent, 2 files / 1535 tests passed. Test TypeScript and targeted ESLint passed; no expected failures or skipped tests in these runs.
## Description

Name the public $wrapNodes API in the patch changeset so changelog readers can identify the affected behavior.

## Test plan

### Before

The summary described wrapping without naming the API.

### After

```text
node scripts/changesets/check.mjs
Changeset check passed: 68 pending changeset(s) valid.
```

Prettier passed for the three changed files.

@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 for the fix: this looks good. It resolves each wrapping unit to the outermost block whose children move, and takes the insertion point from that unit. That removes both the parent cycle and the text reordering described in #9665. The new cases assert text order, structure and an attached selection. They cover both directions and nested empty blocks, and the property test is no longer pinned as test.fails.

A few optional, non-blocking suggestions:

  1. Changeset Verify: (.changeset/tidy-box-wrap.md:7). Besides keeping order, the output now changes shape for these blocks. Nested blocks stay inside the one new element, for example quote(p("t0"), "t1", box("t2", hr, p("t3"), p("t4"))) instead of separate sibling quotes. A nested empty block also stays in place instead of becoming an extra empty wrapper. CONTRIBUTING.md (Changesets) asks a fix that changes observable output to give a Verify: saying what to test. One sentence on this would help people who upgrade.
  2. A list-item case. One built-in shape this also affects is a ListItemNode that holds text beside a nested ListNode. It comes from stored or pasted content; see the comment on INTERNAL_$liftsFromStructure in packages/lexical-list/src/LexicalListItemNode.ts. Such documents can reorder text on main. With this change they keep their order, and the nested list stays inside the new quote. A case like list(li("t1", list(li("t2")))) wrapped from t1 to t2 would pin that behavior.
  3. Empty wrappingElement placement (packages/lexical-selection/src/range-selection.ts:639-643). Sometimes the selection holds no leaf and no empty block, for example a caret at an element point between blocks. Then firstNode falls back to a non-empty element, and it now takes its parent's unit. So an empty wrappingElement lands beside the parent instead of beside that block. Nothing moves, and neither placement is clearly better. If the previous placement is preferred, using firstUnit only for a leaf or a covered empty block keeps it, and a small test would pin whichever placement is intended.


[fix] `$wrapNodes` keeps nested content in order when a block holds text directly next to its blocks

Verify: Wrap a selection across text and nested blocks in a custom element without a declared structure. The update should finish, keep the content in its original order, and leave the selection attached.

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: besides order, the output shape changes for these blocks. Nested blocks now stay inside the one new element (e.g. quote(p("t0"), "t1", box("t2", hr, p("t3"), p("t4"))) rather than separate sibling quotes), and nested empty blocks stay in place. One sentence here, per CONTRIBUTING.md's Verify: guidance, would tell upgraders what to check.

Comment on lines +639 to +641
const firstUnit = emptyElements.has(firstNode.getKey())
? null
: units[nodes.indexOf(firstNode)];

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: when firstNode is the nodes[0] fallback (a non-empty element, e.g. a caret at an element point between blocks), this now takes the element's unit, which is its parent's block. An empty wrappingElement then lands beside the parent instead of beside the block. Nothing moves, and neither placement is clearly better. If the previous placement is preferred, using firstUnit only when firstNode is a leaf or a covered empty block keeps it.

This branch was successfully deployed

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant