[lexical][lexical-table] Bug Fix: selection and caret state at block and shadow-root boundaries - #9058
Open
LeSingh1 wants to merge 1 commit into
Open
[lexical][lexical-table] Bug Fix: selection and caret state at block and shadow-root boundaries#9058LeSingh1 wants to merge 1 commit into
LeSingh1 wants to merge 1 commit into
Conversation
…and shadow-root boundaries ## Description Eight related core defects share one story: a `RangeSelection` or a block cursor ends up pointing at the wrong place, or carrying the format and style of the place it just left, whenever the caret crosses a block, shadow-root or inline boundary — plus the two places where the surrounding machinery leaks that same boundary confusion into the DOM. All of the source changes are in `packages/lexical`; `lexical-table` and `lexical-playground` only gain tests. **Caret at a block or shadow-root boundary** - A block cursor sitting between two shadow-root `ElementNode`s (a table, or a slot-bearing card) ignored Backspace and Delete entirely. `deleteCharacter` in `LexicalSelection.ts` only removed an adjacent block `DecoratorNode`; for an `ElementNode` host the caret walk descended into it as a `ChildCaret` and bailed out at the shadow root. It now removes the adjacent node when `$needsBlockCursorBeside` reports one, leaving decorators to the existing branch so `isIsolated()` is still honoured. (facebook#8939) - Forward delete in an empty block immediately before a table did nothing. `forwardDeletion` stops at an adjacent shadow root so its content is never merged into the anchor block — but when that block is empty there is nothing to merge. `deleteCharacter` now falls through to the caret walk in exactly that case, removing the empty block and leaving the table in place, which is what backward delete already did. Restricted to a shadow root that is a sibling of the anchor block, so forward delete at the end of a table cell still bails out. (facebook#8075) - Select-all over a document whose only top-level node is a shadow-root widget normalized both points down into the widget, so the range described "the text inside the widget" rather than "everything" and a delete emptied the widget in place instead of removing it. `$normalizeSelectionForSelectAll` in `LexicalUtils.ts` keeps the element-level points when both points resolve into the *same* top-level shadow root; a select-all that merely starts in one still normalizes as before. `$removeTextFromCaretRange` in `caret/LexicalCaretUtils.ts` restores the empty paragraph the root would otherwise be left without. (facebook#6938) - Pasting inline content with the caret inside an inline `ElementNode` that cannot be split put the content after the whole node instead of at the caret. An `ElementNode` is split by moving its trailing children into whatever `insertNewAfter()` returns, and the base-class default returns `null`. `$splitNodeAtPoint` and `$removeTextAndSplitBlock` in `LexicalSelection.ts` gained a `stopAtUnsplittableInline` flag and now return `[container, offset]`, so the all-inline branch of `insertNodes` inserts into the node the walk stopped on. (facebook#6477) **Stale format and style after the caret moves** - Moving the selection through the node APIs left the old position's pending format and style on it, so the toolbar and the next insertion used stale formatting. `$internalCreateRangeSelection` already re-derives them for every selection change that originates in the DOM; the new `$internalRefreshSelectionFormatAndStyle` in `LexicalSelection.ts` does the same for `ElementNode.select()` and `TextNode.select()`. Landing on the same node is deliberately not a move — a format armed on a collapsed caret must survive being re-selected in place. (facebook#8817) - `RangeSelection.removeText` can leave the caret in a node it did not start in — backspacing an empty paragraph merges it into the end of the previous block — while keeping the format and style of the node it left. It now re-derives both from the node the caret landed on. (facebook#6781) **DOM state left behind at the same boundaries** - Clearing the last theme class or the last inline declaration left `class=""` and `style=""` in the editor DOM: `classList.remove(...)` and `style.setProperty(prop, '')` do not remove the attribute once it is empty. The new `removeEmptyDOMAttribute` in `LexicalUtils.ts` is called from `setElementIndent` and `setElementFormat` in `LexicalReconciler.ts` and from `setTextThemeClassNames` and `TextNode.updateDOM`. (facebook#5307) - Pressing undo while focus is in a control outside the editor (the link toolbar's URL field) dispatches `historyUndo` at the editor root with no Lexical selection. `$handleBeforeInput` in `LexicalEvents.ts` returned without preventing the default, so the browser ran its native history over a Lexical-managed contenteditable and the editor state was then rebuilt from a DOM that was never in Lexical's history. It now prevents the default and dispatches `UNDO_COMMAND`/`REDO_COMMAND`; `@lexical/history` restores a whole editor state and needs no selection. (facebook#6714) ## Test plan Five new unit test files (`Issue8939Repro`, `Issue8817Repro`, `Issue6781Repro`, `Issue6477Repro`, `Issue5307Repro` under `packages/lexical/src/__tests__/unit`, plus `Issue6938Repro` under `packages/lexical-playground/__tests__/unit`) and new cases appended to the existing `LexicalSelection.test.ts`, `LexicalTableSelection.test.tsx` and `CardNode.test.ts` — 21 new unit tests, each of which fails on `main` and passes here. The link-toolbar undo fix (facebook#6714) is browser-only and is covered by a new case in the Playwright spec `packages/lexical-playground/__tests__/e2e/Links.spec.mjs`, which runs in CI's e2e job and cannot be executed locally. The three other e2e specs (`ClearFormatting`, `Indentation`, `KeyboardShortcuts`) only drop the now-absent `style=""` from their expected HTML, which is the visible effect of the facebook#5307 fix; those also run in CI's e2e job. ### Before Source changes reverted, new tests kept: ``` $ npx vitest run --project unit packages/lexical/src packages/lexical-table packages/lexical-playground × clearing an element format removes the style attribute 74ms × clearing an element indent removes the style attribute 7ms × clearing a text style removes the style attribute 31ms × clearing the last text format removes the class attribute 8ms × selectStart() drops the format of the old position 202ms × selectEnd() picks up the format of the new position 31ms × selectStart() drops the style of the old position 25ms × selectEnd() picks up the style of the new position 4ms × merging into the previous block adopts that block text format and style 142ms × forward delete removes an empty block before a shadow root 13ms × block cursor between two Cards deletes one (isBackward: true) 9ms × block cursor between two Cards deletes one (isBackward: false) 5ms × forward delete removes an empty paragraph before a table 9ms × removes the widget when it is the first node 17ms × removes the widget when it is the first node via removeText 2ms × replaces the widget when typing over a select-all 21ms × pasted text is inserted at the caret inside the node 38ms × an inline node inserted at the caret goes inside the node 5ms × deletes the adjacent 'shadowRoot' (isBackward: true) 6ms × deletes the adjacent 'shadowRoot' (isBackward: false) 1ms × Backspace after the last shadow root deletes it 6ms Test Files 9 failed | 85 passed (94) Tests 21 failed | 1718 passed (1739) ``` ### After ``` $ npx vitest run --project unit packages/lexical/src packages/lexical-table packages/lexical-playground Test Files 94 passed (94) Tests 1739 passed (1739) $ npx tsc --noEmit -p . (clean, exit 0) ``` Supersedes facebook#8945, facebook#8946, facebook#8948, facebook#8954, facebook#8955, facebook#8970, facebook#8971, facebook#8977, consolidated per the review feedback on facebook#9027 and facebook#9035.
LeSingh1
requested review from
acywatson,
etrepum,
fantactuka,
ivailop7,
potatowagon and
zurfyx
as code owners
August 10, 2026 04:20
|
@LeSingh1 is attempting to deploy a commit to the Meta Open Source Team on Vercel. A member of the Team first needs to authorize it. |
This was referenced Aug 10, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Eight related core defects share one story: a
RangeSelectionor a block cursorends up pointing at the wrong place, or carrying the format and style of the
place it just left, whenever the caret crosses a block, shadow-root or inline
boundary — plus the two places where the surrounding machinery leaks that same
boundary confusion into the DOM. All of the source changes are in
packages/lexical;lexical-tableandlexical-playgroundonly gain tests.Caret at a block or shadow-root boundary
ElementNodes (a table, or aslot-bearing card) ignored Backspace and Delete entirely.
deleteCharacterin
LexicalSelection.tsonly removed an adjacent blockDecoratorNode; foran
ElementNodehost the caret walk descended into it as aChildCaretandbailed out at the shadow root. It now removes the adjacent node when
$needsBlockCursorBesidereports one, leaving decorators to the existingbranch so
isIsolated()is still honoured. (Bug: The block cursor between ElementNodes does not delete the node #8939)forwardDeletionstops at an adjacent shadow root so its content is nevermerged into the anchor block — but when that block is empty there is nothing
to merge.
deleteCharacternow falls through to the caret walk in exactlythat case, removing the empty block and leaving the table in place, which is
what backward delete already did. Restricted to a shadow root that is a
sibling of the anchor block, so forward delete at the end of a table cell
still bails out. (Bug: Delete key does not delete empty line before a table #8075)
normalized both points down into the widget, so the range described "the text
inside the widget" rather than "everything" and a delete emptied the widget in
place instead of removing it.
$normalizeSelectionForSelectAllinLexicalUtils.tskeeps the element-level points when both points resolve intothe same top-level shadow root; a select-all that merely starts in one still
normalizes as before.
$removeTextFromCaretRangeincaret/LexicalCaretUtils.tsrestores the empty paragraph the root would otherwise be left without. (Bug: Columns Layout if first node deletion is incorrect #6938)
ElementNodethatcannot be split put the content after the whole node instead of at the caret.
An
ElementNodeis split by moving its trailing children into whateverinsertNewAfter()returns, and the base-class default returnsnull.$splitNodeAtPointand$removeTextAndSplitBlockinLexicalSelection.tsgained a
stopAtUnsplittableInlineflag and now return[container, offset], so the all-inline branch ofinsertNodesinserts intothe node the walk stopped on. (Bug: Pasting text from the clipboard into a CustomElementNode does not work properly #6477)
Stale format and style after the caret moves
format and style on it, so the toolbar and the next insertion used stale
formatting.
$internalCreateRangeSelectionalready re-derives them for everyselection change that originates in the DOM; the new
$internalRefreshSelectionFormatAndStyleinLexicalSelection.tsdoes thesame for
ElementNode.select()andTextNode.select(). Landing on the samenode is deliberately not a move — a format armed on a collapsed caret must
survive being re-selected in place. (Bug: Programmatically moving a Selection does not update the Formatting nor Style #8817)
RangeSelection.removeTextcan leave the caret in a node it did not start in— backspacing an empty paragraph merges it into the end of the previous block
— while keeping the format and style of the node it left. It now re-derives
both from the node the caret landed on. (Bug: Removing an empty paragraph with a sibling does not correctly set styles on selection #6781)
DOM state left behind at the same boundaries
class=""and
style=""in the editor DOM:classList.remove(...)andstyle.setProperty(prop, '')do not remove the attribute once it is empty.The new
removeEmptyDOMAttributeinLexicalUtils.tsis called fromsetElementIndentandsetElementFormatinLexicalReconciler.tsand fromsetTextThemeClassNamesandTextNode.updateDOM. (Bug: node.setFormat and selection.formatText leave empty style and class attributes #5307)toolbar's URL field) dispatches
historyUndoat the editor root with noLexical selection.
$handleBeforeInputinLexicalEvents.tsreturned withoutpreventing the default, so the browser ran its native history over a
Lexical-managed contenteditable and the editor state was then rebuilt from a
DOM that was never in Lexical's history. It now prevents the default and
dispatches
UNDO_COMMAND/REDO_COMMAND;@lexical/historyrestores a wholeeditor state and needs no selection. (Bug: After pasting a link, I focused on the URL input and pressed Ctrl+Z (undo). The latest node inserted was the link, but the other part of the text is being undone, and the undo command isn't reflected in the tree view. #6714)
Test plan
Five new unit test files (
Issue8939Repro,Issue8817Repro,Issue6781Repro,Issue6477Repro,Issue5307Reprounderpackages/lexical/src/__tests__/unit,plus
Issue6938Reprounderpackages/lexical-playground/__tests__/unit) and newcases appended to the existing
LexicalSelection.test.ts,LexicalTableSelection.test.tsxandCardNode.test.ts— 21 new unit tests, eachof which fails on
mainand passes here. The link-toolbar undo fix (#6714) isbrowser-only and is covered by a new case in the Playwright spec
packages/lexical-playground/__tests__/e2e/Links.spec.mjs, which runs in CI'se2e job and cannot be executed locally. The three other e2e specs
(
ClearFormatting,Indentation,KeyboardShortcuts) only drop the now-absentstyle=""from their expected HTML, which is the visible effect of the #5307fix; those also run in CI's e2e job.
Before
Source changes reverted, new tests kept:
After
Supersedes #8945, #8946, #8948, #8954, #8955, #8970, #8971, #8977, consolidated
per the review feedback on #9027 and #9035.