Skip to content

[lexical] Bug Fix: Keep the block cursor inside the DOMSlot before boundary - #9707

Merged
etrepum merged 2 commits into
meta:mainfrom
mangeshraut712:fix-block-cursor-slot-before
Nov 1, 2026
Merged

etrepum merged 2 commits into
meta:mainfrom
mangeshraut712:fix-block-cursor-slot-before

Conversation

@mangeshraut712

Copy link
Copy Markdown
Contributor

Description

When a collapsed element selection sits after the last child of an element (for example after a block decorator) and that element's getDOMSlot uses withBefore, $updateDOMBlockCursorElement appended the block cursor to the end of slot.element. That put the cursor after the slot's trailing decoration, outside the managed range.

The cursor is now inserted before slot.before (which is null when there is no boundary, so behavior without a slot boundary is unchanged). This also matches where ElementDOMSlot.getDecoratorBoundaryAnchor('trailing') already expects to find the cursor (before.previousSibling).

Fixes #9680

Test plan

  • Added a unit test in LexicalDOMSlot.test.tsx (trailing decoration slot with a block decorator, selection at the end). It fails before the change and passes after.
  • vitest --project unit for packages/lexical, packages/lexical-rich-text, packages/lexical-selection: 663 files passed, 28582 tests passed.
  • tsc --noEmit, eslint and prettier clean.

@vercel

vercel Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
lexical Ready Ready Preview Nov 1, 2026 5:26pm UTC
lexical-playground Ready Ready Preview Nov 1, 2026 5:26pm UTC

Request Review

@mangeshraut712

Copy link
Copy Markdown
Contributor Author

I kept this minimal by inserting the cursor before slot.before rather than appending to slot.element. Does that approach match what you want here, or would you prefer it go through a slot method?

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Nov 1, 2026
@meta-code-review

meta-code-review Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 1, 2026, 5:39 PM UTC

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix, this looks good. Inserting the block cursor before slot.before keeps it inside the slot's managed range. It also matches where ElementDOMSlot.getDecoratorBoundaryAnchor('trailing') already expects to find the cursor, and it leaves elements without a before boundary unchanged, since insertBefore(cursor, null) behaves like appendChild. The new test in LexicalDOMSlot.test.tsx fails without the change and passes with it.

On your question: using slot.before directly looks like the right choice to me. DOMSlot.insertChild anchors on getInsertionAnchor(), which falls back to the managed line break and the trailing decorator boundary anchor. Going through it would move the cursor ahead of that scaffolding even for elements with no before boundary.

Optional, non-blocking suggestions:

  1. Guard against a before node that has left the DOM (packages/lexical/src/LexicalUtils.ts:2285). A getDOMSlot can return a stored reference to its boundary, like StoredTrailingDecorElementNode in LexicalDOMSlot.test.tsx, and a native edit can remove that node. ElementNode.reconcileObservedMutation already drops such a stale boundary (packages/lexical/src/nodes/LexicalElementNode.ts:1028-1034). In that state, with the caret after the last block decorator, insertBefore throws NotFoundError where appendChild did not. Mirroring that guard avoids it:

    const before =
      slot.before !== null && slot.before.parentNode === elementDOM
        ? slot.before
        : null;
    elementDOM.insertBefore(blockCursorElement, before);
  2. Add a changeset. CONTRIBUTING.md (Changesets) asks for one when a published package's DOM output changes, with a Verify: paragraph for fixes that change output. For example, .changeset/lexical-block-cursor-slot-before.md:

    ---
    'lexical': patch
    ---
    
    [fix] Keep the block cursor inside an element's DOM slot `before` boundary
    
    Verify: in an ElementNode whose `getDOMSlot` returns `withBefore(...)`, put the caret after its last block decorator; the block cursor renders before the trailing decoration instead of after it.
  3. Paste the test output. AGENTS.md (Commits and Pull Requests) asks for the actual failing output under ### Before and the passing output under ### After in the Test plan, rather than a description of them.

Comment thread packages/lexical/src/LexicalUtils.ts Outdated
elementDOM.appendChild(blockCursorElement);
// Stay inside the slot's managed range: a slot with a `before`
// boundary keeps its trailing decoration after the children.
elementDOM.insertBefore(blockCursorElement, slot.before);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: if a getDOMSlot returns a stored before node that a native edit has removed, this insertBefore throws NotFoundError, which appendChild never did. ElementNode.reconcileObservedMutation already guards the same case (packages/lexical/src/nodes/LexicalElementNode.ts:1028-1034):

const before =
  slot.before !== null && slot.before.parentNode === elementDOM
    ? slot.before
    : null;
