Skip to content

Fix: update selection format and style on programmatic select - #8824

Open
1012ayush wants to merge 5 commits into
facebook:mainfrom
1012ayush:fix-programmatic-selection-latest
Open

Fix: update selection format and style on programmatic select#8824
1012ayush wants to merge 5 commits into
facebook:mainfrom
1012ayush:fix-programmatic-selection-latest

Conversation

@1012ayush

Copy link
Copy Markdown

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.

@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 Jul 10, 2026
@vercel

vercel Bot commented Jul 10, 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 Jul 11, 2026 5:41pm
lexical-playground Ready Ready Preview Jul 11, 2026 5:41pm

Request Review

@etrepum etrepum left a comment

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.

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.

@1012ayush

Copy link
Copy Markdown
Author

@etrepum Thanks for the review! That makes total sense.
I will update the logic in LexicalTextNode.ts to inherit this.getFormat() and this.getStyle() instead of just clearing them to 0, and I'll expand the unit tests to explicitly verify that jumping to a formatted/styled node correctly updates the selection properties.
Regarding LexicalElementNode: in a previous commit, I tried wiping the format on node jumps in LexicalElementNode.ts, but it caused the E2E typing tests to fail (specifically around line breaks losing format inheritance). Since selection.format expects text formatting bitmasks and ElementNode.getFormat() returns element alignment bitmasks, what is the safest way you'd recommend extracting the correct text format from an ElementNode to apply to the selection here?

@etrepum

etrepum commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

ElementNode has getTextFormat and getTextStyle

@etrepum etrepum left a comment

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.

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 hardcode format=0/style='' ($internalMakeRangeSelection), and the caret utilities ($updateRangeSelectionFromCaretRange, used by deleteCharacter/removeText), direct anchor.set/focus.set, and $createRangeSelection + $setSelection never 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. onSelectionChange computes 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 yields format=0, so a toolbar reads bold off where ctrl-A reads it on.
  • Duplication / altitude. The stamped logic re-implements the existing $updateSelectionFormatStyleFromTextNode / …FromElementNode helpers in LexicalEvents.ts, and the isDifferentNode "same-key keeps format" idea already exists in $internalCreateRangeSelection. Three copies of this policy now evolve separately.
  • isDifferentNode key comparison. The OR over both points means collapsing a cross-node range back onto its own anchor, or P.selectStart() redirecting an element point P:0 to child T: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 if selectEnd() ever returns a non-range selection), and $getRoot().selectEnd() resolves to TextNode.select — the ElementNode.select hunk has zero coverage.
  • Backwards-compat. This changes observable behavior of a public API, which AGENTS.mdBackwards 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:

  1. 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);
  2. the anchor moved to a different node than before the update ran;
  3. format/style were not explicitly assigned during the update (a formatText toggle 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

This comment was marked as off-topic.

@1012ayush

Copy link
Copy Markdown
Author

@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.
I am happy to pivot to your suggested approach of fixing this at the update layer by calling $refreshSelectionFormatStyle(selection) during the $beginUpdate cycle when no nodes are dirty. This makes much more sense for ensuring architectural consistency.

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?

@etrepum

etrepum commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

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.

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. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Programmatically moving a Selection does not update the Formatting nor Style

2 participants