Skip to content

[lexical][lexical-table] Bug Fix: selection and caret state at block and shadow-root boundaries - #9058

Open
LeSingh1 wants to merge 1 commit into
facebook:mainfrom
LeSingh1:consol/core-selection-caret
Open

[lexical][lexical-table] Bug Fix: selection and caret state at block and shadow-root boundaries#9058
LeSingh1 wants to merge 1 commit into
facebook:mainfrom
LeSingh1:consol/core-selection-caret

Conversation

@LeSingh1

Copy link
Copy Markdown
Contributor

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 ElementNodes (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. (Bug: The block cursor between ElementNodes does not delete the node #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. (Bug: Delete key does not delete empty line before a table #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. (Bug: Columns Layout if first node deletion is incorrect #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. (Bug: Pasting text from the clipboard into a CustomElementNode does not work properly #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. (Bug: Programmatically moving a Selection does not update the Formatting nor Style #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. (Bug: Removing an empty paragraph with a sibling does not correctly set styles on selection #6781)

DOM state left behind at the same boundaries

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 (#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 #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 #8945, #8946, #8948, #8954, #8955, #8970, #8971, #8977, consolidated
per the review feedback on #9027 and #9035.

…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.
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant