Fix: update selection format and style on programmatic select - #8824
Fix: update selection format and style on programmatic select#88241012ayush wants to merge 5 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
etrepum
left a comment
There was a problem hiding this comment.
I think this could use some more tests, it's only testing that the selection format gets cleared, not that it gets set to exactly what the TextNode format and style are. It might also make sense to use the format of the ElementNode if it's not on a TextNode.
|
@etrepum Thanks for the review! That makes total sense. |
|
ElementNode has getTextFormat and getTextStyle |
etrepum
left a comment
There was a problem hiding this comment.
Unfortunately these methods are used in scenarios where it is not expected to change the selection's format or style and applying this change will cause regressions that the test suite does not currently catch.
Here's a deeper analysis from claude, the tests and alternative fix it suggests are in this branch. I have not thoroughly audited any of claude's output here beyond confirming that there are regressions caused by this PR's current approach.
Review: format/style inheritance on programmatic selection
The direction is right — programmatic selection should adopt the target node's format/style the way an interactive move already does. But hooking this into TextNode.select() / ElementNode.select() breaks the "pending format" contract those methods have always upheld: core mutation flows call .select() mid-operation while selection.format/style deliberately carry a toggle that hasn't been committed to a node yet (the mechanism onSelectionChange protects with its 200ms collapsedSelectionFormat window). Every such call site now clobbers that pending state.
Verifying the maintainer's comment — CONFIRMED
At LexicalSelection.ts:965-968, when typing at the end of a token/segmented node with a mergeable sibling, nextSibling.select(0, 0) hits the new isDifferentNode branch (selection is still anchored on the token node), overwriting selection.format/style with the sibling's values before the recursive this.insertText(text) at line 971 re-reads this.format.
Concretely: caret at end of @mention, italic sibling, user toggles bold, types x → pre-PR the format-mismatch branch (~1047-1052) created a new bold TextNode; post-PR x is spliced into the sibling as italic.
One correction to the framing: the bug isn't limited to "sibling has different formatting." The offset-0 mirror path (prevSibling.select(), line 998) loses selection.style even when a brand-new sibling is created, because that branch calls setFormat but never setStyle (line 989).
This generalizes — other confirmed regressions from the same root cause
| Flow | Site | Symptom |
|---|---|---|
insertText token/segmented redirect |
LexicalSelection.ts:968,998 |
typed text at a token boundary gets the sibling's format; offset-0 path also drops pending style |
insertParagraph (Enter) |
LexicalSelection.ts:1600 |
newBlock.selectStart() lands on moved trailing TextNode → pending bold dropped; the block was just seeded with rangeSelection.format, so state is internally inconsistent |
insertNodes |
LexicalSelection.ts:1425 (+1389,1407,1441,1470,1556) and .select(index,index) at 1443/1496 |
typing after a programmatic inline insert no longer continues in the active format |
$removeSegment (backspace into segmented) |
LexicalSelection.ts:2942 |
select() runs while anchor is on an adjacent node → next char adopts the segmented node's format (surfaces via the collapsedSelectionFormat window) |
$onCompositionEndImpl |
LexicalEvents.ts:1497 |
IME text committed on a token node while the anchor moved away inherits the token's format |
Design-level findings
- Incomplete for the stated goal. The fix only covers
.select(). Fresh selections still hardcodeformat=0/style=''($internalMakeRangeSelection), and the caret utilities ($updateRangeSelectionFromCaretRange, used bydeleteCharacter/removeText), directanchor.set/focus.set, and$createRangeSelection+$setSelectionnever touch format/style — so identical programmatic moves yield different results depending on which API ran and what selection existed before. The PR's own scenario still fails on the no-prior-selection branch. - Diverges from the interactive path.
onSelectionChangecomputes a non-collapsed format as the AND-intersection of covered TextNodes (and leaves style untouched), and for a non-empty element point it preserves format / clears style. The PR stamps a single node's raw format+style for all of these — e.g.root.select(0, childrenCount)over all-bold content yieldsformat=0, so a toolbar reads bold off wherectrl-Areads it on. - Duplication / altitude. The stamped logic re-implements the existing
$updateSelectionFormatStyleFromTextNode/…FromElementNodehelpers inLexicalEvents.ts, and theisDifferentNode"same-key keeps format" idea already exists in$internalCreateRangeSelection. Three copies of this policy now evolve separately. isDifferentNodekey comparison. The OR over both points means collapsing a cross-node range back onto its own anchor, orP.selectStart()redirecting an element pointP:0to childT:0(same caret position), counts as a node change and wipes pending format.- Test coverage. In the added test every assertion sits inside a bare
if ($isRangeSelection(selection))guard (vacuous green ifselectEnd()ever returns a non-range selection), and$getRoot().selectEnd()resolves toTextNode.select— theElementNode.selecthunk has zero coverage. - Backwards-compat. This changes observable behavior of a public API, which
AGENTS.md→ Backwards Compatibility asks to flag; worth a changelog note regardless of approach.
Suggested alternative — fix at the update layer, not in select()
Detect a purely programmatic selection move at the end of $beginUpdate and refresh format/style only then. The discriminator:
editor._dirtyType === NO_DIRTY_NODES— no nodes mutated, so it can't be one of the flows carrying a pending toggle (insertText / Enter / delete / paste all dirty nodes);- the anchor moved to a different node than before the update ran;
format/stylewere not explicitly assigned during the update (aformatTexttoggle or manual assignment always wins).
Then call a single $refreshSelectionFormatStyle(selection) — extracted from onSelectionChange into LexicalSelection.ts and shared by both paths, so interactive and programmatic behavior can't drift (non-collapsed → intersection, empty element → its textFormat, etc.).
This fixes the same stale-format bug, covers the entry points the current PR misses (fresh selections, caret utilities, $createRangeSelection+$setSelection), leaves deletion/mutation flows' intentional format-preservation intact, and removes the duplication. Net +402 / −74 across 4 files (LexicalEvents.ts shrinks 74 lines).
Test matrix (LexicalSelectionPendingFormat.test.ts, 9 tests, run in all three states):
| Test | main |
main + this PR |
main + alternative |
|---|---|---|---|
| Type at token end → pending bold, not sibling italic (the maintainer's case) | pass | FAIL | pass |
Type at token start keeps pending style |
pass | FAIL | pass |
| Enter preserves pending toggle | pass | FAIL | pass |
insertNodes preserves pending toggle |
pass | FAIL | pass |
| Programmatic select-all over bold content reports bold | pass | FAIL | pass |
| Backspace into segmented node doesn't adopt its format | pass | pass* | pass |
| Programmatic move inherits target format/style (PR goal) | FAIL | pass | pass |
| …with no prior RangeSelection | FAIL | FAIL | pass |
Select an empty element inherits its textFormat |
FAIL | pass | pass |
* The $removeSegment clobber is real in code but only visible through the browser's 200ms format window, which a jsdom unit test doesn't exercise.
The PR's own added test also passes on top of the alternative; full monorepo unit suite stays green (4761 passed).
|
@etrepum, thank you for taking the time to perform this deep analysis. I completely understand how hooking into .select() was clobbering the 'pending format' state and causing these regressions. Do you recommend I proceed with implementing this $refreshSelectionFormatStyle extraction, or would you prefer I work from the specific branch you mentioned where this alternative was drafted? |
|
Most of what I pasted above was directly from Claude's review, hence the "I have not thoroughly audited any of claude's output here beyond confirming that there are regressions caused by this PR's current approach." I don't have any preference as to your approach. You're welcome to use any part of the code that claude generated in that branch, or none of it. Once you have something that works better without breaking compatibility, I'll give it a closer review. |
Current Behavior:
When moving a selection programmatically (e.g., using $getRoot().selectStart() or $getRoot().selectEnd()), the RangeSelection object updates its anchor and focus coordinates, but fails to clear its format and style caches. This results in the new selection retaining stale formatting data from the previous cursor position.
Changes in this PR:
Added an isDifferentNode check (selection.anchor.key !== key || selection.focus.key !== key) to the select() methods in LexicalTextNode.ts and LexicalElementNode.ts.
The format and style caches are now safely flushed only when the selection jumps to a completely new node boundary.
This resolves the stale cache bug for programmatic movements while protecting the high-frequency select() calls used by the core engine during active text composition.
(Note: This is a fresh PR replacing my previously closed PR #8819 with the finalized, tested code).
Closes #8817
Test plan
Before
If a user selected text with a specific format (e.g., bold) and a programmatic selection jump was triggered to an unformatted node, $getSelection().hasFormat('bold') would incorrectly remain true because the bitmask cache was never cleared.
After
Programmatic selection changes now correctly evaluate the destination node's formatting. Moving the selection to an unformatted node now correctly returns false for $getSelection().hasFormat('bold').
Added a dedicated unit test (programmatic selection movement clears stale formatting) in LexicalSelection.test.ts to verify the cache is flushed correctly on node jumps.
Verified that DOM and Unit test environments pass cleanly.