From 06b1032a3c52f235bac1155173e9e5261b3bb3d2 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 17 Jun 2026 06:40:47 -0700 Subject: [PATCH] [lexical] Bug Fix: Insert nodes at the block cursor inside a shadow root (#8708) Co-authored-by: Claude --- packages/lexical/src/LexicalSelection.ts | 33 ++- .../__tests__/unit/LexicalSelection.test.ts | 227 ++++++++++++++++++ 2 files changed, 249 insertions(+), 11 deletions(-) diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 31301ff685f..c9f4b6f4ded 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -1406,15 +1406,6 @@ export class RangeSelection implements BaseSelection { if (!this.isCollapsed()) { this.removeText(); } - if (this.anchor.key === 'root') { - this.insertParagraph(); - const selection = $getSelection(); - invariant( - $isRangeSelection(selection), - 'Expected RangeSelection after insertParagraph', - ); - return selection.insertNodes(nodes); - } // @experimental named-slots. Anchor on a slot value root (e.g. after a // slot-scoped Cmd+A leaves the selection on the slot's element point) // has __parent === null, so the block-finding walk below would throw. @@ -1448,6 +1439,25 @@ export class RangeSelection implements BaseSelection { } } + // The anchor is an element point directly on a root or shadow root that is + // not a named-slot host (handled above). This includes the document root + // (e.g. an empty editor) and shadow roots that hold block-level children + // directly — for instance the block cursor between or after the children of + // a decorator-only container or the playground CollapsibleContentNode. + // Roots and shadow roots hold blocks (and shadow roots) directly, so splice + // the nodes in at the anchor offset: a block node (such as a pasted + // DecoratorNode) goes in as-is, while inline runs are wrapped in a block + // first since a root/shadow root cannot contain inline children. + if (this.anchor.type === 'element' && $isRootOrShadowRoot(anchorNode)) { + const blocksParent = $wrapInlineNodes(nodes); + const nodeToSelect = blocksParent.getLastDescendant(); + anchorNode.splice(this.anchor.offset, 0, blocksParent.getChildren()); + if (nodeToSelect !== null) { + nodeToSelect.selectEnd(); + } + return; + } + const firstPoint = this.isBackward() ? this.focus : this.anchor; const firstNode = firstPoint.getNode(); const firstBlock = $findMatchingParent(firstNode, INTERNAL_$isBlock); @@ -1570,9 +1580,10 @@ export class RangeSelection implements BaseSelection { * @returns the newly inserted node. */ insertParagraph(): ElementNode | null { - if (this.anchor.key === 'root') { + const anchorNode = this.anchor.getNode(); + if (this.anchor.type === 'element' && $isRootOrShadowRoot(anchorNode)) { const paragraph = $createParagraphNode(); - $getRoot().splice(this.anchor.offset, 0, [paragraph]); + anchorNode.splice(this.anchor.offset, 0, [paragraph]); paragraph.select(); return paragraph; } diff --git a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts index 131b45cdcf3..d8fed8c594b 100644 --- a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts @@ -23,7 +23,9 @@ import { $getCaretInDirection, $getRoot, $getSelection, + $isDecoratorNode, $isParagraphNode, + $isRangeSelection, $isTextNode, $selectAll, $setSelection, @@ -43,6 +45,8 @@ import { $assertRangeSelection, $createTestDecoratorNode, $createTestInlineElementNode, + $createTestShadowRootNode, + $isTestShadowRootNode, initializeUnitTest, invariant, } from '../utils'; @@ -874,6 +878,229 @@ describe('Regression tests for #6701', () => { }); }); +describe('Regression tests for #8707', () => { + // A shadow root that holds block-level children directly (e.g. a + // decorator-only container). Placing the caret adjacent to a block child + // shows the block cursor, whose RangeSelection is a collapsed element point + // on the shadow root itself. Inserting block-level content there (such as + // pasting a copied decorator) used to throw because a shadow root has no + // block ancestor to split. Roots and shadow roots hold blocks directly, so + // the block goes straight in at the anchor offset with no paragraph wrapper. + initializeUnitTest(testEnv => { + test('inserts a block decorator after the block cursor at the end of a shadow root', () => { + const {editor} = testEnv; + let shadowKey = ''; + editor.update( + () => { + const shadow = $createTestShadowRootNode(); + shadow.append($createTestDecoratorNode().setIsInline(false)); + $getRoot().clear().append(shadow); + shadowKey = shadow.getKey(); + // Block cursor: collapsed element point after the decorator. + shadow.select(1, 1); + }, + {discrete: true}, + ); + + editor.update( + () => { + const selection = $getSelection(); + invariant($isRangeSelection(selection), 'Expected RangeSelection'); + selection.insertNodes([ + $createTestDecoratorNode().setIsInline(false), + ]); + }, + {discrete: true}, + ); + + editor.read(() => { + const root = $getRoot(); + // The decorator landed in the same shadow root as the block cursor, + // not the outer document root. + expect(root.getChildrenSize()).toBe(1); + const shadow = root.getFirstChildOrThrow(); + invariant($isTestShadowRootNode(shadow), 'Expected shadow root'); + expect(shadow.getKey()).toBe(shadowKey); + const children = shadow.getChildren(); + expect(children).toHaveLength(2); + expect(children.every($isDecoratorNode)).toBe(true); + }); + }); + + test('inserts a block decorator before the block cursor at the start of a shadow root', () => { + const {editor} = testEnv; + let existingKey = ''; + editor.update( + () => { + const shadow = $createTestShadowRootNode(); + const existing = $createTestDecoratorNode().setIsInline(false); + shadow.append(existing); + $getRoot().clear().append(shadow); + existingKey = existing.getKey(); + // Block cursor: collapsed element point before the decorator. + shadow.select(0, 0); + }, + {discrete: true}, + ); + + editor.update( + () => { + const selection = $getSelection(); + invariant($isRangeSelection(selection), 'Expected RangeSelection'); + selection.insertNodes([ + $createTestDecoratorNode().setIsInline(false), + ]); + }, + {discrete: true}, + ); + + editor.read(() => { + const shadow = $getRoot().getFirstChildOrThrow(); + invariant($isTestShadowRootNode(shadow), 'Expected shadow root'); + const children = shadow.getChildren(); + expect(children).toHaveLength(2); + expect(children.every($isDecoratorNode)).toBe(true); + // Inserted before the pre-existing decorator. + expect(children[1].getKey()).toBe(existingKey); + }); + }); + + test('inserts a block decorator into an empty shadow root', () => { + const {editor} = testEnv; + editor.update( + () => { + const shadow = $createTestShadowRootNode(); + $getRoot().clear().append(shadow); + shadow.select(0, 0); + }, + {discrete: true}, + ); + + editor.update( + () => { + const selection = $getSelection(); + invariant($isRangeSelection(selection), 'Expected RangeSelection'); + selection.insertNodes([ + $createTestDecoratorNode().setIsInline(false), + ]); + }, + {discrete: true}, + ); + + editor.read(() => { + const shadow = $getRoot().getFirstChildOrThrow(); + invariant($isTestShadowRootNode(shadow), 'Expected shadow root'); + const children = shadow.getChildren(); + expect(children).toHaveLength(1); + expect($isDecoratorNode(children[0])).toBe(true); + }); + }); + + test('inserts a block element at the block cursor inside a shadow root', () => { + const {editor} = testEnv; + editor.update( + () => { + const shadow = $createTestShadowRootNode(); + shadow.append($createTestDecoratorNode().setIsInline(false)); + $getRoot().clear().append(shadow); + shadow.select(1, 1); + }, + {discrete: true}, + ); + + editor.update( + () => { + const selection = $getSelection(); + invariant($isRangeSelection(selection), 'Expected RangeSelection'); + selection.insertNodes([ + $createParagraphNode().append($createTextNode('inserted')), + ]); + }, + {discrete: true}, + ); + + editor.read(() => { + const shadow = $getRoot().getFirstChildOrThrow(); + invariant($isTestShadowRootNode(shadow), 'Expected shadow root'); + const children = shadow.getChildren(); + expect(children).toHaveLength(2); + expect($isDecoratorNode(children[0])).toBe(true); + expect($isParagraphNode(children[1])).toBe(true); + expect(children[1].getTextContent()).toBe('inserted'); + }); + }); + + test('insertParagraph at an element point on a shadow root seeds into that shadow root', () => { + const {editor} = testEnv; + editor.update( + () => { + const shadow = $createTestShadowRootNode(); + shadow.append($createTestDecoratorNode().setIsInline(false)); + $getRoot().clear().append(shadow); + shadow.select(1, 1); + }, + {discrete: true}, + ); + + editor.update( + () => { + const selection = $getSelection(); + invariant($isRangeSelection(selection), 'Expected RangeSelection'); + const paragraph = selection.insertParagraph(); + invariant(paragraph !== null, 'Expected a paragraph to be inserted'); + expect(paragraph.getParent()!.is($getRoot().getFirstChild())).toBe( + true, + ); + }, + {discrete: true}, + ); + + editor.read(() => { + const root = $getRoot(); + expect(root.getChildrenSize()).toBe(1); + const shadow = root.getFirstChildOrThrow(); + invariant($isTestShadowRootNode(shadow), 'Expected shadow root'); + expect(shadow.getChildrenSize()).toBe(2); + expect($isParagraphNode(shadow.getLastChild())).toBe(true); + }); + }); + + test('inserts a block decorator at a root element point without wrapping it in a paragraph', () => { + const {editor} = testEnv; + editor.update( + () => { + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode('existing'))); + // Element point directly on the root, after the paragraph. + $getRoot().select(1, 1); + }, + {discrete: true}, + ); + + editor.update( + () => { + const selection = $getSelection(); + invariant($isRangeSelection(selection), 'Expected RangeSelection'); + selection.insertNodes([ + $createTestDecoratorNode().setIsInline(false), + ]); + }, + {discrete: true}, + ); + + editor.read(() => { + const children = $getRoot().getChildren(); + // The decorator is a direct child of root; no empty paragraph wrapper + // was created for it. + expect(children).toHaveLength(2); + expect($isParagraphNode(children[0])).toBe(true); + expect($isDecoratorNode(children[1])).toBe(true); + }); + }); + }); +}); + describe('getNodes()', () => { initializeUnitTest(testEnv => { let paragraphNode: ParagraphNode;