Skip to content

[lexical-plain-text][lexical-selection] Bug Fix: Move the caret beside a node-selected decorator on an arrow key - #9710

Open
therealemmanueloppong wants to merge 3 commits into
meta:mainfrom
therealemmanueloppong:fix-plain-text-node-selection-arrows
Open

therealemmanueloppong wants to merge 3 commits into
meta:mainfrom
therealemmanueloppong:fix-plain-text-node-selection-arrows

Conversation

@therealemmanueloppong

Copy link
Copy Markdown

Description

Current behavior: in a plain-text editor, a click on a decorator such as the horizontal rule gives a NodeSelection. Plain text answers the left and right arrows only for a RangeSelection and has no up or down listener, so the browser's default runs. The browser's range was removed for the node selection, so the caret starts from the top of the document while Lexical still holds the node selection, and what is typed next goes to the top of the document (#4214 is the same report).

This PR gives plain text's four arrow listeners the node-selection branch rich text already has: the caret moves to the decorator's side, and with Shift a contiguous node selection becomes the range it covers for the key to extend. Rich text's $exitNodeSelectionToward and $convertContiguousNodeSelection move unchanged to @lexical/selection (the issue's suggestion; plain text already depends on that package) and rich text imports them from there. That adds two exports to @lexical/selection and its Flow types; if you would rather keep them internal, say so and I will change it.

The diagnosis and the plain-text diff are from the issue.

Closes #9282

Test plan

New file: packages/lexical-plain-text/src/__tests__/unit/NodeSelectionArrow.test.ts (each of the four arrows on a node-selected horizontal rule, and Shift+ArrowDown).

Before

pnpm exec vitest run --project unit packages/lexical-plain-text on main's packages/lexical-plain-text/src/index.ts:

× ArrowUp moves the caret to the decorator's side 79ms
     × ArrowLeft moves the caret to the decorator's side 6ms
     × ArrowDown moves the caret to the decorator's side 4ms
     × ArrowRight moves the caret to the decorator's side 2ms
     × Shift+ArrowDown turns the node selection into a range to extend 4ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯
      Tests  5 failed (5)

After

$ pnpm exec vitest run --project unit packages/lexical-plain-text
Tests  5 passed (5)
$ pnpm exec vitest run --project unit packages/lexical-plain-text packages/lexical-rich-text packages/lexical-selection packages/lexical-table packages/lexical-extension
Test Files  85 passed | 2 skipped (87)
      Tests  3172 passed | 1 expected fail | 4 skipped (3177)

pnpm run tsc, pnpm run flow, pnpm run check-changesets, eslint and prettier are clean.

Only the unit tests (jsdom, macOS) were run. They dispatch the arrow commands directly, so real key presses in a browser, and the e2e suite, were not exercised.

@vercel

vercel Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
lexical Ready Ready Preview Nov 1, 2026 11:17pm UTC
lexical-playground Ready Ready Preview Nov 1, 2026 11:17pm UTC

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 Nov 1, 2026
@meta-code-review

meta-code-review Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 1, 2026, 11:35 PM UTC
@therealemmanueloppong

Copy link
Copy Markdown
Author

Heads up, and apologies for the noise: after opening this I noticed #9286 by @rinarakaki, opened on Sept 30, which fixes the same issue (#9282, and #4214). I missed it when I checked for existing PRs.

Both move $exitNodeSelectionToward and $convertContiguousNodeSelection into @lexical/selection and give plain text the same arrow key handling. This one only differs in having jsdom unit tests that dispatch the four arrow commands directly, where #9286 has a browser test, so it may add little.

Please take whichever you prefer. I am happy to close this one in favor of #9286, just say so.

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This looks good — thanks for tracking down #9282 and sharing rich text's handlers rather than copying them. Plain text now answers arrow keys on a node-selected decorator the same way rich text does, for each arrow with and without Shift, and the two helpers move to @lexical/selection with unchanged bodies, so rich text's behavior is untouched.

