Skip to content

[lexical] Bug Fix: make $extendCaretToRange's focus a real, flippable position - #8930

Open
Sa-Te wants to merge 1 commit into
facebook:mainfrom
Sa-Te:fix/8927-extend-caret-to-range
Open

[lexical] Bug Fix: make $extendCaretToRange's focus a real, flippable position#8930
Sa-Te wants to merge 1 commit into
facebook:mainfrom
Sa-Te:fix/8927-extend-caret-to-range

Conversation

@Sa-Te

@Sa-Te Sa-Te commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Root cause

$extendCaretToRange built its focus caret as $getSiblingCaret($getRoot(), anchor.direction)
— literally "the position after root, among root's siblings." Root has no siblings, so this
is a sentinel with no real position in the tree, not an actual caret.

This went unnoticed because every in-tree caller of $extendCaretToRange (rich-text,
table selection, LexicalSelection.ts, playground's ImageNode) only iterates the
resulting range — none of them ever call .getFlipped() on the focus.

$removeTextFromCaretRange does call range.focus.getFlipped(), and that's where it
breaks: AbstractSiblingCaret.getFlipped() falls back to $getChildCaret(this.origin.getParentOrThrow(), dir)
when there's no node at the caret. For a normal sibling caret this is fine (the origin's
parent exists). For root, it isn't — root has no parent — so it always throws
Expected node root to have a parent.

Repro (from #8927):
``
editor.update(() => {
const p1 = $createParagraphNode().append($createTextNode('hello'));
const p2 = $createParagraphNode().append($createTextNode('world'));
$getRoot().clear().append(p1, p2);
const t = p1.getFirstChild() as TextNode;
const range = $extendCaretToRange($getTextPointCaret(t, 'next', 2));
$removeTextFromCaretRange(range); // throws
});
``\

Fix

Option (a) from the issue, per @etrepum's comment: build the focus from root's last
real child in the anchor's direction, rather than from root itself:

``
const focus = $getChildCaret($getRoot(), flipDirection(dir)).getFlipped() as unknown as NodeCaret;
``\

This is a real, flippable position (e.g. SiblingCaret(lastChild, 'next') for a
non-empty document), because it's anchored on a node that genuinely has a parent.

The as unknown as cast follows the existing precedent in $getCaretRangeInDirection
in this same file — TypeScript can't prove FlipDirection<FlipDirection<D>> reduces to
D for a generic D (only for concrete 'next'/'previous' literals), even though
flipping twice always returns the original direction at runtime. $getCaretRange's own
invariant(anchor.direction === focus.direction, ...) check guards this at runtime
regardless.

Tradeoff (flagging per the issue's own framing)

This changes what the focus caret's identity compares equal to via .is(). Previously
it was SiblingCaret(root, dir); now it's SiblingCaret(lastChild, dir) (or
ChildCaret(root, dir) for an empty document). I checked every real caller in the repo
(rich-text, table selection, LexicalSelection.ts, lexical-utils, playground) — none
compare the focus by identity, only by iteration — so this is safe today, but it's a
narrow behavior change worth a reviewer's eyes.

Test plan

Added describe('$extendCaretToRange', ...) to LexicalCaret.test.ts:

  • Repro from the issue, both directions, confirms no throw and correct resulting document
    (verified against an actual editor.update run, not hand-derived)
  • Iteration-equivalence check: confirmed the exact node sequence a plain for...of visits
    is unchanged before and after this fix (I temporarily reverted to the old implementation
    locally to diff the two outputs) — this fix only changes flippability, not traversal
  • Empty-document edge case: focus can be flipped without throwing when root has no children

pnpm run test-unit passes (4982 passed, 1 pre-existing flake unrelated to this change —
FastPathCrossParent.test.ts's fuzz test times out under full-suite CPU contention but
passes in isolation in 3.3s; confirmed no reference to any caret code). tsc, flow,
lint, prettier all clean. No Flow declaration change needed (signature unchanged).
No website doc update needed (traversals.md only lists $extendCaretToRange as a
constructor, doesn't describe focus identity semantics). No e2e specs touch this code path.

Fixes #8927

@vercel

vercel Bot commented Aug 5, 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 Aug 5, 2026 9:12am
lexical-playground Ready Ready Preview Aug 5, 2026 9:12am

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 Aug 5, 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

$extendCaretToRange() returns a range that $removeTextFromCaretRange() throws on

1 participant