Repository navigation
[lexical-selection] Bug Fix: Preserve mixed-block content when wrapping selections - #9668
wozhenbang2004 wants to merge 6 commits into
Conversation
…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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Hi @wozhenbang2004! Thank you for your pull request and welcome to our community. Action RequiredIn 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. ProcessIn 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 If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks! |
|
AI review status for this pull request.
|
There was a problem hiding this comment.
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, t1box(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 letwrapFailuresgenerate 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.mdasks for a summary that describes the change from the user's side.
| } | ||
| } | ||
|
|
||
| const firstUnit = units[nodes.indexOf(firstNode)]; |
There was a problem hiding this comment.
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:
| const firstUnit = units[nodes.indexOf(firstNode)]; | |
| const firstUnit = emptyElements.has(firstNode.getKey()) | |
| ? null | |
| : units[nodes.indexOf(firstNode)]; |
There was a problem hiding this comment.
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)', () => { |
There was a problem hiding this comment.
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.
| '@lexical/selection': patch | ||
| --- | ||
|
|
||
| [fix] Keep nested content in order when wrapping blocks that also hold text directly |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
- 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 examplequote(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 aVerify:saying what to test. One sentence on this would help people who upgrade. - A list-item case. One built-in shape this also affects is a
ListItemNodethat holds text beside a nestedListNode. It comes from stored or pasted content; see the comment onINTERNAL_$liftsFromStructureinpackages/lexical-list/src/LexicalListItemNode.ts. Such documents can reorder text onmain. With this change they keep their order, and the nested list stays inside the new quote. A case likelist(li("t1", list(li("t2"))))wrapped fromt1tot2would pin that behavior. - Empty
wrappingElementplacement (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. ThenfirstNodefalls back to a non-empty element, and it now takes its parent's unit. So an emptywrappingElementlands beside the parent instead of beside that block. Nothing moves, and neither placement is clearly better. If the previous placement is preferred, usingfirstUnitonly 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. |
There was a problem hiding this comment.
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.
| const firstUnit = emptyElements.has(firstNode.getKey()) | ||
| ? null | ||
| : units[nodes.indexOf(firstNode)]; |
There was a problem hiding this comment.
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.
Description
When a block holds text directly next to nested blocks,
$wrapNodescan 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.mdadds a patch entry for@lexical/selection, with verification steps.Test plan
Before
The new regression tests against main
21bcf62: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 sameWrapNodesBlockLeaf.test.ts --maxWorkers=1command reports4 failed | 15 passed (19): the Box/list forward/backward cases reproducet0, t3, t1instead oft0, t1, t3. Both covered-empty cases already pass.After
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
WrapNodesBlockLeafandWrapNodesInlineContent: 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 --noEmitnode_modules/.bin/tsc -p tsconfig.test.json --noEmitnode scripts/check-flow-types.mjs(Found 0 errors)node scripts/changesets/check.mjs(Changeset check passed: 68 pending changeset(s) valid.)The combined
pnpm run ci-checkattempt 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.