Skip to content

[lexical-rich-text] Bug Fix: Caret stuck when block has no leading or trailing text#8604

Merged
etrepum merged 3 commits into
facebook:mainfrom
mayrang:fix/8601-cursor-end-decorators-only
Jun 1, 2026
Merged

[lexical-rich-text] Bug Fix: Caret stuck when block has no leading or trailing text#8604
etrepum merged 3 commits into
facebook:mainfrom
mayrang:fix/8601-cursor-end-decorators-only

Conversation

@mayrang

@mayrang mayrang commented May 31, 2026

Copy link
Copy Markdown
Contributor

Description

MOVE_TO_END / MOVE_TO_START handlers added in #8558 fall through to the native browser when the target element has no trailing / leading text — last/first descendant is a decorator, or the element holds only decorators. The assumption was that the native caret would take over, but Mac Chrome and Safari stop the caret at the inline decorator's contenteditable=false boundary in those cases too, leaving the cursor stuck. Windows Chromium handles it natively.

This PR drops the fall-through branches and lets the handlers finish the move themselves:

  • MOVE_TO_END: when lastDescendant is null or a decorator, land the caret at element.getChildrenSize() — the element offset past the last child.
  • MOVE_TO_START: drop the lastDescendant === decorator || null branch entirely. The rest of the handler already sets focus to element offset 0, which is the right target regardless of what the last descendant is.

Closes #8601

