[lexical-rich-text] Bug Fix: Caret stuck when block has no leading or trailing text#8604
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
This isn't directly related to the issue, but I happened to notice this behavior. If NodeSelection is set (i.e., Screen.Recording.2026-06-01.at.00.25.57.mov |
Review: Caret stuck fix for blocks with no leading/trailing textAssessment: Safe to approve ✅ What I verified:
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) |
|
Thanks for flagging this. Two paths I see for the NodeSelection case: (1) |
|
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 |
|
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 |
|
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. |
|
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: Why the behavioral change is correct:
Edge cases verified in tests:
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. |
…MOVE_TO_END / MOVE_TO_START
| if (nodes.length === 0) { | ||
| return true; | ||
| } | ||
| const targetNode = isBackward ? nodes[0] : nodes[nodes.length - 1]; |
There was a problem hiding this comment.
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;There was a problem hiding this comment.
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.
| const ending = | ||
| lastDescendant == null || $isDecoratorNode(lastDescendant) | ||
| ? element.select( | ||
| element.getChildrenSize(), | ||
| element.getChildrenSize(), | ||
| ) | ||
| : element.selectEnd(); |
There was a problem hiding this comment.
Shouldn't element.selectEnd() and element.select(element.getChildrenSize(), element.getChildrenSize()) be equivalent for this case? If not, maybe we should fix element.selectEnd().
There was a problem hiding this comment.
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)
|
Follow-up review on commit 5abd8ab ("Address review feedback") This is a solid improvement over the previous iteration. Changes verified:
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 |
potatowagon
left a comment
There was a problem hiding this comment.
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.
Description
MOVE_TO_END/MOVE_TO_STARThandlers 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'scontenteditable=falseboundary 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: whenlastDescendantis null or a decorator, land the caret atelement.getChildrenSize()— the element offset past the last child.MOVE_TO_START: drop thelastDescendant === decorator || nullbranch 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_STARTfire onNodeSelectiontoo, but the handler'sRangeSelectionguard 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
pnpm vitest run --project unit packages/lexical-rich-text packages/lexical-code-core— 101/101 pass (6 new scenarios for Bug: The cursor doesn't move to the end if the line contains only decorators #8601 + 6 new for the NodeSelection branch + no regression on the [lexical][lexical-rich-text][lexical-code-core] Bug Fix: Cursor stuck before leading inline DecoratorNode #8558 tests).playground.lexical.dev(before) vs local build (after), using the doc state from the issue:[decorator][text][decorator], caret at start + Cmd+→ → caret lands past the trailing decorator.[decorator][text][decorator], caret in text + Cmd+← → caret lands at element offset 0.