A few optional, non-blocking suggestions:

  1. Add a browser-tier test next to packages/lexical-plain-text/src/__tests__/unit/NodeSelectionArrow.test.ts. AGENTS.md (Testing Strategy) puts behavior that depends on a real layout or selection engine under packages/**/__tests__/browser/**, and the bug in #9282 is about where the browser's caret goes. packages/lexical-rich-text/src/__tests__/browser/RichTextNodeSelectionArrow.test.ts is a ready template; a plain-text twin would also cover Shift+ArrowLeft/Right, which jsdom cannot exercise because $moveCharacter relies on Selection.modify.
  2. Document the new public helpers in packages/lexical-selection/src/node-selection.ts:48-53. Now that $convertContiguousNodeSelection is exported, its JSDoc is its contract: please mention that it commits the current update synchronously ($flushSyncAfterUpdate() at line 87) and that it returns false and leaves the selection alone when the nodes are not contiguous. The phrase "the arrow handlers" points at rich text's internals, which API readers won't see. On your question about keeping the helpers internal: exporting them is additive and consistent with AGENTS.md's backwards-compatibility rules, so that's a maintainer preference either way.
  3. Nit: the Shift+ArrowDown test (line 97) only asserts isCollapsed() === false. Asserting the converted anchor and focus (the root offsets on either side of the rule) would pin the conversion itself rather than any non-collapsed selection.

The changesets look right per CONTRIBUTING.md: [fix] with a Verify: paragraph for @lexical/plain-text, and [feature] at patch for @lexical/selection.

@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Nov 1, 2026
@pkg-pr-new

pkg-pr-new Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
lexical

npm i https://pkg.pr.new/lexical@9710

@lexical/a11y

npm i https://pkg.pr.new/@lexical/a11y@9710

@lexical/clipboard

npm i https://pkg.pr.new/@lexical/clipboard@9710

@lexical/code

npm i https://pkg.pr.new/@lexical/code@9710

@lexical/code-core

npm i https://pkg.pr.new/@lexical/code-core@9710

@lexical/code-prism

npm i https://pkg.pr.new/@lexical/code-prism@9710

@lexical/code-shiki

npm i https://pkg.pr.new/@lexical/code-shiki@9710

@lexical/compiler

npm i https://pkg.pr.new/@lexical/compiler@9710

@lexical/devtools-core

npm i https://pkg.pr.new/@lexical/devtools-core@9710

@lexical/dragon

npm i https://pkg.pr.new/@lexical/dragon@9710

@lexical/eslint-plugin

npm i https://pkg.pr.new/@lexical/eslint-plugin@9710

@lexical/extension

npm i https://pkg.pr.new/@lexical/extension@9710

@lexical/fast-check

npm i https://pkg.pr.new/@lexical/fast-check@9710

@lexical/file

npm i https://pkg.pr.new/@lexical/file@9710

@lexical/hashtag

npm i https://pkg.pr.new/@lexical/hashtag@9710

@lexical/headless

npm i https://pkg.pr.new/@lexical/headless@9710

@lexical/history

npm i https://pkg.pr.new/@lexical/history@9710

@lexical/html

npm i https://pkg.pr.new/@lexical/html@9710

@lexical/internal

npm i https://pkg.pr.new/@lexical/internal@9710

@lexical/link

npm i https://pkg.pr.new/@lexical/link@9710

@lexical/list

npm i https://pkg.pr.new/@lexical/list@9710

@lexical/mark

npm i https://pkg.pr.new/@lexical/mark@9710

@lexical/markdown

npm i https://pkg.pr.new/@lexical/markdown@9710

@lexical/mdast

npm i https://pkg.pr.new/@lexical/mdast@9710

@lexical/offset

npm i https://pkg.pr.new/@lexical/offset@9710

@lexical/overflow

npm i https://pkg.pr.new/@lexical/overflow@9710

@lexical/plain-text

npm i https://pkg.pr.new/@lexical/plain-text@9710

@lexical/react

npm i https://pkg.pr.new/@lexical/react@9710

@lexical/rich-text

npm i https://pkg.pr.new/@lexical/rich-text@9710

@lexical/selection

npm i https://pkg.pr.new/@lexical/selection@9710

@lexical/table

npm i https://pkg.pr.new/@lexical/table@9710

@lexical/tailwind

npm i https://pkg.pr.new/@lexical/tailwind@9710

@lexical/text

npm i https://pkg.pr.new/@lexical/text@9710

@lexical/utils

npm i https://pkg.pr.new/@lexical/utils@9710

@lexical/y

npm i https://pkg.pr.new/@lexical/y@9710

@lexical/yjs

npm i https://pkg.pr.new/@lexical/yjs@9710

commit: 4ac060e

…ction helpers' contract and pin the Shift+Arrow range

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This looks good — thanks for picking up #9282, and for turning the earlier JSDoc and Shift-range suggestions around in 4ac060e. Plain text now answers arrow keys on a node-selected decorator the way rich text does: each arrow leaves the caret beside the decorator and the next character is typed there, and with Shift a contiguous node selection becomes the range the key extends. The two helpers move to @lexical/selection unchanged, so rich text's behavior is untouched.

A few optional, non-blocking suggestions:

  1. Make the Shift test's event cancelable (packages/lexical-plain-text/src/__tests__/unit/NodeSelectionArrow.test.ts:99-102). Without cancelable: true, preventDefault() does nothing on that event, so expect(event.defaultPrevented).toBe(false) on line 111 passes even if the handler cancels the key, which would stop the browser from extending the range. The non-Shift cases already pass cancelable: true (line 76).
  2. Consider a browser-tier test. AGENTS.md (Testing Strategy) puts behavior that depends on a real layout or selection engine under packages/**/__tests__/browser/**, and #9282 is about where the browser's caret goes. packages/lexical-rich-text/src/__tests__/browser/RichTextNodeSelectionArrow.test.ts is a ready template; a plain-text version would also cover Shift+ArrowLeft/Right, which jsdom can't exercise because $moveCharacter relies on Selection.modify.
  3. Name @lexical/rich-text in a changeset. .changeset/README.md asks the frontmatter to list every published package a change affects, and the Changesets check warns that packages/lexical-rich-text/src/index.ts changed without one. Adding '@lexical/rich-text': patch to .changeset/selection-node-selection-exit-helpers.md covers it.

On the two open questions in the description and the comments: exporting the helpers is additive and fits AGENTS.md's backwards-compatibility rules, and #9286 exposes the same two names from the same package, so either pull request leaves the same public API. Which of the two to merge is the maintainers' call.

The changesets otherwise follow CONTRIBUTING.md: [fix] with a Verify: paragraph for @lexical/plain-text, and [feature] at patch for @lexical/selection.

…est event cancelable and list rich-text in the changeset

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This looks good — thanks for picking up #9282, and for making the Shift test's event cancelable and adding @lexical/rich-text to the changeset in 6956b05. Plain text now answers arrow keys on a node-selected decorator the way rich text does: each arrow leaves the caret beside the decorator and the next character is typed there, and with Shift a contiguous node selection becomes the range the key extends. The two helpers move to @lexical/selection unchanged, so rich text's behavior is untouched.

One optional, non-blocking suggestion:

  • Consider a browser-tier test. AGENTS.md (Testing Strategy) puts behavior that depends on a real layout or selection engine under packages/**/__tests__/browser/**, and #9282 is about where the browser's caret goes once Lexical holds a node selection. packages/lexical-rich-text/src/__tests__/browser/RichTextNodeSelectionArrow.test.ts is a ready template; a plain-text version would also cover Shift+ArrowLeft/Right, which jsdom can't exercise because $moveCharacter relies on Selection.modify. The new unit test in packages/lexical-plain-text/src/__tests__/unit/NodeSelectionArrow.test.ts already tells the fix apart from main, so this is extra coverage rather than a blocker.

On the open questions in the description and the comments: exporting the helpers is additive and fits AGENTS.md's backwards-compatibility rules, and #9286 exposes the same two names from the same package, so either pull request leaves the same public API. Which of the two to merge is the maintainers' call.

The changesets follow CONTRIBUTING.md: [fix] with a Verify: paragraph for @lexical/plain-text, and [feature] at patch naming @lexical/selection and @lexical/rich-text.

This branch was successfully deployed

2 active deployments
Preview – lexical — 6956b056 Deployed Nov 1, 2026 by vercel[bot]
Preview – lexical-playground — 6956b056 Deployed Nov 1, 2026 by vercel[bot]
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

2 participants