diff --git a/.changeset/selection-wrap-block-leaf.md b/.changeset/selection-wrap-block-leaf.md
new file mode 100644
index 00000000000..597c65ddff9
--- /dev/null
+++ b/.changeset/selection-wrap-block-leaf.md
@@ -0,0 +1,7 @@
+---
+'@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
+
+Verify: in a box element that declares no structure, holding "aa", "bb", a horizontal rule and "cc", select from inside "bb" to the end of "cc" and wrap in quotes: the call returns, "bb", the rule and "cc" are in quotes, and "aa" is untouched.
diff --git a/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts b/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts
new file mode 100644
index 00000000000..9c830dce1eb
--- /dev/null
+++ b/packages/lexical-selection/src/__tests__/unit/WrapNodesBlockLeaf.test.ts
@@ -0,0 +1,351 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+// @vitest-environment node
+
+import {
+ $createHorizontalRuleNode,
+ $isHorizontalRuleNode,
+ buildEditorFromExtensions,
+ HorizontalRuleExtension,
+} from '@lexical/extension';
+import {$createQuoteNode, QuoteNode} from '@lexical/rich-text';
+import {$wrapNodes} from '@lexical/selection';
+import {
+ $create,
+ $createParagraphNode,
+ $createRangeSelection,
+ $createTextNode,
+ $getRoot,
+ $isElementNode,
+ $isTextNode,
+ $setSelection,
+ defineExtension,
+ ElementNode,
+ type LexicalEditor,
+ type LexicalNode,
+ type PointType,
+} from 'lexical';
+import {afterEach, beforeEach, describe, expect, test} from 'vitest';
+
+// #9638: a block leaf (a horizontal rule) in an element that is not a block
+// container (a box that declares no structure): $wrapNodes took the box for
+// the rule's block, moved all the box's children into one new element, those
+// before the range included, and put the new elements next to one of them,
+// inside that new element. The update never returned. A block leaf there is
+// now wrapped on its own, as in a block container.
+
+/** A plain block element that holds blocks, with no declared structure. */
+class BoxNode extends ElementNode {
+ $config() {
+ return this.config('test_wrap_box', {extends: ElementNode});
+ }
+}
+
+// A cycle in the tree makes the update walk parents forever: a guard turns
+// that into an error, so a hang fails the test instead of the run.
+const LIMIT = 1_000_000;
+let parentCalls = 0;
+const originalGetParent = ElementNode.prototype.getParent;
+beforeEach(() => {
+ parentCalls = 0;
+ ElementNode.prototype.getParent = function (this: ElementNode) {
+ if (++parentCalls > LIMIT) {
+ throw new Error('getParent called more than a million times: a cycle');
+ }
+ return originalGetParent.call(this);
+ } as typeof ElementNode.prototype.getParent;
+});
+afterEach(() => {
+ ElementNode.prototype.getParent = originalGetParent;
+});
+
+function build() {
+ return buildEditorFromExtensions(
+ defineExtension({
+ dependencies: [HorizontalRuleExtension],
+ name: 'wrap-block-leaf',
+ nodes: [BoxNode, QuoteNode],
+ }),
+ );
+}
+
+/** The texts and rules in document order, and the quotes' texts. */
+function outline(editor: LexicalEditor): {
+ all: string[];
+ quoted: string[];
+} {
+ return editor.read(() => {
+ const all: string[] = [];
+ const quoted: string[] = [];
+ const walk = (node: LexicalNode, inQuote: boolean) => {
+ if ($isTextNode(node)) {
+ all.push(node.getTextContent());
+ if (inQuote) {
+ quoted.push(node.getTextContent());
+ }
+ } else if ($isHorizontalRuleNode(node)) {
+ all.push('
');
+ if (inQuote) {
+ quoted.push('
');
+ }
+ } else if ($isElementNode(node)) {
+ const quote = inQuote || node.getType() === 'quote';
+ node.getChildren().forEach(child => walk(child, quote));
+ }
+ };
+ walk($getRoot(), false);
+ return {all, quoted};
+ });
+}
+
+// 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
+// throws (a parent cycle: it would never return) or does not keep every text
+// in order.
+function wrapFailures(directText: boolean): string[] {
+ let seed = 9638;
+ const random = () => {
+ seed = (seed * 1103515245 + 12345) % 2147483648;
+ return seed / 2147483648;
+ };
+ const failures: string[] = [];
+ for (let run = 0; run < 500; run++) {
+ parentCalls = 0;
+ using editor = build();
+ let n = 0;
+ const $children = (depth: number): LexicalNode[] => {
+ const out: LexicalNode[] = [];
+ const count = 1 + Math.floor(random() * 4);
+ for (let i = 0; i < count; i++) {
+ const r = random();
+ if (r < 0.25) {
+ out.push($createHorizontalRuleNode());
+ } else if (r < 0.4 && depth < 2) {
+ out.push($create(BoxNode).append(...$children(depth + 1)));
+ } else if (directText && r < 0.55 && depth > 0) {
+ // Text directly in a box, next to its blocks.
+ out.push($createTextNode(`t${n++}`));
+ } else {
+ out.push($createParagraphNode().append($createTextNode(`t${n++}`)));
+ }
+ }
+ return out;
+ };
+ let before: string[] = [];
+ let error: unknown = null;
+ try {
+ editor.update(
+ () => {
+ $getRoot()
+ .clear()
+ .append(...$children(0));
+ before = $getRoot()
+ .getAllTextNodes()
+ .map(t => t.getTextContent());
+ const points: [string, number, 'text' | 'element'][] = [];
+ const collect = (node: LexicalNode) => {
+ if ($isTextNode(node)) {
+ for (let o = 0; o <= node.getTextContentSize(); o++) {
+ points.push([node.getKey(), o, 'text']);
+ }
+ } else if ($isElementNode(node)) {
+ for (let o = 0; o <= node.getChildrenSize(); o++) {
+ points.push([node.getKey(), o, 'element']);
+ }
+ node.getChildren().forEach(collect);
+ }
+ };
+ collect($getRoot());
+ const a = points[Math.floor(random() * points.length)];
+ const b = points[Math.floor(random() * points.length)];
+ const selection = $createRangeSelection();
+ (selection.anchor as PointType).set(...a);
+ (selection.focus as PointType).set(...b);
+ $setSelection(selection);
+ $wrapNodes(selection, () => $createQuoteNode());
+ },
+ {discrete: true},
+ );
+ } catch (e) {
+ error = e;
+ }
+ const after = outline(editor).all.filter(t => t !== '
');
+ // Adjacent texts may merge: compare the text in order.
+ if (error !== null || after.join('') !== before.join('')) {
+ failures.push(`run ${run}: ${String(error ?? after.join())}`);
+ }
+ }
+ return failures;
+}
+
+describe('$wrapNodes with a block leaf in an element that is not a block container (#9638)', () => {
+ test('a range across a horizontal rule in a box keeps every paragraph', () => {
+ using editor = build();
+ editor.update(
+ () => {
+ const bb = $createTextNode('bb');
+ const cc = $createTextNode('cc');
+ $getRoot()
+ .clear()
+ .append(
+ $create(BoxNode).append(
+ $createParagraphNode().append($createTextNode('aa')),
+ $createParagraphNode().append(bb),
+ $createHorizontalRuleNode(),
+ $createParagraphNode().append(cc),
+ ),
+ );
+ const selection = $createRangeSelection();
+ selection.anchor.set(bb.getKey(), 1, 'text');
+ selection.focus.set(cc.getKey(), 2, 'text');
+ $setSelection(selection);
+ $wrapNodes(selection, () => $createQuoteNode());
+ },
+ {discrete: true},
+ );
+ expect(outline(editor)).toEqual({
+ all: ['aa', 'bb', '
', 'cc'],
+ quoted: ['bb', '
', 'cc'],
+ });
+ // The rule stays in the box, next to the paragraphs.
+ expect(
+ editor.read(() =>
+ $getRoot()
+ .getChildren()
+ .map(n => n.getType()),
+ ),
+ ).toEqual(['test_wrap_box']);
+ });
+
+ test('a range that starts at the rule', () => {
+ using editor = build();
+ editor.update(
+ () => {
+ const cc = $createTextNode('cc');
+ const box = $create(BoxNode).append(
+ $createParagraphNode().append($createTextNode('aa')),
+ $createHorizontalRuleNode(),
+ $createParagraphNode().append(cc),
+ );
+ $getRoot().clear().append(box);
+ const selection = $createRangeSelection();
+ selection.anchor.set(box.getKey(), 1, 'element');
+ selection.focus.set(cc.getKey(), 2, 'text');
+ $setSelection(selection);
+ $wrapNodes(selection, () => $createQuoteNode());
+ },
+ {discrete: true},
+ );
+ expect(outline(editor)).toEqual({
+ all: ['aa', '
', 'cc'],
+ quoted: ['
', 'cc'],
+ });
+ });
+
+ // A box that holds text directly is the block of that text and of the
+ // rules next to it: its children move together, as before.
+ for (const backward of [false, true]) {
+ test(`a box with text and two rules in it, from the second rule (${backward ? 'backward' : 'forward'})`, () => {
+ using editor = build();
+ editor.update(
+ () => {
+ const cc = $createTextNode('cc');
+ const box = $create(BoxNode).append(
+ $createTextNode('aa'),
+ $createHorizontalRuleNode(),
+ $createHorizontalRuleNode(),
+ cc,
+ );
+ $getRoot().clear().append(box);
+ const selection = $createRangeSelection();
+ const [from, to] = backward
+ ? [selection.focus, selection.anchor]
+ : [selection.anchor, selection.focus];
+ from.set(box.getKey(), 2, 'element');
+ to.set(cc.getKey(), 2, 'text');
+ $setSelection(selection);
+ $wrapNodes(selection, () => $createQuoteNode());
+ },
+ {discrete: true},
+ );
+ expect(outline(editor).all).toEqual(['aa', '
', '
', 'cc']);
+ });
+ }
+
+ test('control: the same range without the rule', () => {
+ using editor = build();
+ editor.update(
+ () => {
+ const bb = $createTextNode('bb');
+ const cc = $createTextNode('cc');
+ $getRoot()
+ .clear()
+ .append(
+ $create(BoxNode).append(
+ $createParagraphNode().append($createTextNode('aa')),
+ $createParagraphNode().append(bb),
+ $createParagraphNode().append(cc),
+ ),
+ );
+ const selection = $createRangeSelection();
+ selection.anchor.set(bb.getKey(), 1, 'text');
+ selection.focus.set(cc.getKey(), 2, 'text');
+ $setSelection(selection);
+ $wrapNodes(selection, () => $createQuoteNode());
+ },
+ {discrete: true},
+ );
+ expect(outline(editor)).toEqual({
+ all: ['aa', 'bb', 'cc'],
+ quoted: ['bb', 'cc'],
+ });
+ });
+
+ test('control: a rule at the root', () => {
+ using editor = build();
+ editor.update(
+ () => {
+ const bb = $createTextNode('bb');
+ const cc = $createTextNode('cc');
+ $getRoot()
+ .clear()
+ .append(
+ $createParagraphNode().append($createTextNode('aa')),
+ $createParagraphNode().append(bb),
+ $createHorizontalRuleNode(),
+ $createParagraphNode().append(cc),
+ );
+ const selection = $createRangeSelection();
+ selection.anchor.set(bb.getKey(), 1, 'text');
+ selection.focus.set(cc.getKey(), 2, 'text');
+ $setSelection(selection);
+ $wrapNodes(selection, () => $createQuoteNode());
+ },
+ {discrete: true},
+ );
+ expect(outline(editor)).toEqual({
+ all: ['aa', 'bb', '
', 'cc'],
+ quoted: ['bb', '
', 'cc'],
+ });
+ });
+
+ test('property: random box, rule and paragraph mixes', () => {
+ 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)', () => {
+ expect(wrapFailures(true)).toEqual([]);
+ });
+});
diff --git a/packages/lexical-selection/src/range-selection.ts b/packages/lexical-selection/src/range-selection.ts
index a0f2e1b8d72..b809a20e95a 100644
--- a/packages/lexical-selection/src/range-selection.ts
+++ b/packages/lexical-selection/src/range-selection.ts
@@ -285,6 +285,41 @@ function $wrappedBlock(
return found;
}
+/**
+ * A leaf that is a block of its own (a block decorator, such as a horizontal
+ * rule) in an element that is not a block container but holds blocks only
+ * (a box that declares no structure): it is wrapped on its own, as it is in a
+ * block container. Why: taking its parent for its block moved all the
+ * parent's children into one new element, those before the range included,
+ * and the new elements then went next to one of them, inside that new
+ * element: the update never returned (#9638). Where the parent also holds
+ * inline content directly, the parent is the block of that content, and of
+ * the leaf, as before: its children move together, and the new elements go
+ * where it is. `cache` holds the answer for each parent already looked at.
+ */
+function $isBlockLeafInElement(
+ node: LexicalNode,
+ cache: Map = new Map(),
+): boolean {
+ const parent = node.getParent();
+ if (
+ parent === null ||
+ !$isLeafNode(node) ||
+ INTERNAL_isInlineContent(node) ||
+ $isBlockContainer(parent)
+ ) {
+ return false;
+ }
+ let blocksOnly = cache.get(parent.getKey());
+ if (blocksOnly === undefined) {
+ blocksOnly = parent
+ .getChildren()
+ .every(child => !INTERNAL_isInlineContent(child));
+ cache.set(parent.getKey(), blocksOnly);
+ }
+ return blocksOnly;
+}
+
/** An empty element that is not inline content. */
function $isEmptyBlock(node: LexicalNode): node is ElementNode {
return (
@@ -498,6 +533,7 @@ export function $wrapNodesImpl(
// structures.
const blockOf = new Map();
const childOf = new Map();
+ const blockLeafParents = new Map();
const firstNodeBlock = $wrappedBlock(firstNode, blockOf);
if (firstNodeBlock === null) {
return;
@@ -508,6 +544,8 @@ export function $wrapNodesImpl(
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;
@@ -556,6 +594,13 @@ export function $wrapNodesImpl(
const runOf = new Map();
for (let i = 0; i < nodesLength; i++) {
const nodeParent = nodes[i].getParent();
+ if (
+ nodeParent !== null &&
+ $isBlockLeafInElement(nodes[i], blockLeafParents)
+ ) {
+ units.push({block: nodeParent, key: nodes[i].getKey(), run: [nodes[i]]});
+ continue;
+ }
const block =
nodeParent === null ? null : $wrappedBlock(nodeParent, blockOf);
if (block === null || !$isBlockContainer(block)) {