elementDOM.insertBefore(blockCursorElement, before);

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix, this looks good. Inserting the block cursor before slot.before keeps it inside the slot's managed range, matches where ElementDOMSlot.getDecoratorBoundaryAnchor('trailing') already looks for it, and leaves elements without a before boundary unchanged, since insertBefore(cursor, null) behaves like appendChild. The fallback for a before node that has left the element (packages/lexical/src/LexicalUtils.ts:2285-2289) mirrors the check in ElementNode.reconcileObservedMutation, and each of the two new tests in LexicalDOMSlot.test.tsx fails without its part of the change and passes with it.

On your question: using slot.before directly is the right choice. DOMSlot.insertChild anchors on getInsertionAnchor(), which for an element without a before boundary falls back to the trailing decorator boundary anchor (packages/lexical/src/LexicalDOMSlot.ts:324-330), so going through it would move the cursor ahead of that anchor for every element that ends in a block decorator. Its invariant (packages/lexical/src/LexicalDOMSlot.ts:162-165) would also throw in the detached-boundary case your guard now handles.

Optional, non-blocking:

  • Paste the test output. AGENTS.md (Commits and Pull Requests) and .github/pull_request_template.md ask the Test plan for the command you ran with its actual failing output under ### Before and passing output under ### After, rather than a description of the results.

@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Nov 1, 2026
@pkg-pr-new

pkg-pr-new Bot commented Nov 1, 2026

Copy link
Copy Markdown
lexical

npm i https://pkg.pr.new/lexical@9707

@lexical/a11y

npm i https://pkg.pr.new/@lexical/a11y@9707

@lexical/clipboard

npm i https://pkg.pr.new/@lexical/clipboard@9707

@lexical/code

npm i https://pkg.pr.new/@lexical/code@9707

@lexical/code-core

npm i https://pkg.pr.new/@lexical/code-core@9707

@lexical/code-prism

npm i https://pkg.pr.new/@lexical/code-prism@9707

@lexical/code-shiki

npm i https://pkg.pr.new/@lexical/code-shiki@9707

@lexical/compiler

npm i https://pkg.pr.new/@lexical/compiler@9707

@lexical/devtools-core

npm i https://pkg.pr.new/@lexical/devtools-core@9707

@lexical/dragon

npm i https://pkg.pr.new/@lexical/dragon@9707

@lexical/eslint-plugin

npm i https://pkg.pr.new/@lexical/eslint-plugin@9707

@lexical/extension

npm i https://pkg.pr.new/@lexical/extension@9707

@lexical/fast-check

npm i https://pkg.pr.new/@lexical/fast-check@9707

@lexical/file

npm i https://pkg.pr.new/@lexical/file@9707

@lexical/hashtag

npm i https://pkg.pr.new/@lexical/hashtag@9707

@lexical/headless

npm i https://pkg.pr.new/@lexical/headless@9707

@lexical/history

npm i https://pkg.pr.new/@lexical/history@9707

@lexical/html

npm i https://pkg.pr.new/@lexical/html@9707

@lexical/internal

npm i https://pkg.pr.new/@lexical/internal@9707

@lexical/link

npm i https://pkg.pr.new/@lexical/link@9707

@lexical/list

npm i https://pkg.pr.new/@lexical/list@9707

@lexical/mark

npm i https://pkg.pr.new/@lexical/mark@9707

@lexical/markdown

npm i https://pkg.pr.new/@lexical/markdown@9707

@lexical/mdast

npm i https://pkg.pr.new/@lexical/mdast@9707

@lexical/offset

npm i https://pkg.pr.new/@lexical/offset@9707

@lexical/overflow

npm i https://pkg.pr.new/@lexical/overflow@9707

@lexical/plain-text

npm i https://pkg.pr.new/@lexical/plain-text@9707

@lexical/react

npm i https://pkg.pr.new/@lexical/react@9707

@lexical/rich-text

npm i https://pkg.pr.new/@lexical/rich-text@9707

@lexical/selection

npm i https://pkg.pr.new/@lexical/selection@9707

@lexical/table

npm i https://pkg.pr.new/@lexical/table@9707

@lexical/tailwind

npm i https://pkg.pr.new/@lexical/tailwind@9707

@lexical/text

npm i https://pkg.pr.new/@lexical/text@9707

@lexical/utils

npm i https://pkg.pr.new/@lexical/utils@9707

@lexical/y

npm i https://pkg.pr.new/@lexical/y@9707

@lexical/yjs

npm i https://pkg.pr.new/@lexical/yjs@9707

commit: 9c1fdbe

@etrepum
etrepum added this pull request to the merge queue Nov 1, 2026
Merged via the queue into meta:main with commit 63850ff Nov 1, 2026
127 of 128 checks passed

This branch was successfully deployed

2 active deployments
Preview – lexical — 9c1fdbe5 Deployed Nov 1, 2026 by vercel[bot]
Preview – lexical-playground — 9c1fdbe5 Deployed Nov 1, 2026 by vercel[bot]
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. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: The block cursor ignores the DOMSlot boundaries of the parent node

2 participants