From eef0f8c144fe5fa9d60baa5f121321847a6fe2fc Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:43:10 +0800 Subject: [PATCH 1/7] [lexical-selection] Bug Fix: Keep mixed-block wrapping units from overlapping ## 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 #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. --- .../lexical-selection/src/range-selection.ts | 103 ++++++++++++------ 1 file changed, 72 insertions(+), 31 deletions(-) diff --git a/packages/lexical-selection/src/range-selection.ts b/packages/lexical-selection/src/range-selection.ts index b809a20e95a..97e6f9bd80c 100644 --- a/packages/lexical-selection/src/range-selection.ts +++ b/packages/lexical-selection/src/range-selection.ts @@ -534,37 +534,6 @@ export function $wrapNodesImpl( const blockOf = new Map(); const childOf = new Map(); const blockLeafParents = new Map(); - const firstNodeBlock = $wrappedBlock(firstNode, blockOf); - if (firstNodeBlock === null) { - return; - } - // For inline content directly in a block container, its run (see - // $inlineRun) stands for the block, and a block leaf there for itself. - let target: LexicalNode = firstNodeBlock; - if ($isBlockContainer(firstNodeBlock)) { - const child = $childIn(firstNodeBlock, firstNode, childOf); - target = INTERNAL_isInlineContent(child) ? $inlineRun(child)[0] : child; - } else if ($isBlockLeafInElement(firstNode, blockLeafParents)) { - target = firstNode; - } - - let targetIsPrevSibling = false; - while (target !== null) { - const prevSibling: LexicalNode | null = target.getPreviousSibling(); - - if (prevSibling !== null) { - target = prevSibling; - targetIsPrevSibling = true; - break; - } - - target = target.getParentOrThrow(); - - if ($isBlockContainer(target)) { - break; - } - } - const emptyElements = new Set(); // Find any top level empty elements @@ -626,6 +595,78 @@ export function $wrapNodesImpl( units.push(unit); } + // Direct text in a mixed block moves all of that block's children. Its + // nested blocks must not move separately, or the insertion anchor can + // move inside a new element and the children can change order (#9665). + const wholeBlocks = new Map(); + for (let i = 0; i < nodesLength; i++) { + const unit = units[i]; + if (unit !== null && unit.run === null && $isLeafNode(nodes[i])) { + wholeBlocks.set(unit.key, unit); + } + } + const coveringBlocks = new Map(); + const $coveringBlock = (start: LexicalNode): Unit | null => { + const walked: NodeKey[] = []; + let node: LexicalNode | null = start; + let covering: Unit | null = null; + while (node !== null && !$isBlockContainer(node)) { + const key = node.getKey(); + if (coveringBlocks.has(key)) { + covering = coveringBlocks.get(key) ?? null; + break; + } + walked.push(key); + node = node.getParent(); + } + for (let i = walked.length - 1; i >= 0; i--) { + const key = walked[i]; + covering = covering ?? wholeBlocks.get(key) ?? null; + coveringBlocks.set(key, covering); + } + return covering; + }; + for (let i = 0; i < nodesLength; i++) { + const unit = units[i]; + if (unit !== null) { + units[i] = $coveringBlock(unit.block) ?? unit; + } + if (emptyElements.has(nodes[i].getKey()) && $coveringBlock(nodes[i])) { + emptyElements.delete(nodes[i].getKey()); + } + } + + const firstUnit = units[nodes.indexOf(firstNode)]; + const firstNodeBlock = + firstUnit == null ? $wrappedBlock(firstNode, blockOf) : firstUnit.block; + if (firstNodeBlock === null) { + return; + } + // Choose the anchor from the whole unit that actually moves, not a + // descendant whose preceding sibling may move along with that unit. + let target: LexicalNode = + firstUnit != null && firstUnit.run !== null + ? firstUnit.run[0] + : firstNodeBlock; + if (firstUnit == null && $isBlockContainer(firstNodeBlock)) { + const child = $childIn(firstNodeBlock, firstNode, childOf); + target = INTERNAL_isInlineContent(child) ? $inlineRun(child)[0] : child; + } + + let targetIsPrevSibling = false; + while (target !== null) { + const prevSibling: LexicalNode | null = target.getPreviousSibling(); + if (prevSibling !== null) { + target = prevSibling; + targetIsPrevSibling = true; + break; + } + target = target.getParentOrThrow(); + if ($isBlockContainer(target)) { + break; + } + } + // Move out all leaf nodes into our elements array. // If we find a top level empty element, also move make // an element for that. From 8691480334f409988da1afc9081b20aeb993db22 Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:44:23 +0800 Subject: [PATCH 2/7] [lexical-selection] Chore: Cover mixed-block wrapping regressions ## Description Add forward and backward cases for the parent cycle and text reordering from #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. --- .../__tests__/unit/WrapNodesBlockLeaf.test.ts | 157 +++++++++++++++++- 1 file changed, 151 insertions(+), 6 deletions(-) diff --git a/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts b/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts index 9c830dce1eb..85a0dde91e2 100644 --- a/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts +++ b/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts @@ -22,7 +22,9 @@ import { $createRangeSelection, $createTextNode, $getRoot, + $getSelection, $isElementNode, + $isRangeSelection, $isTextNode, $setSelection, defineExtension, @@ -104,6 +106,25 @@ function outline(editor: LexicalEditor): { }); } +function expectAttachedSelection(editor: LexicalEditor): void { + editor.read(() => { + const selection = $getSelection(); + expect($isRangeSelection(selection)).toBe(true); + if ($isRangeSelection(selection)) { + for (const point of [selection.anchor, selection.focus]) { + const node = point.getNode(); + expect(node.isAttached()).toBe(true); + expect(point.offset).toBeGreaterThanOrEqual(0); + expect(point.offset).toBeLessThanOrEqual( + $isElementNode(node) + ? node.getChildrenSize() + : node.getTextContentSize(), + ); + } + } + }); +} + // Random documents of paragraphs, rules (adjacent ones too), boxes (nested // up to twice) and, with `directText`, text directly in a box, and random // ranges between text and element points: the documents where $wrapNodes @@ -340,12 +361,136 @@ describe('$wrapNodes with a block leaf in an element that is not a block contain expect(wrapFailures(false)).toEqual([]); }); - // With text directly in a box next to its blocks, $wrapNodes reorders text - // or never returns in some documents on main too (#9665): pinned until - // that is fixed. This change adds no failure there (main fails 8 of these - // 500 documents, 4 of them by never returning; this change fails 7, 3 by - // never returning, all of them among main's). - test.fails('property: with text directly in a box (#9665)', () => { + for (const backward of [false, true]) { + test(`a range ending inside a mixed box keeps its insertion anchor outside the moved content (${backward ? 'backward' : 'forward'})`, () => { + using editor = build(); + editor.update( + () => { + const first = $createParagraphNode().append($createTextNode('t1')); + const box = $create(BoxNode).append( + $createParagraphNode().append($createTextNode('t0')), + $create(BoxNode).append( + first, + $createParagraphNode().append($createTextNode('t2')), + $createTextNode('t3'), + ), + $createParagraphNode().append($createTextNode('t4')), + $createParagraphNode().append($createTextNode('t5')), + ); + $getRoot() + .clear() + .append(box, $createParagraphNode().append($createTextNode('t6'))); + const selection = $createRangeSelection(); + const [from, to] = backward + ? [selection.focus, selection.anchor] + : [selection.anchor, selection.focus]; + from.set(first.getKey(), 1, 'element'); + to.set(box.getKey(), 4, 'element'); + $setSelection(selection); + $wrapNodes(selection, () => $createQuoteNode()); + }, + {discrete: true}, + ); + expect(outline(editor).all).toEqual([ + 't0', + 't1', + 't2', + 't3', + 't4', + 't5', + 't6', + ]); + expectAttachedSelection(editor); + }); + + test(`a mixed box is not moved again after one of its blocks (${backward ? 'backward' : 'forward'})`, () => { + using editor = build(); + editor.update( + () => { + const first = $createTextNode('t0'); + const last = $createParagraphNode().append($createTextNode('t4')); + $getRoot() + .clear() + .append( + $create(BoxNode).append( + $createParagraphNode().append(first), + $createTextNode('t1'), + $create(BoxNode).append( + $createTextNode('t2'), + $createHorizontalRuleNode(), + $createParagraphNode().append($createTextNode('t3')), + last, + ), + ), + $createParagraphNode().append($createTextNode('t5')), + ); + const selection = $createRangeSelection(); + const [from, to] = backward + ? [selection.focus, selection.anchor] + : [selection.anchor, selection.focus]; + from.set(first.getKey(), 1, 'text'); + to.set(last.getKey(), 0, 'element'); + $setSelection(selection); + $wrapNodes(selection, () => $createQuoteNode()); + }, + {discrete: true}, + ); + expect(outline(editor).all).toEqual([ + 't0', + 't1', + 't2', + '
', + 't3', + 't4', + 't5', + ]); + expectAttachedSelection(editor); + }); + } + + test('a whole mixed block keeps its nested empty blocks and rules', () => { + using editor = build(); + let emptyParagraph: LexicalNode; + let emptyBox: LexicalNode; + editor.update( + () => { + const first = $createTextNode('first'); + const last = $createTextNode('last'); + emptyParagraph = $createParagraphNode(); + emptyBox = $create(BoxNode); + $getRoot() + .clear() + .append( + $create(BoxNode).append( + $createParagraphNode().append(first), + emptyParagraph, + $createTextNode('middle'), + emptyBox, + $createHorizontalRuleNode(), + $createParagraphNode().append(last), + ), + ); + const selection = $createRangeSelection(); + selection.anchor.set(first.getKey(), 0, 'text'); + selection.focus.set(last.getKey(), 4, 'text'); + $setSelection(selection); + $wrapNodes(selection, () => $createQuoteNode(), $create(BoxNode)); + }, + {discrete: true}, + ); + expect(outline(editor)).toEqual({ + all: ['first', 'middle', '
', 'last'], + quoted: ['first', 'middle', '
', 'last'], + }); + editor.read(() => { + expect(emptyParagraph.isAttached()).toBe(true); + expect(emptyBox.isAttached()).toBe(true); + expect(emptyParagraph.getParent()?.is(emptyBox.getParent())).toBe(true); + }); + expectAttachedSelection(editor); + }); + + test('property: with text directly in a box (#9665)', () => { expect(wrapFailures(true)).toEqual([]); }); }); From 0024dfcd94a697ff80dea725c60a900ded5b32f1 Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:46:00 +0800 Subject: [PATCH 3/7] [lexical-selection] Chore: Add a changeset for mixed-block wrapping ## Description Document the corrected wrapping behavior and the upgrade verification steps for #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. --- .changeset/tidy-box-wrap.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/tidy-box-wrap.md diff --git a/.changeset/tidy-box-wrap.md b/.changeset/tidy-box-wrap.md new file mode 100644 index 00000000000..07641459e68 --- /dev/null +++ b/.changeset/tidy-box-wrap.md @@ -0,0 +1,7 @@ +--- +'@lexical/selection': patch +--- + +[fix] Keep nested content in order when wrapping blocks that also hold text directly + +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. From be1edd82dfa5c3a6d36c79ac901957942fe93514 Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:28:00 +0800 Subject: [PATCH 4/7] [lexical-selection] Bug Fix: Preserve the anchor of an empty first block ## 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 0024dfcd 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. --- packages/lexical-selection/src/range-selection.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/lexical-selection/src/range-selection.ts b/packages/lexical-selection/src/range-selection.ts index 97e6f9bd80c..dfa4c45d3e9 100644 --- a/packages/lexical-selection/src/range-selection.ts +++ b/packages/lexical-selection/src/range-selection.ts @@ -636,7 +636,9 @@ export function $wrapNodesImpl( } } - const firstUnit = units[nodes.indexOf(firstNode)]; + const firstUnit = emptyElements.has(firstNode.getKey()) + ? null + : units[nodes.indexOf(firstNode)]; const firstNodeBlock = firstUnit == null ? $wrappedBlock(firstNode, blockOf) : firstUnit.block; if (firstNodeBlock === null) { From f9f800bcc3b9034804b427e41f6352157c9c250c Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:29:20 +0800 Subject: [PATCH 5/7] [lexical-selection] Chore: Cover selections beginning on empty blocks ## 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 0024dfcd 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. --- .../__tests__/unit/WrapNodesBlockLeaf.test.ts | 110 +++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts b/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts index 85a0dde91e2..37fcadc0d42 100644 --- a/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts +++ b/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts @@ -14,6 +14,12 @@ import { buildEditorFromExtensions, HorizontalRuleExtension, } from '@lexical/extension'; +import { + $createListItemNode, + $createListNode, + ListItemNode, + ListNode, +} from '@lexical/list'; import {$createQuoteNode, QuoteNode} from '@lexical/rich-text'; import {$wrapNodes} from '@lexical/selection'; import { @@ -72,7 +78,7 @@ function build() { defineExtension({ dependencies: [HorizontalRuleExtension], name: 'wrap-block-leaf', - nodes: [BoxNode, QuoteNode], + nodes: [BoxNode, QuoteNode, ListNode, ListItemNode], }), ); } @@ -448,6 +454,108 @@ describe('$wrapNodes with a block leaf in an element that is not a block contain }); } + for (const backward of [false, true]) { + for (const kind of ['box', 'list'] as const) { + test(`an empty first block keeps its preceding siblings in a ${kind} (${backward ? 'backward' : 'forward'})`, () => { + using editor = build(); + let container: ElementNode; + let first: ElementNode; + editor.update( + () => { + const createBlock = + kind === 'list' ? $createListItemNode : $createParagraphNode; + first = createBlock().append($createTextNode('t1')); + const empty = createBlock(); + const last = $createTextNode('t3'); + container = + kind === 'list' ? $createListNode('bullet') : $create(BoxNode); + $getRoot() + .clear() + .append( + $createParagraphNode().append($createTextNode('t0')), + container.append(first, empty, createBlock().append(last)), + ); + const selection = $createRangeSelection(); + const [from, to] = backward + ? [selection.focus, selection.anchor] + : [selection.anchor, selection.focus]; + from.set(empty.getKey(), 0, 'element'); + to.set(last.getKey(), 2, 'text'); + $setSelection(selection); + $wrapNodes(selection, () => $createQuoteNode()); + }, + {discrete: true}, + ); + expect(outline(editor)).toEqual({ + all: ['t0', 't1', 't3'], + quoted: ['t3'], + }); + editor.read(() => { + const children = $getRoot().getChildren(); + expect(children[1].is(container)).toBe(true); + const siblings = + kind === 'list' ? children.slice(1) : container.getChildren(); + expect(siblings.map(node => node.getType())).toEqual([ + kind === 'list' ? 'list' : 'paragraph', + 'quote', + 'quote', + ]); + if (kind === 'list') { + expect(container.getChildren().map(node => node.getKey())).toEqual([ + first.getKey(), + ]); + } else { + expect(siblings[0].is(first)).toBe(true); + } + expect(siblings[1].getTextContent()).toBe(''); + expect(siblings[2].getTextContent()).toBe('t3'); + }); + expectAttachedSelection(editor); + }); + } + + test(`a covered empty first block moves with its whole mixed block (${backward ? 'backward' : 'forward'})`, () => { + using editor = build(); + let empty: ElementNode; + let first: ElementNode; + let last: ElementNode; + editor.update( + () => { + first = $createParagraphNode().append($createTextNode('tA')); + empty = $createParagraphNode(); + last = $createParagraphNode().append($createTextNode('tB')); + const direct = $createTextNode('tC'); + $getRoot() + .clear() + .append($create(BoxNode).append(first, empty, last, direct)); + const selection = $createRangeSelection(); + const [from, to] = backward + ? [selection.focus, selection.anchor] + : [selection.anchor, selection.focus]; + from.set(empty.getKey(), 0, 'element'); + to.set(direct.getKey(), 2, 'text'); + $setSelection(selection); + $wrapNodes(selection, () => $createQuoteNode()); + }, + {discrete: true}, + ); + expect(outline(editor)).toEqual({ + all: ['tA', 'tB', 'tC'], + quoted: ['tA', 'tB', 'tC'], + }); + editor.read(() => { + const quote = $getRoot().getFirstChildOrThrow(); + expect(quote.getType()).toBe('quote'); + expect($getRoot().getChildrenSize()).toBe(1); + expect(empty.isAttached()).toBe(true); + expect(empty.getParent()?.is(quote)).toBe(true); + expect(empty.getPreviousSibling()?.is(first)).toBe(true); + expect(empty.getNextSibling()?.is(last)).toBe(true); + }); + expectAttachedSelection(editor); + }); + } + test('a whole mixed block keeps its nested empty blocks and rules', () => { using editor = build(); let emptyParagraph: LexicalNode; From 669b8752c02f3216ad6ccb05c6569dd2cffe770b Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:30:06 +0800 Subject: [PATCH 6/7] [lexical-selection] Chore: Name $wrapNodes in the changeset summary ## 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. --- .changeset/tidy-box-wrap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tidy-box-wrap.md b/.changeset/tidy-box-wrap.md index 07641459e68..52f7ced6edf 100644 --- a/.changeset/tidy-box-wrap.md +++ b/.changeset/tidy-box-wrap.md @@ -2,6 +2,6 @@ '@lexical/selection': patch --- -[fix] Keep nested content in order when wrapping blocks that also hold text directly +[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. From b25d6102e854e5c34cf49dcf1067f78467f7fea9 Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Mon, 2 Nov 2026 00:44:51 +0800 Subject: [PATCH 7/7] [lexical-selection] Documentation Update: Clarify nested wrapping verification ## Description Clarify the changeset verification step requested in review: nested blocks, including empty blocks, stay inside one new wrapper instead of becoming separate sibling wrappers. The package, release type, runtime and tests are unchanged. ## Test plan ### Before Reviewed the existing Verify paragraph; it covered content order and attached selection but omitted the wrapper shape. ### After Reviewed the rendered changeset in the GitHub editor. It retains the @lexical/selection patch entry and adds the wrapper-shape check to the same Verify paragraph. No runtime tests were rerun for this documentation-only change; the existing regression results in the PR description remain the validation evidence for the implementation. --- .changeset/tidy-box-wrap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tidy-box-wrap.md b/.changeset/tidy-box-wrap.md index 52f7ced6edf..0c0be803b9d 100644 --- a/.changeset/tidy-box-wrap.md +++ b/.changeset/tidy-box-wrap.md @@ -4,4 +4,4 @@ [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. +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. Nested blocks, including empty blocks, should stay inside one new wrapper rather than becoming separate sibling wrappers.