Repository navigation
Description
Summary
TextNode.spliceText(offset, delCount, '') (without moveSelection) and TextNode.setTextContent(shorter) leave a RangeSelection point that was inside the node at its old offset. The update commits that selection although the offset is now larger than the node's text size. The next update that compares the points (RangeSelection.isBackward, reached from TextNode.splitText on another node) throws in dev:
Error: $getTextNodeOffset: invalid offset 3 for size 1 at key 7
at $getTextNodeOffset (packages/lexical/src/caret/LexicalCaret.ts)
at $getTextPointCaret (packages/lexical/src/caret/LexicalCaret.ts)
at $caretFromPoint (packages/lexical/src/caret/LexicalCaretUtils.ts)
at Point.isBefore (packages/lexical/src/LexicalSelection.ts)
at RangeSelection.isBackward (packages/lexical/src/LexicalSelection.ts)
at TextNode.splitText (packages/lexical/src/nodes/LexicalTextNode.ts)
In production $getTextNodeOffset clamps, so the throw is dev-only, but the committed editor state still holds an out-of-range selection.
Repro
A paragraph with two text nodes, ab and cde. The selection runs from offset 1 of ab to offset 3 of cde. Then:
editor.update(() => $getRoot().getLastDescendant().spliceText(1, 2, ''), {discrete: true});
// committed: focus = {key of "c", offset: 3}, but "c" has size 1
editor.update(() => $getRoot().getFirstDescendant().splitText(1), {discrete: true});
// dev: Error: $getTextNodeOffset: invalid offset 3 for size 1Test file (vitest, under packages/lexical/src/__tests__/unit/). It builds the editor with buildEditorFromExtensions(RichTextExtension) and has 7 cells:
SelectionPastEnd.test.ts
/**
* 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');
});Expected
After any update, every committed RangeSelection text point is within 0..getTextContentSize() of its node. A shrink that cuts under a point should move the point, for example by clamping it to the new end or shifting it by the deleted length. Later APIs should not throw.
Actual
spliceText(1, 2, ''): the committed focus offset is 3 on a node of size 1, and the latersplitTextthrows.setTextContent('a'): same result.
Controls
spliceText(1, 2, '', true)(moveSelection): the selection is valid and nothing throws.- An in-bounds selection (focus at offset 1 of
cde) followed by the samespliceText(1, 2, '')andsplitText: nothing throws. - Plain Lexical: no Yjs, no collaboration, no migration.
Result on main 23ddbfc: 4 failed, 3 passed. The 4 failures are the two shrinks × two checks; the 3 passes are the controls.
Notes
spliceText's doc describes moveSelection as optional, which reads as "leave the selection where it is". The commit step in $beginUpdate validates that the selected nodes exist, not that the offsets are in range. One place to fix it is there: clamp text points to the node size, the same way $getTextNodeOffset(…, 'clamp') does in prod. The other is in the two setters.
Activity
[lexical] Refactor: Preserve original selection offsets in splitText …
[lexical] Bug Fix: Clamp text selection points past the end of a shru…