[Breaking Change][lexical] Bug Fix: Adjust selection when removeFromParent callers move a node out of its parent#8501
Conversation
…e a node out of its parent `removeFromParent` is a low-level pointer-only operation that does not touch the editor's selection. `replace`, `insertAfter`, and `insertBefore` call it as part of moving a node from one parent to another, but they did not adjust element-anchored offsets in the old parent, leaving them stale (and sometimes out of range) once the old parent's child count dropped. Each of the three call sites now captures the moved node's old parent and index before `removeFromParent`, then calls `$updateElementSelectionOnCreateDeleteNode(..., -1)` so any element-anchored offset past the removed index shifts by one. The existing post-insertion adjustment is unchanged, so within-parent moves also stay in range. `removeFromParent` keeps its signature; its JSDoc now documents the caller contract. Closes facebook#6031.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
It might make sense to extend this sort of behavior to other methods, e.g. replace which currently has a $moveSelectionPointToEnd workaround that we are probably better off not having because other functions end up having to undo it like $setBlocksType const parent = $createParagraphNode().append($createLineBreakNode(), $createLineBreakNode(), $createLineBreakNode());
$getRoot().clear().append(parent);
parent.select(1,1);
const newParent = $createParagraphNode().append('before');
const prevSize = newParent.getChildrenSize();
parent.replace(newParent, true);
const selection = $getSelection();
assert($isRangeSelection(selection));
expect(selection.isCollapsed()).toBe(true);
expect(selection.anchor).toMatchObject({key: newParent.getKey(), offset: prevSize + 1}); |
…aps element-anchored points by prevSize + originalOffset Per facebook#8501 review feedback (facebook#8501 (comment)): extend the selection-tracks-node-mutation pattern from facebook#6031 to `replace`'s element-anchored case. Previously, an element-anchored point on the replaced node was relocated to the receiver's last descendant via `$moveSelectionPointToEnd`, losing the relative position. The point now maps to `{key: other.__key, offset: prevSize + originalOffset, type: 'element'}` where `prevSize = other.getChildrenSize()` before the children transfer. Receivers with no pre-existing children (`$setBlocksType`'s freshly-created receivers) see `prevSize = 0`, so the original offset is preserved end-to-end. Falls back to the legacy `$moveSelectionPointToEnd` for `!includeChildren` (children destroyed, no relative position to preserve) and for non-element points (text-anchored selections inside transferred children follow their text node's key, which is preserved across the transfer). Tests cover the maintainer's snippet plus non-collapsed, text-anchored, !includeChildren-legacy, and prevSize=0 shapes.
|
Applied the Held back on the |
|
I think that's a good rationale for a follow-up, and maybe while we're in there it would make sense to do some general cleanup/optimization by using |
|
Thanks — tried the swap locally on the |
…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.
Description
LexicalNode.prototype.replace,insertAfter, andinsertBeforemove a node from one parent to another by calling the internalremoveFromParenthelper, then re-wiring the node into its new home.removeFromParentis a low-level pointer-only operation that does not touch the editor's selection, and the three callers also did not adjust element-anchored offsets in the old parent. An{key: oldParentKey, type: 'element', offset: N}selection withN > removedIndexwas left pointing past the now-shrunk parent's last child, sometimes out of range entirely (e.g. cursor at end of source, mover at index 0).Each caller now captures the moved node's old parent and index before
removeFromParent, then calls$updateElementSelectionOnCreateDeleteNode(selection, oldParent, oldIndex, -1)so element-anchored offsets past the removed index shift by one. The existing post-insertion+1adjustment ininsertBefore/insertAfteris unchanged, so within-parent moves stay in range throughout — previously the cursor at end of source went out of range while the parent was momentarily shrunk before the re-insertion.The fix lives in the callers rather than
removeFromParentbecause importing$updateElementSelectionOnCreateDeleteNodeintoLexicalUtils.tscreates a circular dependency withLexicalSelection.ts(fails atTabNode extends TextNodeload withClass extends value undefined).removeFromParentkeeps its signature; its JSDoc now documents the caller contract and points at the four call sites ($removeNode,replace,insertBefore,insertAfter) that follow the pattern.Per review feedback (#8501 (comment)) the same selection-tracks-node-mutation pattern is also applied inside
replace's element-anchored case: instead of$moveSelectionPointToEndrelocating the point to the receiver's last descendant, the point now maps to{key: other.__key, offset: prevSize + originalOffset, type: 'element'}whereprevSizeisother.getChildrenSize()before the children transfer. Receivers with no pre-existing children (e.g.$setBlocksType's freshly-created receivers) seeprevSize = 0so the original offset is preserved end-to-end.$moveSelectionPointToEndstays as the fallback for!includeChildren(children destroyed, no relative position to preserve) and non-element points (text-anchored selections inside transferred children follow their text node's key, preserved across the transfer).Breaking Change
Public methods now adjust the editor's selection differently after a node move:
replace/insertAfter/insertBefore. Previously, an element-anchored selection in the source parent withoffset > removedIndexwas left unadjusted, sometimes pointing past the parent's last child. Now the offset shifts by-1. Downstream code that read$getSelection()immediately after one of these calls and relied on the stale offset will observe a different value.insertBefore/insertAfter/replace. Previously the cursor at the end of the parent could briefly be left out of range whileremoveFromParentshrank the parent before the re-insertion grew it back. Now the pre-removal-1shift composes with the existing post-insertion+1, so element-anchored offsets stay in range. Forreplace, this also means a within-parent cursor past the removed slot now shifts by-1instead of staying put, matching the cross-parent case.replace(other, true)with an element-anchored point on the replaced node. Previously,$moveSelectionPointToEndrelocated the point to the receiver's last descendant, losing the relative position. Now the point maps to{key: other.__key, offset: prevSize + originalOffset, type: 'element'}whereprevSize = other.getChildrenSize()before the children transfer. Receivers with no pre-existing children (e.g.$setBlocksType's freshly-created receivers) seeprevSize = 0, so the original offset is preserved. The legacy$moveSelectionPointToEndfallback is kept for!includeChildrenand non-element points (where there's no children-mapping to preserve).Closes #6031
Test plan
pnpm vitest run --project unit— 114 files / 2540 tests pass. ExistingElement-anchored selection on old parent (#6031)block plus the newreplace(other, includeChildren) selection mappingblock inLexicalNode.test.ts:test.forover the three methods covering: cross-parent past-boundary shift / boundary preservation / non-collapsed selection / unrelated-parent untouched /restoreSelection: falseskip / within-parent no-op end-cursor preservation.replaceselection mapping block — element-anchored point maps toprevSize + originalOffset(etrepum's snippet) / non-collapsed anchor+focus both shift / text-anchored selection inside transferred children unaffected /!includeChildrenfalls back to legacy$moveSelectionPointToEnd/ fresh-receiver (prevSize=0) preserves original offset.pnpm tsc --noEmit -p tsconfig.jsonclean.