From a948a118808354f2adea17c6421ba7a8d0b302b3 Mon Sep 17 00:00:00 2001 From: Gunjan Jaswal Date: Mon, 27 Jul 2026 18:47:49 +0530 Subject: [PATCH 1/2] fix: collapse to paragraph when select-all delete empties a non-paragraph first block When a range deletion removes all root content and the surviving block is a non-paragraph element block (list item, heading, quote, or nested list), it lingered empty while keeping its element type instead of collapsing to a plain paragraph. Add a tight special-case in $removeTextFromCaretRange: once the removal settles, if the sole remaining root content is a single empty non-paragraph block whose ancestor chain to the root is a single empty branch, replace that top-level subtree with a fresh empty paragraph. Shadow roots, decorators, and any sibling content bail out so the shared deletion path is otherwise untouched. Closes #5835 --- .../lexical/src/caret/LexicalCaretUtils.ts | 69 +++++++- .../caret/__tests__/unit/LexicalCaret.test.ts | 158 +++++++++++++++++- 2 files changed, 224 insertions(+), 3 deletions(-) diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index b91166e9f26..b4775abe273 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -27,7 +27,10 @@ import { INTERNAL_$isBlock, } from '../LexicalUtils'; import {$isElementNode, type ElementNode} from '../nodes/LexicalElementNode'; -import {$createParagraphNode} from '../nodes/LexicalParagraphNode'; +import { + $createParagraphNode, + $isParagraphNode, +} from '../nodes/LexicalParagraphNode'; import {$isRootNode} from '../nodes/LexicalRootNode'; import { $createTextNode, @@ -415,8 +418,15 @@ export function $removeTextFromCaretRange( ...focusCandidates, ].find($isCaretAttached); if (bestCandidate) { + const normalizedBest = $normalizeCaret(bestCandidate); + // Special-case select-all + delete: when the removal emptied the entire + // root and the sole surviving block is a non-paragraph element block (a + // list item, heading, quote, ...), it would otherwise linger empty while + // keeping its type. Replace it with a fresh paragraph so the document + // collapses to a single empty paragraph, matching an empty editor. + const collapsedCaret = $collapseEmptiedRootToParagraph(normalizedBest); const anchor = $getCaretInDirection( - $normalizeCaret(bestCandidate), + collapsedCaret || normalizedBest, initialRange.direction, ); return $getCollapsedCaretRange(anchor); @@ -428,6 +438,61 @@ export function $removeTextFromCaretRange( ); } +/** + * Handle the select-all + delete edge case where the removal emptied the whole + * root but left behind a single non-paragraph block (list item, heading, + * quote, ...) that survives empty while keeping its element type. + * + * The guard is deliberately tight: this only fires when the caret's block is an + * empty, non-paragraph normal element block whose ancestor chain up to the root + * is a single, empty branch (so the root's only content is this one block). + * Any shadow root, decorator, or sibling content in the chain bails out, so the + * shared deletion path is otherwise untouched. + * + * @returns a caret at the start of the replacement paragraph, or null if the + * special case does not apply and the original caret should be used. + */ +function $collapseEmptiedRootToParagraph( + caret: PointCaret, +): null | ChildCaret { + const block = $getBlockFromCaret($getCaretInDirection(caret, 'next')); + if ( + !block || + !$isElementNode(block) || + $isParagraphNode(block) || + !block.isEmpty() + ) { + return null; + } + // Walk up to the top-level block (the direct child of the root), bailing on + // anything that means this is not a plain, fully-emptied document. + let topLevel: ElementNode = block; + for ( + let parent = block.getParent(); + parent !== null && !$isRootNode(parent); + parent = parent.getParent() + ) { + if ( + $isRootOrShadowRoot(parent) || + !$isElementNode(parent) || + parent.getChildrenSize() !== 1 + ) { + return null; + } + topLevel = parent; + } + const root = topLevel.getParent(); + if (!$isRootNode(root) || root.getChildrenSize() !== 1) { + return null; + } + const paragraph = $createParagraphNode(); + topLevel.insertBefore(paragraph); + // Use remove() (not replace()) so any selection still resolving inside the + // removed subtree is moved out to the parent rather than left dangling. + topLevel.remove(); + return $getChildCaret(paragraph, 'next'); +} + function $getBlockFromCaret( caret: PointCaret, ): ElementNode | null { diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index 53ff3f34596..ad66f7346b7 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -14,7 +14,11 @@ import { type ListNode, } from '@lexical/list'; import {$createMarkNode} from '@lexical/mark'; -import {$createHeadingNode, $isHeadingNode} from '@lexical/rich-text'; +import { + $createHeadingNode, + $createQuoteNode, + $isHeadingNode, +} from '@lexical/rich-text'; import { $createTableCellNode, $createTableNode, @@ -757,6 +761,158 @@ describe('LexicalCaret', () => { ); }); }); + describe('select-all + delete collapses to an empty paragraph (#5835)', () => { + test('heading as the first (and only) block', () => { + testEnv.editor.update( + () => { + $getRoot() + .clear() + .append($createHeadingNode('h1').append($createTextNode('hi'))); + const resultRange = $removeTextFromCaretRange( + $caretRangeFromSelection($selectAll()), + ); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(1); + invariant( + $isParagraphNode(children[0]), + 'Expected a ParagraphNode', + ); + expect(children[0].isEmpty()).toBe(true); + expect($getRoot().getAllTextNodes()).toEqual([]); + expect(resultRange).toMatchObject({ + anchor: {direction: 'next', origin: children[0], type: 'child'}, + }); + }, + {discrete: true}, + ); + }); + test('quote as the first (and only) block', () => { + testEnv.editor.update( + () => { + $getRoot() + .clear() + .append($createQuoteNode().append($createTextNode('hi'))); + $removeTextFromCaretRange($caretRangeFromSelection($selectAll())); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(1); + invariant( + $isParagraphNode(children[0]), + 'Expected a ParagraphNode', + ); + expect(children[0].isEmpty()).toBe(true); + }, + {discrete: true}, + ); + }); + test('list item as the first block, followed by a paragraph', () => { + testEnv.editor.update( + () => { + $getRoot() + .clear() + .append( + $createListNode('bullet').append( + $createListItemNode().append($createTextNode('one')), + $createListItemNode().append($createTextNode('two')), + ), + $createParagraphNode().append($createTextNode('after')), + ); + $removeTextFromCaretRange($caretRangeFromSelection($selectAll())); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(1); + invariant( + $isParagraphNode(children[0]), + 'Expected a ParagraphNode', + ); + expect(children[0].isEmpty()).toBe(true); + expect($getRoot().getAllTextNodes()).toEqual([]); + }, + {discrete: true}, + ); + }); + test('nested list collapses to a single empty paragraph', () => { + testEnv.editor.update( + () => { + $getRoot() + .clear() + .append( + $createListNode('bullet').append( + $createListItemNode().append($createTextNode('one')), + $createListItemNode().append( + $createListNode('bullet').append( + $createListItemNode().append($createTextNode('two')), + ), + ), + ), + ); + $removeTextFromCaretRange($caretRangeFromSelection($selectAll())); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(1); + invariant( + $isParagraphNode(children[0]), + 'Expected a ParagraphNode', + ); + expect(children[0].isEmpty()).toBe(true); + expect($getRoot().getAllTextNodes()).toEqual([]); + }, + {discrete: true}, + ); + }); + test('paragraph as the first block is left unchanged', () => { + testEnv.editor.update( + () => { + const paragraphNode = $createParagraphNode().append( + $createTextNode('hi'), + ); + $getRoot() + .clear() + .append( + paragraphNode, + $createHeadingNode('h1').append($createTextNode('there')), + ); + $removeTextFromCaretRange($caretRangeFromSelection($selectAll())); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(1); + // The original first paragraph survives (not a freshly created one) + expect(children[0]).toBe(paragraphNode); + expect(paragraphNode.isEmpty()).toBe(true); + }, + {discrete: true}, + ); + }); + test('partial delete that only empties the first block is unaffected', () => { + testEnv.editor.update( + () => { + const heading = $createHeadingNode('h1').append( + $createTextNode('abc'), + ); + const paragraphNode = $createParagraphNode().append( + $createTextNode('def'), + ); + $getRoot().clear().append(heading, paragraphNode); + // Select only the heading's own text, not the whole document. + const sel = $createRangeSelection(); + const headingText = heading.getFirstChildOrThrow(); + const paragraphText = paragraphNode.getFirstChildOrThrow(); + invariant( + $isTextNode(headingText) && $isTextNode(paragraphText), + 'Expected TextNodes', + ); + sel.anchor.set(headingText.getKey(), 0, 'text'); + sel.focus.set(headingText.getKey(), 3, 'text'); + $setSelection(sel); + $removeTextFromCaretRange($caretRangeFromSelection(sel)); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(2); + // The heading keeps its type; it is not converted to a paragraph. + invariant($isHeadingNode(children[0]), 'Expected a HeadingNode'); + expect(children[0].isEmpty()).toBe(true); + expect(children[1]).toBe(paragraphNode); + expect(paragraphNode.getTextContent()).toBe('def'); + }, + {discrete: true}, + ); + }); + }); describe('ported Table e2e tests', () => { test('Can delete all with range selection anchored in table', () => { testEnv.editor.update( From 287af042ee124ab5a7f9a32fff61631c5352d413 Mon Sep 17 00:00:00 2001 From: Gunjan Jaswal Date: Mon, 27 Jul 2026 22:11:29 +0530 Subject: [PATCH 2/2] fix: collapse select-all delete in the delete path, not removeText The previous approach put the "empty document collapses to a paragraph" special case inside $removeTextFromCaretRange. That primitive is shared: insertText() also calls removeText() to clear the old selection before inserting, so replacing all of a lone non-paragraph block's content (for example the Prettier "format" button on a single code block) was wrongly collapsing the block to a paragraph, and the reformatted text landed in a plain paragraph. That broke the CodeActionMenu e2e test. Move the logic to RangeSelection.deleteCharacter, the actual delete-key path, so replacements are untouched. The existing collapseAtStart handling there only covers backwards deletion of a heading or quote; extend it to also cover forward Delete and list / nested-list survivors. The guard stays tight: it only fires when a full-range delete leaves the root with a single empty non-paragraph block on a single empty branch, bailing on any sibling content, decorator, or shadow root. Revert the $removeTextFromCaretRange change and rewrite the unit tests to drive the behavior through deleteCharacter (both directions), plus a regression test that replacing all text in a lone block keeps its type. Closes #5835 --- packages/lexical/src/LexicalSelection.ts | 61 ++++ .../lexical/src/caret/LexicalCaretUtils.ts | 69 +---- .../caret/__tests__/unit/LexicalCaret.test.ts | 261 ++++++++++-------- 3 files changed, 211 insertions(+), 180 deletions(-) diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index aa90191bf20..0dce9f43f43 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -1915,6 +1915,19 @@ export class RangeSelection implements BaseSelection { } $ensureRootHasParagraph(); } + // A full-range (e.g. select-all) delete can empty the whole document down + // to a single non-paragraph block -- a heading, quote, list, or nested + // list -- which then lingers empty while keeping its type instead of + // collapsing to a plain paragraph like an empty editor. `collapseAtStart` + // above only covers backwards deletion of a heading/quote, so handle the + // forward-delete and list cases here. This lives in `deleteCharacter` + // rather than in the shared `removeText`/`$removeTextFromCaretRange` + // primitive on purpose: `insertText` also routes through `removeText` to + // clear the old selection before inserting (as the Prettier "format" flow + // does on a lone code block), and that replacement must keep its block. + if (!wasCollapsed && this.isCollapsed()) { + $collapseEmptiedRootToParagraph(this); + } } /** @@ -2294,6 +2307,54 @@ function $collapseAtStart( return false; } +/** + * After a full-range delete, collapse a document that is left as a single + * empty non-paragraph block (heading, quote, list, nested list, ...) down to a + * single empty paragraph, matching the empty-editor state. Bails out unless the + * root's only content is that one block sitting on a single, empty branch -- + * any sibling content, decorator, shadow root, or surviving text leaves the + * block untouched. + */ +function $collapseEmptiedRootToParagraph(selection: RangeSelection): void { + const anchorNode = selection.anchor.getNode(); + const block = $isElementNode(anchorNode) + ? anchorNode + : anchorNode.getParent(); + if ( + !block || + !$isElementNode(block) || + $isParagraphNode(block) || + $isRootOrShadowRoot(block) || + !block.isEmpty() + ) { + return; + } + // Walk up to the top-level block (the direct child of the root), bailing on + // anything that means this is not a plain, fully-emptied document. + let topLevel: ElementNode = block; + for ( + let parent = block.getParent(); + parent !== null && !$isRootNode(parent); + parent = parent.getParent() + ) { + if ( + $isRootOrShadowRoot(parent) || + !$isElementNode(parent) || + parent.getChildrenSize() !== 1 + ) { + return; + } + topLevel = parent; + } + const root = topLevel.getParent(); + if (!$isRootNode(root) || root.getChildrenSize() !== 1) { + return; + } + const paragraph = $createParagraphNode(); + topLevel.replace(paragraph); + paragraph.selectStart(); +} + function $swapPoints(selection: RangeSelection): void { const focus = selection.focus; const anchor = selection.anchor; diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index b4775abe273..b91166e9f26 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -27,10 +27,7 @@ import { INTERNAL_$isBlock, } from '../LexicalUtils'; import {$isElementNode, type ElementNode} from '../nodes/LexicalElementNode'; -import { - $createParagraphNode, - $isParagraphNode, -} from '../nodes/LexicalParagraphNode'; +import {$createParagraphNode} from '../nodes/LexicalParagraphNode'; import {$isRootNode} from '../nodes/LexicalRootNode'; import { $createTextNode, @@ -418,15 +415,8 @@ export function $removeTextFromCaretRange( ...focusCandidates, ].find($isCaretAttached); if (bestCandidate) { - const normalizedBest = $normalizeCaret(bestCandidate); - // Special-case select-all + delete: when the removal emptied the entire - // root and the sole surviving block is a non-paragraph element block (a - // list item, heading, quote, ...), it would otherwise linger empty while - // keeping its type. Replace it with a fresh paragraph so the document - // collapses to a single empty paragraph, matching an empty editor. - const collapsedCaret = $collapseEmptiedRootToParagraph(normalizedBest); const anchor = $getCaretInDirection( - collapsedCaret || normalizedBest, + $normalizeCaret(bestCandidate), initialRange.direction, ); return $getCollapsedCaretRange(anchor); @@ -438,61 +428,6 @@ export function $removeTextFromCaretRange( ); } -/** - * Handle the select-all + delete edge case where the removal emptied the whole - * root but left behind a single non-paragraph block (list item, heading, - * quote, ...) that survives empty while keeping its element type. - * - * The guard is deliberately tight: this only fires when the caret's block is an - * empty, non-paragraph normal element block whose ancestor chain up to the root - * is a single, empty branch (so the root's only content is this one block). - * Any shadow root, decorator, or sibling content in the chain bails out, so the - * shared deletion path is otherwise untouched. - * - * @returns a caret at the start of the replacement paragraph, or null if the - * special case does not apply and the original caret should be used. - */ -function $collapseEmptiedRootToParagraph( - caret: PointCaret, -): null | ChildCaret { - const block = $getBlockFromCaret($getCaretInDirection(caret, 'next')); - if ( - !block || - !$isElementNode(block) || - $isParagraphNode(block) || - !block.isEmpty() - ) { - return null; - } - // Walk up to the top-level block (the direct child of the root), bailing on - // anything that means this is not a plain, fully-emptied document. - let topLevel: ElementNode = block; - for ( - let parent = block.getParent(); - parent !== null && !$isRootNode(parent); - parent = parent.getParent() - ) { - if ( - $isRootOrShadowRoot(parent) || - !$isElementNode(parent) || - parent.getChildrenSize() !== 1 - ) { - return null; - } - topLevel = parent; - } - const root = topLevel.getParent(); - if (!$isRootNode(root) || root.getChildrenSize() !== 1) { - return null; - } - const paragraph = $createParagraphNode(); - topLevel.insertBefore(paragraph); - // Use remove() (not replace()) so any selection still resolving inside the - // removed subtree is moved out to the parent rather than left dangling. - topLevel.remove(); - return $getChildCaret(paragraph, 'next'); -} - function $getBlockFromCaret( caret: PointCaret, ): ElementNode | null { diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index ad66f7346b7..77569179bbb 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -762,123 +762,134 @@ describe('LexicalCaret', () => { }); }); describe('select-all + delete collapses to an empty paragraph (#5835)', () => { - test('heading as the first (and only) block', () => { - testEnv.editor.update( - () => { - $getRoot() - .clear() - .append($createHeadingNode('h1').append($createTextNode('hi'))); - const resultRange = $removeTextFromCaretRange( - $caretRangeFromSelection($selectAll()), - ); - const children = $getRoot().getChildren(); - expect(children).toHaveLength(1); - invariant( - $isParagraphNode(children[0]), - 'Expected a ParagraphNode', - ); - expect(children[0].isEmpty()).toBe(true); - expect($getRoot().getAllTextNodes()).toEqual([]); - expect(resultRange).toMatchObject({ - anchor: {direction: 'next', origin: children[0], type: 'child'}, - }); - }, - {discrete: true}, - ); - }); - test('quote as the first (and only) block', () => { - testEnv.editor.update( - () => { - $getRoot() - .clear() - .append($createQuoteNode().append($createTextNode('hi'))); - $removeTextFromCaretRange($caretRangeFromSelection($selectAll())); - const children = $getRoot().getChildren(); - expect(children).toHaveLength(1); - invariant( - $isParagraphNode(children[0]), - 'Expected a ParagraphNode', - ); - expect(children[0].isEmpty()).toBe(true); - }, - {discrete: true}, - ); - }); - test('list item as the first block, followed by a paragraph', () => { - testEnv.editor.update( - () => { - $getRoot() - .clear() - .append( - $createListNode('bullet').append( - $createListItemNode().append($createTextNode('one')), - $createListItemNode().append($createTextNode('two')), - ), - $createParagraphNode().append($createTextNode('after')), + // The collapse lives in RangeSelection.deleteCharacter (the delete key + // path), not in the shared $removeTextFromCaretRange primitive, so + // exercise it the way the editor does: select all, then delete. Both + // directions (Backspace and forward Delete) must collapse. + function $selectAllAndDelete(isBackward: boolean): void { + const selection = $selectAll(); + $setSelection(selection); + selection.deleteCharacter(isBackward); + } + for (const isBackward of [true, false]) { + const label = isBackward ? 'Backspace' : 'Delete'; + test(`heading as the only block collapses (${label})`, () => { + testEnv.editor.update( + () => { + $getRoot() + .clear() + .append( + $createHeadingNode('h1').append($createTextNode('hi')), + ); + $selectAllAndDelete(isBackward); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(1); + invariant( + $isParagraphNode(children[0]), + 'Expected a ParagraphNode', ); - $removeTextFromCaretRange($caretRangeFromSelection($selectAll())); - const children = $getRoot().getChildren(); - expect(children).toHaveLength(1); - invariant( - $isParagraphNode(children[0]), - 'Expected a ParagraphNode', - ); - expect(children[0].isEmpty()).toBe(true); - expect($getRoot().getAllTextNodes()).toEqual([]); - }, - {discrete: true}, - ); - }); - test('nested list collapses to a single empty paragraph', () => { - testEnv.editor.update( - () => { - $getRoot() - .clear() - .append( - $createListNode('bullet').append( - $createListItemNode().append($createTextNode('one')), - $createListItemNode().append( - $createListNode('bullet').append( - $createListItemNode().append($createTextNode('two')), + expect(children[0].isEmpty()).toBe(true); + expect($getRoot().getAllTextNodes()).toEqual([]); + }, + {discrete: true}, + ); + }); + test(`quote as the only block collapses (${label})`, () => { + testEnv.editor.update( + () => { + $getRoot() + .clear() + .append($createQuoteNode().append($createTextNode('hi'))); + $selectAllAndDelete(isBackward); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(1); + invariant( + $isParagraphNode(children[0]), + 'Expected a ParagraphNode', + ); + expect(children[0].isEmpty()).toBe(true); + }, + {discrete: true}, + ); + }); + test(`list followed by a paragraph collapses (${label})`, () => { + testEnv.editor.update( + () => { + $getRoot() + .clear() + .append( + $createListNode('bullet').append( + $createListItemNode().append($createTextNode('one')), + $createListItemNode().append($createTextNode('two')), + ), + $createParagraphNode().append($createTextNode('after')), + ); + $selectAllAndDelete(isBackward); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(1); + invariant( + $isParagraphNode(children[0]), + 'Expected a ParagraphNode', + ); + expect(children[0].isEmpty()).toBe(true); + expect($getRoot().getAllTextNodes()).toEqual([]); + }, + {discrete: true}, + ); + }); + test(`nested list collapses (${label})`, () => { + testEnv.editor.update( + () => { + $getRoot() + .clear() + .append( + $createListNode('bullet').append( + $createListItemNode().append($createTextNode('one')), + $createListItemNode().append( + $createListNode('bullet').append( + $createListItemNode().append($createTextNode('two')), + ), ), ), - ), + ); + $selectAllAndDelete(isBackward); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(1); + invariant( + $isParagraphNode(children[0]), + 'Expected a ParagraphNode', ); - $removeTextFromCaretRange($caretRangeFromSelection($selectAll())); - const children = $getRoot().getChildren(); - expect(children).toHaveLength(1); - invariant( - $isParagraphNode(children[0]), - 'Expected a ParagraphNode', - ); - expect(children[0].isEmpty()).toBe(true); - expect($getRoot().getAllTextNodes()).toEqual([]); - }, - {discrete: true}, - ); - }); - test('paragraph as the first block is left unchanged', () => { - testEnv.editor.update( - () => { - const paragraphNode = $createParagraphNode().append( - $createTextNode('hi'), - ); - $getRoot() - .clear() - .append( - paragraphNode, - $createHeadingNode('h1').append($createTextNode('there')), + expect(children[0].isEmpty()).toBe(true); + expect($getRoot().getAllTextNodes()).toEqual([]); + }, + {discrete: true}, + ); + }); + test(`paragraph as the first block is left unchanged (${label})`, () => { + testEnv.editor.update( + () => { + const paragraphNode = $createParagraphNode().append( + $createTextNode('hi'), ); - $removeTextFromCaretRange($caretRangeFromSelection($selectAll())); - const children = $getRoot().getChildren(); - expect(children).toHaveLength(1); - // The original first paragraph survives (not a freshly created one) - expect(children[0]).toBe(paragraphNode); - expect(paragraphNode.isEmpty()).toBe(true); - }, - {discrete: true}, - ); - }); + $getRoot() + .clear() + .append( + paragraphNode, + $createHeadingNode('h1').append($createTextNode('there')), + ); + $selectAllAndDelete(isBackward); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(1); + invariant( + $isParagraphNode(children[0]), + 'Expected a ParagraphNode', + ); + expect(children[0].isEmpty()).toBe(true); + }, + {discrete: true}, + ); + }); + } test('partial delete that only empties the first block is unaffected', () => { testEnv.editor.update( () => { @@ -900,7 +911,10 @@ describe('LexicalCaret', () => { sel.anchor.set(headingText.getKey(), 0, 'text'); sel.focus.set(headingText.getKey(), 3, 'text'); $setSelection(sel); - $removeTextFromCaretRange($caretRangeFromSelection(sel)); + // Forward delete: a backwards delete of an emptied first block + // is separately collapsed by the pre-existing collapseAtStart + // path, which is not what this test is guarding. + sel.deleteCharacter(false); const children = $getRoot().getChildren(); expect(children).toHaveLength(2); // The heading keeps its type; it is not converted to a paragraph. @@ -912,6 +926,27 @@ describe('LexicalCaret', () => { {discrete: true}, ); }); + test('replacing all text in a lone non-paragraph block keeps its type', () => { + // Guards the Prettier "format" regression: selecting all of a lone + // block's content and inserting (a replace, which routes through + // removeText) must keep the block, not collapse it to a paragraph. + testEnv.editor.update( + () => { + const heading = $createHeadingNode('h1').append( + $createTextNode('hi'), + ); + $getRoot().clear().append(heading); + const selection = heading.select(0); + $setSelection(selection); + selection.insertText('there'); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(1); + invariant($isHeadingNode(children[0]), 'Expected a HeadingNode'); + expect(children[0].getTextContent()).toBe('there'); + }, + {discrete: true}, + ); + }); }); describe('ported Table e2e tests', () => { test('Can delete all with range selection anchored in table', () => {