From 58b9e697f445f8c55c68e24fcaf94b424939fb73 Mon Sep 17 00:00:00 2001 From: Rohan-Dedhia Date: Thu, 5 Nov 2026 13:49:20 +0530 Subject: [PATCH 1/4] [lexical] Bug Fix: Clamp committed selection points when a text node is shrunk (#9767) --- .changeset/clamp-selection-on-text-shrink.md | 5 + packages/lexical/src/LexicalUpdates.ts | 37 ++++- .../src/__tests__/unit/Issue9767Repro.test.ts | 127 ++++++++++++++++++ packages/lexical/src/nodes/LexicalTextNode.ts | 34 +++++ 4 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 .changeset/clamp-selection-on-text-shrink.md create mode 100644 packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts diff --git a/.changeset/clamp-selection-on-text-shrink.md b/.changeset/clamp-selection-on-text-shrink.md new file mode 100644 index 00000000000..3813f2caf91 --- /dev/null +++ b/.changeset/clamp-selection-on-text-shrink.md @@ -0,0 +1,5 @@ +--- +'lexical': patch +--- + +[fix] Clamp committed selection points when a text node is shrunk diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index 053a62a9300..07361c7040a 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -1407,17 +1407,46 @@ function $beginUpdate( const pendingNodeMap = pendingEditorState._nodeMap; const anchorKey = pendingSelection.anchor.key; const focusKey = pendingSelection.focus.key; + const anchorNode = pendingNodeMap.get(anchorKey); + const focusNode = pendingNodeMap.get(focusKey); - if ( - pendingNodeMap.get(anchorKey) === undefined || - pendingNodeMap.get(focusKey) === undefined - ) { + if (anchorNode === undefined || focusNode === undefined) { invariant( false, 'updateEditor: selection has been lost because the previously selected nodes have been removed and ' + "selection wasn't moved to another node. Ensure selection changes after removing/replacing a selected node.", ); } + + let selectionMutated = false; + for (const [point, node] of [ + [pendingSelection.anchor, anchorNode], + [pendingSelection.focus, focusNode], + ] as const) { + if (point.type === 'text' && $isTextNode(node)) { + const size = node.getTextContentSize(); + if (point.offset > size) { + point.offset = size; + selectionMutated = true; + } else if (point.offset < 0) { + point.offset = 0; + selectionMutated = true; + } + } 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; + } + } + } + if (selectionMutated) { + pendingSelection._cachedNodes = null; + pendingSelection._cachedIsBackward = null; + } } else if ($isNodeSelection(pendingSelection)) { // TODO: we should also validate node selection? if (pendingSelection._nodes.size === 0) { diff --git a/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts b/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts new file mode 100644 index 00000000000..75fdfbce65a --- /dev/null +++ b/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts @@ -0,0 +1,127 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +// @vitest-environment node +import {buildEditorFromExtensions, defineExtension} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + $isTextNode, + type LexicalEditor, + type TextNode, +} from 'lexical'; +import {describe, expect, test} from 'vitest'; + +// A programmatic shrink of a text node (spliceText without +// moveSelection, setTextContent) leaves a RangeSelection point past the end +// of the node; the update commits it, and a later splitText on that node +// throws (dev) from $getTextNodeOffset. + +const ext = defineExtension({ + dependencies: [RichTextExtension], + name: '[selection-past-end]', +}); + +function setup(): LexicalEditor { + const editor = buildEditorFromExtensions(ext); + editor.update( + () => { + const a = $createTextNode('ab').toggleFormat('bold'); + const t = $createTextNode('cde'); + $getRoot().clear().append($createParagraphNode().append(a, t)); + const s = t.select(3, 3); + s.anchor.set(a.getKey(), 1, 'text'); + }, + {discrete: true}, + ); + return editor; +} + +const $first = (): TextNode => { + const t = $getRoot().getFirstDescendant(); + if (!$isTextNode(t)) { + throw new Error('no text'); + } + return t; +}; + +const $text = (): TextNode => { + const t = $getRoot().getLastDescendant(); + if (!$isTextNode(t)) { + throw new Error('no text'); + } + return t; +}; + +const shrink = { + setTextContent: () => $text().setTextContent('a'), + spliceText: () => $text().spliceText(1, 2, ''), + 'spliceText, moveSelection (control)': () => + $text().spliceText(1, 2, '', true), +} as const; + +describe.each(Object.entries(shrink))('%s', (name, $shrink) => { + test('the committed selection stays inside the node', () => { + const editor = setup(); + editor.update($shrink, {discrete: true}); + const out = editor.getEditorState().read(() => { + const s = $getSelection(); + return $isRangeSelection(s) + ? {focus: s.focus.offset, size: $text().getTextContentSize()} + : null; + }); + expect(out).not.toBeNull(); + expect(out!.focus).toBeLessThanOrEqual(out!.size); + }); + + test('a later splitText on the node does not throw', () => { + const editor = setup(); + editor.update($shrink, {discrete: true}); + let error: unknown = null; + editor.update( + () => { + try { + $first().splitText(1); + } catch (e) { + error = e; + } + }, + {discrete: true}, + ); + expect(String(error)).toBe('null'); + void name; + }); +}); + +test('control: an in-bounds selection and the same splitText', () => { + const editor = setup(); + editor.update( + () => { + const s = $text().select(1, 1); + s.anchor.set($first().getKey(), 1, 'text'); + }, + {discrete: true}, + ); + editor.update(() => $text().spliceText(1, 2, ''), {discrete: true}); + let error: unknown = null; + editor.update( + () => { + try { + $first().splitText(1); + } catch (e) { + error = e; + } + }, + {discrete: true}, + ); + expect(String(error)).toBe('null'); +}); diff --git a/packages/lexical/src/nodes/LexicalTextNode.ts b/packages/lexical/src/nodes/LexicalTextNode.ts index d35912cf921..0265d40d834 100644 --- a/packages/lexical/src/nodes/LexicalTextNode.ts +++ b/packages/lexical/src/nodes/LexicalTextNode.ts @@ -1156,6 +1156,21 @@ export class TextNode extends LexicalNode implements InlineFormattableNode { const self = this.getWritable(); $recordTextContent(self, text); self.__text = text; + 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; + } + } return self; } @@ -1258,6 +1273,25 @@ export class TextNode extends LexicalNode implements InlineFormattableNode { const updatedText = text.slice(0, index) + newText + text.slice(index + delCount); + if (!moveSelection && $isRangeSelection(selection)) { + const key = writableSelf.__key; + let pointMutated = false; + for (const pt of [selection.anchor, selection.focus]) { + if ( + pt.type === 'text' && + pt.key === key && + pt.offset > updatedText.length + ) { + pt.offset = updatedText.length; + pointMutated = true; + } + } + if (pointMutated) { + selection._cachedNodes = null; + selection._cachedIsBackward = null; + } + } + const editor = getActiveEditor(); if (editor._recordTextEdits) { $textEditLog(editor).push( From 9b66bfdee9a918ebaf11629c4ef657e148915782 Mon Sep 17 00:00:00 2001 From: Rohan-Dedhia Date: Thu, 5 Nov 2026 16:21:17 +0530 Subject: [PATCH 2/4] fix: address review feedback, clamp text points in beginUpdate only --- .changeset/clamp-selection-on-text-shrink.md | 2 ++ packages/lexical/src/LexicalUpdates.ts | 20 ++--------- .../src/__tests__/unit/Issue9767Repro.test.ts | 4 +-- packages/lexical/src/nodes/LexicalTextNode.ts | 34 ------------------- 4 files changed, 6 insertions(+), 54 deletions(-) diff --git a/.changeset/clamp-selection-on-text-shrink.md b/.changeset/clamp-selection-on-text-shrink.md index 3813f2caf91..0d724f495ee 100644 --- a/.changeset/clamp-selection-on-text-shrink.md +++ b/.changeset/clamp-selection-on-text-shrink.md @@ -3,3 +3,5 @@ --- [fix] Clamp committed selection points when a text node is shrunk + +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. diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index 07361c7040a..1462a62d660 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -1418,7 +1418,6 @@ function $beginUpdate( ); } - let selectionMutated = false; for (const [point, node] of [ [pendingSelection.anchor, anchorNode], [pendingSelection.focus, focusNode], @@ -1426,27 +1425,12 @@ function $beginUpdate( if (point.type === 'text' && $isTextNode(node)) { const size = node.getTextContentSize(); if (point.offset > size) { - point.offset = size; - selectionMutated = true; + point.set(point.key, size, 'text'); } else if (point.offset < 0) { - point.offset = 0; - selectionMutated = true; - } - } 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; + point.set(point.key, 0, 'text'); } } } - if (selectionMutated) { - pendingSelection._cachedNodes = null; - pendingSelection._cachedIsBackward = null; - } } else if ($isNodeSelection(pendingSelection)) { // TODO: we should also validate node selection? if (pendingSelection._nodes.size === 0) { diff --git a/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts b/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts index 75fdfbce65a..e52e31ad129 100644 --- a/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts +++ b/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts @@ -75,12 +75,12 @@ describe.each(Object.entries(shrink))('%s', (name, $shrink) => { editor.update($shrink, {discrete: true}); const out = editor.getEditorState().read(() => { const s = $getSelection(); - return $isRangeSelection(s) + return s && $isRangeSelection(s) ? {focus: s.focus.offset, size: $text().getTextContentSize()} : null; }); expect(out).not.toBeNull(); - expect(out!.focus).toBeLessThanOrEqual(out!.size); + expect(out!.focus).toBe(out!.size); }); test('a later splitText on the node does not throw', () => { diff --git a/packages/lexical/src/nodes/LexicalTextNode.ts b/packages/lexical/src/nodes/LexicalTextNode.ts index 0265d40d834..d35912cf921 100644 --- a/packages/lexical/src/nodes/LexicalTextNode.ts +++ b/packages/lexical/src/nodes/LexicalTextNode.ts @@ -1156,21 +1156,6 @@ export class TextNode extends LexicalNode implements InlineFormattableNode { const self = this.getWritable(); $recordTextContent(self, text); self.__text = text; - 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; - } - } return self; } @@ -1273,25 +1258,6 @@ export class TextNode extends LexicalNode implements InlineFormattableNode { const updatedText = text.slice(0, index) + newText + text.slice(index + delCount); - if (!moveSelection && $isRangeSelection(selection)) { - const key = writableSelf.__key; - let pointMutated = false; - for (const pt of [selection.anchor, selection.focus]) { - if ( - pt.type === 'text' && - pt.key === key && - pt.offset > updatedText.length - ) { - pt.offset = updatedText.length; - pointMutated = true; - } - } - if (pointMutated) { - selection._cachedNodes = null; - selection._cachedIsBackward = null; - } - } - const editor = getActiveEditor(); if (editor._recordTextEdits) { $textEditLog(editor).push( From 4a4af3417e55ab69424b2d24bc3a5a4d26fdb114 Mon Sep 17 00:00:00 2001 From: Rohan-Dedhia Date: Thu, 5 Nov 2026 19:15:39 +0530 Subject: [PATCH 3/4] test(lexical): assert anchor and focus keys in Issue9767Repro --- .../src/__tests__/unit/Issue9767Repro.test.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts b/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts index e52e31ad129..162d52c495e 100644 --- a/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts +++ b/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts @@ -75,12 +75,29 @@ describe.each(Object.entries(shrink))('%s', (name, $shrink) => { editor.update($shrink, {discrete: true}); const out = editor.getEditorState().read(() => { const s = $getSelection(); + const a = $first(); + const t = $text(); return s && $isRangeSelection(s) - ? {focus: s.focus.offset, size: $text().getTextContentSize()} + ? { + anchorKey: s.anchor.key, + anchorOffset: s.anchor.offset, + focusKey: s.focus.key, + focusOffset: s.focus.offset, + firstKey: a.getKey(), + textKey: t.getKey(), + size: t.getTextContentSize(), + } : null; }); expect(out).not.toBeNull(); - expect(out!.focus).toBe(out!.size); + if (name.includes('moveSelection')) { + expect(out!.anchorKey).toBe(out!.textKey); + } else { + expect(out!.anchorKey).toBe(out!.firstKey); + } + expect(out!.anchorOffset).toBe(1); + expect(out!.focusKey).toBe(out!.textKey); + expect(out!.focusOffset).toBe(out!.size); }); test('a later splitText on the node does not throw', () => { From 1aaa1a773c27e0b3dac00e18a55af73ba01ce5d3 Mon Sep 17 00:00:00 2001 From: Rohan-Dedhia Date: Thu, 5 Nov 2026 19:47:16 +0530 Subject: [PATCH 4/4] fix(lexical): clamp text selection points before node transforms and fix test key sorting --- packages/lexical/src/LexicalUpdates.ts | 44 +++++++++++++------ .../src/__tests__/unit/Issue9767Repro.test.ts | 4 +- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index 1462a62d660..adaab2bc6e0 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -8,6 +8,7 @@ import type { LexicalNode, + NodeKey, ParsableSerializedNode, SerializedLexicalNode, SerializedPartialNode, @@ -59,6 +60,7 @@ import { $updateDOMSelection, applySelectionTransforms, type BaseSelection, + type RangeSelection, } from './LexicalSelection'; import {$isSlotHost, $setSlot} from './LexicalSlot'; import { @@ -1303,6 +1305,28 @@ function $discardPendingUpdate( updates.length = kept; } +function $clampTextPoints( + pendingSelection: RangeSelection, + pendingNodeMap: Map, +): void { + const anchorNode = pendingNodeMap.get(pendingSelection.anchor.key); + const focusNode = pendingNodeMap.get(pendingSelection.focus.key); + + for (const [point, node] of [ + [pendingSelection.anchor, anchorNode], + [pendingSelection.focus, focusNode], + ] as const) { + if (node !== undefined && point.type === 'text' && $isTextNode(node)) { + const size = node.getTextContentSize(); + if (point.offset > size) { + point.set(point.key, size, 'text'); + } else if (point.offset < 0) { + point.set(point.key, 0, 'text'); + } + } + } +} + function $beginUpdate( editor: LexicalEditor, updateFn: () => void, @@ -1370,6 +1394,12 @@ function $beginUpdate( const startingCompositionKey = editor._compositionKey; updateFn(); skipTransforms = $processNestedUpdates(editor, skipTransforms); + if ($isRangeSelection(pendingEditorState._selection)) { + $clampTextPoints( + pendingEditorState._selection, + pendingEditorState._nodeMap, + ); + } applySelectionTransforms(pendingEditorState, editor); if (editor._dirtyType !== NO_DIRTY_NODES) { @@ -1418,19 +1448,7 @@ function $beginUpdate( ); } - for (const [point, node] of [ - [pendingSelection.anchor, anchorNode], - [pendingSelection.focus, focusNode], - ] as const) { - if (point.type === 'text' && $isTextNode(node)) { - const size = node.getTextContentSize(); - if (point.offset > size) { - point.set(point.key, size, 'text'); - } else if (point.offset < 0) { - point.set(point.key, 0, 'text'); - } - } - } + $clampTextPoints(pendingSelection, pendingNodeMap); } else if ($isNodeSelection(pendingSelection)) { // TODO: we should also validate node selection? if (pendingSelection._nodes.size === 0) { diff --git a/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts b/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts index 162d52c495e..3122ed1adb2 100644 --- a/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts +++ b/packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts @@ -81,11 +81,11 @@ describe.each(Object.entries(shrink))('%s', (name, $shrink) => { ? { anchorKey: s.anchor.key, anchorOffset: s.anchor.offset, + firstKey: a.getKey(), focusKey: s.focus.key, focusOffset: s.focus.offset, - firstKey: a.getKey(), - textKey: t.getKey(), size: t.getTextContentSize(), + textKey: t.getKey(), } : null; });