Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/clamp-selection-on-text-shrink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'lexical': patch
---

[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.
39 changes: 35 additions & 4 deletions packages/lexical/src/LexicalUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import type {
LexicalNode,
NodeKey,
ParsableSerializedNode,
SerializedLexicalNode,
SerializedPartialNode,
Expand Down Expand Up @@ -59,6 +60,7 @@ import {
$updateDOMSelection,
applySelectionTransforms,
type BaseSelection,
type RangeSelection,
} from './LexicalSelection';
import {$isSlotHost, $setSlot} from './LexicalSlot';
import {
Expand Down Expand Up @@ -1303,6 +1305,28 @@ function $discardPendingUpdate(
updates.length = kept;
}

function $clampTextPoints(
pendingSelection: RangeSelection,
pendingNodeMap: Map<NodeKey, LexicalNode>,
): 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,
Expand Down Expand Up @@ -1370,6 +1394,12 @@ function $beginUpdate(
const startingCompositionKey = editor._compositionKey;
updateFn();
skipTransforms = $processNestedUpdates(editor, skipTransforms);
if ($isRangeSelection(pendingEditorState._selection)) {
$clampTextPoints(
pendingEditorState._selection,
pendingEditorState._nodeMap,
);
}
Comment on lines +1397 to +1402

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: nothing fails today if this block is removed, because the commit-time call at line 1451 repairs the selection for every case in Issue9767Repro.test.ts. A case where a TextNode transform calls splitText on the shrunk node (or HashtagExtension with setTextContent('a #x') on a selected cdefghij) would pin it; without this block that case throws $getTextNodeOffset: invalid offset 8 for size 4.

applySelectionTransforms(pendingEditorState, editor);

if (editor._dirtyType !== NO_DIRTY_NODES) {
Expand Down Expand Up @@ -1407,17 +1437,18 @@ 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.",
);
}

$clampTextPoints(pendingSelection, pendingNodeMap);
} else if ($isNodeSelection(pendingSelection)) {
// TODO: we should also validate node selection?
if (pendingSelection._nodes.size === 0) {
Expand Down
144 changes: 144 additions & 0 deletions packages/lexical/src/__tests__/unit/Issue9767Repro.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* 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();
const a = $first();
const t = $text();
return s && $isRangeSelection(s)
? {
anchorKey: s.anchor.key,
anchorOffset: s.anchor.offset,
firstKey: a.getKey(),
focusKey: s.focus.key,
focusOffset: s.focus.offset,
size: t.getTextContentSize(),
textKey: t.getKey(),
}
: null;
});
expect(out).not.toBeNull();
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', () => {
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');
});