Skip to content

Bug Fix: [lexical] Clamp committed selection points when a text node is shrunk - #9793

Open
Rohan-Dedhia wants to merge 2 commits into
meta:mainfrom
Rohan-Dedhia:fix/selection-clamp-on-text-shrink
Open

Rohan-Dedhia wants to merge 2 commits into
meta:mainfrom
Rohan-Dedhia:fix/selection-clamp-on-text-shrink

Conversation

@Rohan-Dedhia

Copy link
Copy Markdown

Fixes #9767.

Summary

When a text node is shortened programmatically (e.g. via TextNode.spliceText without moveSelection, or via TextNode.setTextContent), an existing RangeSelection point positioned inside that node can be left pointing to an offset beyond the node's new text length. In dev mode, a subsequent operation that normalizes or compares carets (such as Point.isBefore / RangeSelection.isBackward called from splitText) triggers invariant in $getTextNodeOffset with invalid offset X for size Y.

Changes

  1. Active selection adjustment: In TextNode.spliceText (when !moveSelection) and TextNode.setTextContent, clamp any text points in the active RangeSelection that point into the modified node to the new text length, and invalidate cached selection nodes / backward state.
  2. Commit validation: In $beginUpdate (LexicalUpdates.ts), validate and clamp text and element selection points against node boundaries (getTextContentSize() and getChildrenSize()) so that all committed range selections remain strictly bounded.
  3. Tests & Changeset: Added reproduction unit tests in Issue9767Repro.test.ts and patch changeset in .changeset/clamp-selection-on-text-shrink.md.

@vercel

vercel Bot commented Nov 5, 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 5, 2026 10:54am UTC
lexical-playground Ready Ready Preview Nov 5, 2026 10:54am UTC

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 Nov 5, 2026
@meta-code-review

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

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 5, 2026, 11:10 AM 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 picking up #9767. The commit-time bounds check in $beginUpdate is the right fix and resolves the reported throw. Unfortunately, the clamps added to the two setters break text splitting, so this needs changes before it can land.

