Skip to content

[lexical][lexical-list][lexical-selection][lexical-link] Refactor: Centralize replace-area selection mapping + bulk splice#8505

Merged
etrepum merged 1 commit into
facebook:mainfrom
mayrang:refactor/replace-area-cleanup-follow-up
May 13, 2026
Merged

[lexical][lexical-list][lexical-selection][lexical-link] Refactor: Centralize replace-area selection mapping + bulk splice#8505
etrepum merged 1 commit into
facebook:mainfrom
mayrang:refactor/replace-area-cleanup-follow-up

Conversation

@mayrang

@mayrang mayrang commented May 12, 2026

Copy link
Copy Markdown
Contributor

Description

Follow-up to #8501. PR #8501 taught LexicalNode.replace to map an element-anchored selection on the replaced node to {key: replacement, offset: prevSize + originalOffset, type: 'element'} and left two pieces for this PR: the same mapping in ListItemNode.replace (an override that bypasses super.replace), and removal of the $setBlocksType workaround that compensated for the lossy pre-#8501 behavior.

What changed

  • ListItemNode.replace mirrors the base prevSize + offset mapping in its includeChildren branch. The mapping runs before the trailing this.remove() so the subsequent $removeNode → moveSelectionPointToSibling sees anchor/focus already redirected to the replacement and is a no-op for those points. Text-anchored selections inside transferred children follow their text node's key across the children move, so no extra mapping is needed for that case. This catches the override up to the contract base replace started honoring in [Breaking Change][lexical] Bug Fix: Adjust selection when removeFromParent callers move a node out of its parent #8501.
  • $setBlocksType drops the cloned-newSelection + per-iteration override + selection.is($getSelection()) commit. With base and list-item replace paths both handling element-anchored mapping, the caller-side workaround is redundant. Unused $createRangeSelection and $getSelection imports removed.
  • getChildren().forEach(parent.append) patterns collapse to a single parent.splice(parent.getChildrenSize(), 0, getChildren()) call at the four sites in this area: base LexicalNode.replace, ListItemNode.replace, $setBlocksType's wrap branch, and $toggleLink's unlink branch. Since append is splice-of-1, this is one bookkeeping pass instead of N.
  • $toggleLink unlink: the original parentLink.getChildren().forEach(c => parentLink.insertBefore(c)) is a "promote children to siblings before self" shape. Splice equivalent: parentLink.getParentOrThrow().splice(parentLink.getIndexWithinParent(), 0, parentLink.getChildren()). parentLink was just located via $findMatchingParent on a descendant so its parent is guaranteed — getParentOrThrow() keeps that as a loud invariant rather than a silent skip.

Note on $toggleLink selection adjustment

The forEach(insertBefore) → single splice substitution has a subtle semantic difference: each insertBefore calls $updateElementSelectionOnCreateDeleteNode (one selection shift per insert), while a single splice with deleteCount=0 does not. In practice this path runs only when selection.isCollapsed() && url === null and the collapsed selection sits inside the link's text content, so no element-anchored selection in the grandparent at the link's index is reachable. Behavior unchanged for the documented entry conditions.

Closes the follow-up scope discussed at #8501 (comment)

Test plan

  • pnpm vitest run --project unit — full unit suite (114 files, 2565 tests + 1 skipped) green.
  • Affected packages (lexical / lexical-list / lexical-selection / lexical-link / lexical-markdown) — 1361 tests pass. The existing $setBlocksType describe block in LexicalSelection.test.tsx covers the workaround-removal regression surface; the list-item regression that blocked the cleanup attempt during [Breaking Change][lexical] Bug Fix: Adjust selection when removeFromParent callers move a node out of its parent #8501 (the "Expected ListItemNode to have block ElementNode ancestor" invariant) does not reproduce on this branch.
  • tsc / prettier / eslint clean.

@vercel

vercel Bot commented May 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment May 13, 2026 4:08pm
lexical-playground Ready Ready Preview, Comment May 13, 2026 4:08pm

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 May 12, 2026
Comment on lines +323 to +337
const {anchor, focus} = selection;
if (anchor.key === toReplaceKey && anchor.type === 'element') {
anchor.set(
replaceWithNode.getKey(),
prevSizeBeforeChildrenTransfer + anchor.offset,
'element',
);
}
if (focus.key === toReplaceKey && focus.type === 'element') {
focus.set(
replaceWithNode.getKey(),
prevSizeBeforeChildrenTransfer + focus.offset,
'element',
);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe we should refine the type of RangeSelection.getStartEndPoints() to be strictly [PointType, PointType] to turn this sort of code into a loop, e.g.

Suggested change
const {anchor, focus} = selection;
if (anchor.key === toReplaceKey && anchor.type === 'element') {
anchor.set(
replaceWithNode.getKey(),
prevSizeBeforeChildrenTransfer + anchor.offset,
'element',
);
}
if (focus.key === toReplaceKey && focus.type === 'element') {
focus.set(
replaceWithNode.getKey(),
prevSizeBeforeChildrenTransfer + focus.offset,
'element',
);
}
for (const point of selection.getStartEndPoints()) {
if (point.key === toReplaceKey && point.type === 'element') {
point.set(
replaceWithNode.getKey(),
prevSizeBeforeChildrenTransfer + point.offset,
'element',
);
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Narrowed RangeSelection.getStartEndPoints() to [PointType, PointType] (matches the shape TableSelection.getStartEndPoints() already returns) and dropped the per-point branches for the suggested loop. Also cleaned two callsites the narrower type made dead: the ! in markSelection.ts $getOrderedSelectionPoints and the invariant !== null in formatList.ts $insertList. Left the BaseSelection-typed callsites as-is since NodeSelection.getStartEndPoints() still returns null.

…ntralize replace-area selection mapping + bulk splice

Follow-up to facebook#8501. The base LexicalNode.replace now maps element-anchored selections from the replaced node to {key: replacement, offset: prevSize + originalOffset, type: 'element'}. Two pieces were held back for this PR: the same mapping in ListItemNode.replace (an override that bypasses super.replace), and removal of the \$setBlocksType workaround that compensated for the lossy pre-facebook#8501 behavior.

ListItemNode.replace now mirrors the base prevSize + offset mapping in the includeChildren branch. The mapping runs before the trailing this.remove() so the subsequent \$removeNode → moveSelectionPointToSibling sees anchor/focus already redirected and is a no-op for those points. Text-anchored selections inside transferred children follow their text node's key across the children move, so no extra mapping is needed there.

\$setBlocksType drops the cloned-newSelection + per-iteration override + selection.is(\$getSelection()) commit. Both replace paths now handle the mapping, making the caller-side workaround redundant.

getChildren().forEach(parent.append) patterns collapse to parent.splice(parent.getChildrenSize(), 0, getChildren()) at four sites: base LexicalNode.replace, ListItemNode.replace, \$setBlocksType's wrap branch, and \$toggleLink's unlink branch. Since append is splice-of-1, this is one bookkeeping pass instead of N. \$toggleLink also switches to parentLink.getParentOrThrow() to keep the existence assertion loud.
@mayrang
mayrang force-pushed the refactor/replace-area-cleanup-follow-up branch from f81a755 to 50d67cb Compare May 13, 2026 16:06
@etrepum
etrepum added this pull request to the merge queue May 13, 2026
Merged via the queue into facebook:main with commit 1844508 May 13, 2026
42 checks passed
@etrepum etrepum mentioned this pull request May 28, 2026
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.

2 participants