[lexical] Bug Fix: make $extendCaretToRange's focus a real, flippable position - #8930
Open
Sa-Te wants to merge 1 commit into
Open
[lexical] Bug Fix: make $extendCaretToRange's focus a real, flippable position#8930Sa-Te wants to merge 1 commit into
Sa-Te wants to merge 1 commit into
Conversation
Sa-Te
requested review from
acywatson,
etrepum,
fantactuka,
ivailop7,
potatowagon and
zurfyx
as code owners
August 5, 2026 09:11
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause
$extendCaretToRangebuilt 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'sImageNode) only iterates theresulting range — none of them ever call
.getFlipped()on the focus.$removeTextFromCaretRangedoes callrange.focus.getFlipped(), and that's where itbreaks:
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 anon-empty document), because it's anchored on a node that genuinely has a parent.
The
as unknown ascast follows the existing precedent in$getCaretRangeInDirectionin this same file — TypeScript can't prove
FlipDirection<FlipDirection<D>>reduces toDfor a genericD(only for concrete'next'/'previous'literals), even thoughflipping twice always returns the original direction at runtime.
$getCaretRange's owninvariant(anchor.direction === focus.direction, ...)check guards this at runtimeregardless.
Tradeoff (flagging per the issue's own framing)
This changes what the focus caret's identity compares equal to via
.is(). Previouslyit was
SiblingCaret(root, dir); now it'sSiblingCaret(lastChild, dir)(orChildCaret(root, dir)for an empty document). I checked every real caller in the repo(rich-text, table selection,
LexicalSelection.ts,lexical-utils, playground) — nonecompare 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', ...)toLexicalCaret.test.ts:(verified against an actual
editor.updaterun, not hand-derived)for...ofvisitsis 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
pnpm run test-unitpasses (4982 passed, 1 pre-existing flake unrelated to this change —FastPathCrossParent.test.ts's fuzz test times out under full-suite CPU contention butpasses in isolation in 3.3s; confirmed no reference to any caret code).
tsc,flow,lint,prettierall clean. No Flow declaration change needed (signature unchanged).No website doc update needed (
traversals.mdonly lists$extendCaretToRangeas aconstructor, doesn't describe focus identity semantics). No e2e specs touch this code path.
Fixes #8927