1. setTextContent clamping the selection breaks splitText (blocking)
packages/lexical/src/nodes/LexicalTextNode.ts:1159-1173 clamps any selection point on the node as soon as its text is set. splitText calls self.setTextContent(firstPart) at :1411, and only afterwards (:1444-1445) reads the anchor/focus offsets it captured at :1384-1395 to move them into the right split node. Those offsets have now been clamped to the end of the first piece, so a caret or range after the split point collapses onto the first piece. Everything that splits a node under the caret is affected:

  • Typing hello #tag world character by character with the hashtag extension gives hello ag world#t. Typing see https://example.com and more with AutoLink gives see m and morehttps://example.co. The playground shows the same scrambling.
  • Existing unit tests that pass on main fail on this branch: the splitText() + select() table in LexicalTextNode.test.tsx (6), MarkStateExtension.test.ts (7), Issue5366Repro.test.ts in lexical-markdown (5: ## becomes h1), incremental typing in LexicalAutoLinkSamples.test.ts (2), MarkFlavors.test.ts (1) and MarkStateProperty.test.ts (1).

Suggested fix: remove the clamps from setTextContent and from spliceText (:1276-1294) and keep only the $beginUpdate check. With just that change, all seven of your new tests pass and the tests above keep passing.

2. The element-point clamp changes insertBefore/insertAfter(node, false)
The element branch in LexicalUpdates.ts:1435-1444 also rewrites committed element offsets. Four existing tests pin the current restoreSelection=false behavior and fail: in LexicalNode.test.ts and LexicalNodeState.test.ts, "Element-anchored selection on old parent (#6031) › … with restoreSelection=false skips the source-parent shift". #9767 only needs text points. Either drop the element branch, or keep it, update those tests, and describe the behavior change in the changeset. AGENTS.md ("Backwards Compatibility") asks that existing behavior be kept unless a change is deliberate.

3. Tests
The new tests reproduce the throw, which is great. expect(out!.focus).toBeLessThanOrEqual(out!.size) (Issue9767Repro.test.ts:83) also passes for offset 0, though. Asserting the exact committed point would pin the behavior. A case with the caret after a splitText boundary, or typing through a splitting transform, would have caught issue 1. Please also add a ## Test plan with real ### Before/### After output, as AGENTS.md ("Commits and Pull Requests") describes. Running pnpm run test-unit shows the failures above.

4. Optional nits

  • .changeset/clamp-selection-on-text-shrink.md: since this changes observable selection behavior, add a Verify: paragraph as AGENTS.md ("Changesets") asks. For example: "Verify: after spliceText/setTextContent shortens a selected text node, the committed selection sits at the node's new end and a following splitText no longer throws in dev."
  • In $beginUpdate, point.set(key, size, type) would also mark the selection dirty and keep the composition key in sync, instead of assigning offset and clearing the caches by hand.

Comment on lines +1159 to +1173
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const key = self.__key;
let pointMutated = false;
for (const pt of [selection.anchor, selection.focus]) {
if (pt.type === 'text' && pt.key === key && pt.offset > text.length) {
pt.offset = text.length;
pointMutated = true;
}
}
if (pointMutated) {
selection._cachedNodes = null;
selection._cachedIsBackward = null;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This clamp runs inside splitText too: splitText calls self.setTextContent(firstPart) (line 1411) and only then reads the anchor/focus offsets it saved (lines 1444-1445). A point that belonged in a later piece has already been pulled to the end of the first piece, so carets and ranges collapse there. Typing hello #tag world with the hashtag extension yields hello ag world#t, and the splitText() selection tests in LexicalTextNode.test.tsx fail. The commit-time check in $beginUpdate already fixes #9767 on its own, so this block (and the one in spliceText) can go.

Comment thread packages/lexical/src/LexicalUpdates.ts Outdated
Comment on lines +1435 to +1444
} else if (point.type === 'element' && $isElementNode(node)) {
const size = node.getChildrenSize();
if (point.offset > size) {
point.offset = size;
selectionMutated = true;
} else if (point.offset < 0) {
point.offset = 0;
selectionMutated = true;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This element branch changes what insertBefore/insertAfter(node, false) commit. The four "#6031 … restoreSelection=false skips the source-parent shift" tests in LexicalNode.test.ts and LexicalNodeState.test.ts pin offset 3 here and now fail. #9767 only needs the text branch. If this one stays, please update those tests and mention it in the changeset.

: null;
});
expect(out).not.toBeNull();
expect(out!.focus).toBeLessThanOrEqual(out!.size);

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: toBeLessThanOrEqual(size) also passes for offset 0. Asserting the exact committed points (anchor ab:1, focus c:1) would pin the intended clamp-to-end behavior.

@Rohan-Dedhia

Copy link
Copy Markdown
Author

Thanks for the thorough and helpful review! I have updated the PR to address all feedback:

  • Removed the clamps from \setTextContent\ and \spliceText\ so in-flight selection moves during operations like \splitText\ remain unaffected.
  • Kept the commit-time bounds clamp in \\ restricted strictly to text points, using \point.set(point.key, size, 'text')\ to ensure points are safely updated and marked dirty.
  • Pinned exact committed point assertions (\expect(out!.focus).toBe(out!.size)) in \Issue9767Repro.test.ts.
  • Added the \Verify:\ section to the changeset.

All unit tests pass cleanly locally.

@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 update, this looks good. Moving the fix into the commit-time check in $beginUpdate (packages/lexical/src/LexicalUpdates.ts:1421-1433) resolves #9767 without touching TextNode.setTextContent/spliceText, so splitText keeps the offsets it saved and element points keep their current behavior. Using Point.set there matches how @lexical/y clamps its own points to a node's size, and it keeps the selection's caches and dirty flag consistent. The new Issue9767Repro.test.ts fails on main and passes on this branch, and the tests the previous revision broke (splitText() selection, mark, heading-shortcut, AutoLink typing and the #6031 restoreSelection=false cases) pass again.

A few optional, non-blocking suggestions:

  1. Update the PR description. It still lists an "Active selection adjustment" in spliceText/setTextContent and says $beginUpdate clamps element points with getChildrenSize(). The diff now only clamps text points in $beginUpdate. The description becomes the commit message when this is merged, so it should describe the one change. AGENTS.md ("Commits and Pull Requests") also asks for the title in the form [lexical] Bug Fix: Clamp committed selection points when a text node is shrunk.
  2. Add a Test plan. AGENTS.md ("Commits and Pull Requests") asks for a ## Test plan with ### Before and ### After sections holding the command and its real output, for example pnpm run test-unit packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts on main (4 failed) and on this branch (7 passed).
  3. Nit: Issue9767Repro.test.ts:83 checks only the focus offset. Also asserting the focus key, and that the anchor stays at offset 1 of ab, would pin the whole committed point.

The changeset looks right: 'lexical': patch, a [fix] summary and the Verify: paragraph CONTRIBUTING.md and .changeset/README.md ask for.

This branch was successfully deployed

2 active deployments
Preview – lexical — 9b66bfde Deployed Nov 5, 2026 by vercel[bot]
Preview – lexical-playground — 9b66bfde Deployed Nov 5, 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: [lexical] Shrinking a text node with spliceText or setTextContent commits a selection point past its end; a later splitText throws in dev

1 participant