NodeSelection branch (#8604 discussion)

@levensta noticed MOVE_TO_END / MOVE_TO_START fire on NodeSelection too, but the handler's RangeSelection guard fell through and Chrome's native Cmd+Arrow took over — navigating browser history out of the editor.

Per @etrepum's design in the PR thread, the NodeSelection branch collapses to a RangeSelection at the iteration-order first node's leading edge (MOVE_TO_START) or last node's trailing edge (MOVE_TO_END), resolved to the nearest non-inline ancestor's offset 0 / childrenSize (root as the fallback). A decorator nested inside an element-decorator host promotes to the surrounding paragraph rather than past the host.

Test plan

@vercel

vercel Bot commented May 31, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment Jun 1, 2026 5:00am
lexical-playground Ready Ready Preview, Comment Jun 1, 2026 5:00am

Request Review

@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 May 31, 2026
@levensta

Copy link
Copy Markdown
Contributor

This isn't directly related to the issue, but I happened to notice this behavior. If NodeSelection is set (i.e., window.selection is null), the MOVE_TO_START and MOVE_TO_END commands are triggered, but they do not move the cursor and do not override the browser’s default behavior (in Chrome, the Ctrl + ArrowLeft/ArrowRight key combinations jump pages in the history)

Screen.Recording.2026-06-01.at.00.25.57.mov
@potatowagon

Copy link
Copy Markdown
Contributor

Review: Caret stuck fix for blocks with no leading/trailing text

Assessment: Safe to approve ✅

What I verified:

  1. Logic correctness: The fix changes MOVE_TO_END/MOVE_TO_START to use element.select(childrenSize) instead of bailing to native browser behavior when the last descendant is a decorator node. This is correct — the previous code returned false (fall-through to native), which caused the caret to get stuck at the contenteditable=false boundary.

  2. Edge cases handled: Tests cover three scenarios: (a) decorator-only elements, (b) [decorator][text][decorator] sandwich layouts, and (c) shift-select variants. The sandwich case is particularly important — it verifies the caret lands at element offset 3 (past all children).

  3. No regressions: The no-op test cases for MOVE_TO_START/END still pass — the decorator-only case was correctly removed from "no-op" (it is now handled, not skipped). Existing text-based movement cases remain unaffected since the new path only triggers when lastDescendant is null or a decorator.

  4. Test quality: Comprehensive unit tests verify exact selection state (type, key, offset, collapsed) rather than just "does not throw." This is a clear improvement over the prior crash-prevention approach.

  5. CI: All core tests, integrity, and e2e canary pass green.

Summary: Clean, well-tested fix that upgrades the decorator boundary handling from "do not crash" to "actually work correctly." The logic is sound and the test coverage is thorough.


Reviewed by Navi (automated review assistant for @potatowagon)

@mayrang

mayrang commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for flagging this.

Two paths I see for the NodeSelection case: (1) preventDefault only — kills the page navigate, caret stays put; (2) promote to RangeSelection at the surrounding block's edge — moves the caret but opens design questions (which block? how does shift behave?). Which direction looks right to you?

@levensta

levensta commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

I think it would be quite organic to see the cursor move to the end of the block, which is the topLevelElement relative to the selected decorator node. Holding down shift should also include the current node in promoted RangeSelection

The caveat is that a NodeSelection may contain multiple nodes, and these nodes may not be adjacent to one another. Also NodeSelection may contain an entire ElementNode - not in the playground, but in theory, artificially via $createNodeSelection. In these cases we could just leave it as preventDefault, but I’d like to hear Bob’s thoughts on it

@etrepum

etrepum commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

I think converting a NodeSelection to a collapsed RangeSelection at the leading edge of the document-ordered first node (move to start) or trailing edge of the document-ordered last node (move to end) and then running the RangeSelection version of the command probably makes the most sense

@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Jun 1, 2026
@mayrang

mayrang commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Folds the edge cases I was about to flag (nested host, whole-element host) into one path — keeps the block-edge direction @levensta was after, just unified. Going with that.

@potatowagon

Copy link
Copy Markdown
Contributor

Follow-up Review — Tater Thoughts Bobblehead (Sherry's Navi)

✅ Test update looks correct and the behavioral change is well-justified. Safe to approve.

What changed since last review:
The tests were updated to match the new behavior: instead of treating decorator-only elements as a no-op/bail case, MOVE_TO_END/START now actively positions the caret using element.select(childrenSize) to clear the trailing decorator boundary.

Why the behavioral change is correct:

  1. element.select(childrenSize, childrenSize) places the caret at the element-level position past all children — this is the expected Home/End behavior for blocks containing only inline decorators
  2. For the "sandwich" case [decorator][text][decorator], the offset is correctly set to 3 (all 3 children), landing past the trailing decorator
  3. MOVE_TO_START correctly lands at element offset 0 (before the leading decorator)
  4. Shift-selection correctly maintains anchor at original position while moving focus to boundary — preserving selection semantics

Edge cases verified in tests:

  • Decorator-only element (no text at all) → caret at offset 1
  • Decorator-text-decorator sandwich → End goes to offset 3, Start goes to offset 0
  • Shift+End extends selection (non-collapsed, anchor=0, focus=childrenSize)
  • Shift+Start extends selection backward (non-collapsed, anchor keeps text offset, focus=0)

Discussion thread observation: The etrepum/levensta/mayrang discussion about NodeSelection→RangeSelection conversion is tangential to this PR's fix. It's about a broader design question, not a blocking issue for this specific caret-stuck bugfix.

CI: All checks green. Ready to approve.

Comment thread packages/lexical-rich-text/src/index.ts Outdated
if (nodes.length === 0) {
return true;
}
const targetNode = isBackward ? nodes[0] : nodes[nodes.length - 1];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I was thinking we'd use document order, NodeSelection's set is the order the items were selected. I think something like this would work:

const targets = nodes.map(node => $getSiblingCaret(node, 'next')).sort($comparePointCaretNext);
const target = isBackward ? nodes[0].getFlipped() : nodes[nodes.length - 1];
$setSelectionFromCaretRange($getCollapsedCaretRange(target));
return true;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied — $getSiblingCaret + $comparePointCaretNext for the sort, then the block-edge promote on the picked node so the caret lands at the surrounding paragraph's edge rather than just the node's sibling boundary.

Comment thread packages/lexical-rich-text/src/index.ts Outdated
Comment on lines +1366 to +1372
const ending =
lastDescendant == null || $isDecoratorNode(lastDescendant)
? element.select(
element.getChildrenSize(),
element.getChildrenSize(),
)
: element.selectEnd();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Shouldn't element.selectEnd() and element.select(element.getChildrenSize(), element.getChildrenSize()) be equivalent for this case? If not, maybe we should fix element.selectEnd().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right — dropped the branch. I was worried text.selectEnd() (text-typed) vs the empty/decorator paths' parent.select() (element-typed) might regress the #8558 text-case behavior, but tested it and the unit suite stayed at 101/101 — selectEnd()'s per-case routing already covers each leaf type.

…selectEnd branch, document-order on NodeSelection)
@potatowagon

Copy link
Copy Markdown
Contributor

Follow-up review on commit 5abd8ab ("Address review feedback")

This is a solid improvement over the previous iteration. Changes verified:

  1. Document-order sorting via $comparePointCaretNext — Correctly addresses the Set insertion order issue. NodeSelection._nodes iterates in click-order (Map/Set insertion), so for multi-node selections the previous code would pick the wrong target. Now sorts by sibling caret comparison which gives true document order. The .origin accessor cleanly extracts the node after sort. ✅

  2. Simplified MOVE_TO_END handler — Replacing the manual lastDescendant == null || $isDecoratorNode(lastDescendant) branch with a direct element.selectEnd() call is correct. selectEnd() internally handles all three cases (text descendant → text-type at end, decorator → element-type at childrenSize, empty → element-type at childrenSize). Less code, same semantics, fewer edge-case paths. ✅

  3. Updated JSDoc — The documentation now accurately describes the algorithm and rationale. The distinction from KEY_ARROW commands (block-edge vs one-step) is clearly explained. ✅

  4. Test coverage — The existing tests from the previous commit cover single-node, multi-node, and whole-element NodeSelection cases for both MOVE_TO_START and MOVE_TO_END. The multi-node test specifically validates document-order resolution.

Core tests pass. E2e still running but the changes are test-only + well-scoped runtime logic.

Safe to approve once e2e green.

— via Navi on behalf of potatowagon

@etrepum etrepum added this pull request to the merge queue Jun 1, 2026
Merged via the queue into facebook:main with commit 9a036d4 Jun 1, 2026
44 checks passed

@potatowagon potatowagon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed by Navi (Tater Thoughts Bobblehead) on behalf of @potatowagon.

LGTM ✅ — Comprehensive fix for caret-stuck behavior with decorator nodes and MOVE_TO_END.

What this does: Fixes issue #8601 where Cmd+ArrowRight would get stuck (no-op) when a block has no trailing text — e.g. a paragraph containing only inline decorator nodes, or ending with an inline decorator. Also adds MOVE_TO_END support for NodeSelection (issue #8604), which previously wasn't handled at all.

What I checked:

  • ✅ Logic correctness: The original code bailed out (returned false) when the last child wasn't a TextNode. The fix correctly moves the caret to an element-type selection at the end of the block (offset = childrenSize), which is the right behavior for non-text trailing content.
  • ✅ NodeSelection handling: The new code resolves the containing block of the selected node(s) and moves the caret to the end of that block — sensible UX for Cmd+End with node selections.
  • ✅ Extensive test coverage: Tests cover decorator-only elements, text+decorator sandwiches (leading/trailing decorators), multi-node selections, whole-element NodeSelection, and existing text-only cases. Tests are well-documented with clear assertions.
  • ✅ Test changes are appropriate: The old "no-op: decorator-only element" test was WRONG (it asserted the bug behavior as correct). Now correctly asserts the caret moves past the decorator.
  • ✅ www compat: The MOVE_TO_END command handler is in @lexical/rich-text. This is a behavioral improvement, not a breaking change. www consumers using Cmd+End near decorators will get better behavior.
  • ✅ CI: All checks green (CLA, core-tests, e2e-tests).

Safe to land. Well-tested fix for a real UX issue — caret navigation should never get stuck.

@etrepum etrepum mentioned this pull request Jun 25, 2026
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

4 participants