Skip to content

[lexical] Bug Fix: keep caret above the on-screen keyboard after Enter#8486

Merged
etrepum merged 3 commits into
facebook:mainfrom
jWA86:fix/auto-scroll-visual-viewport-mobile
May 10, 2026
Merged

[lexical] Bug Fix: keep caret above the on-screen keyboard after Enter#8486
etrepum merged 3 commits into
facebook:mainfrom
jWA86:fix/auto-scroll-visual-viewport-mobile

Conversation

@jWA86

@jWA86 jWA86 commented May 9, 2026

Copy link
Copy Markdown
Contributor

Summary

On mobile, after pressing Enter (in a list, paragraph, heading, table cell ...), the new caret often lands behind the on-screen keyboard. The browser's own auto-scroll only kicks in once the user starts typing, typically on the second character.
In a list this is particularly painful: users press Enter again, thinking nothing happened, which exits the list and destroys the item they were trying to add.

Before / After

  • Before: Chrome / Android, long bullet list. Enter -> new item is hidden behind the keyboard until you type 2 characters.
Before.mp4
  • After: same scenario, scroll is immediate on Enter, no double-Enter, the new item appears above the keyboard.
after.mp4

Root cause

scrollIntoViewIfNeeded(LexicalUtils.ts) compares the selection rect against window.innerHeight in the body-scroll branch. On a desktop, layout viewport is more or less = visual viewport, so this is correct.
On mobile with the keyboard open, the layout viewport stays full-screen while the visual viewport shrinks to the area above the keyboard. The function therefore concludes the caret is already in view (currentBottom <= innerHeight) and does not scroll, even though visually the caret is behind the keyboard.

When the user starts typing, the browser's native auto-scroll-into-visual-viewport heuristic eventually catches up, that's why "the second character fixes it."

Fix

In the body-scroll branch, read window.visualViewport when available; fall back to innerHeight for environments without it.

const visualViewport = defaultView.visualViewport;
if (visualViewport) {
  const offsetTop = visualViewport.offsetTop;
  targetTop = offsetTop;
  targetBottom = offsetTop + visualViewport.height;
} else {
  targetTop = 0;
  targetBottom = getWindow(editor).innerHeight;
}

getBoundingClientRect already reports in layout-viewport coordinates, and visualViewport.offsetTop + height is the visible-area bottom in those same coordinates, so the surrounding math (currentBottom > targetBottom -> scrollBy(0, diff)) stays consistent.
The CSS scroll-padding accounting from #8218 is unchanged.
The non-body branch (in-editor overflow) is untouched.

Scope

The bug affects every Enter/return key that creates a new block: paragraphs, list items (<li>), headings, table cells, custom decorator nodes, and more generally any post-update selection change that hits the body-scroll branch.
Lists make it particularly user-visible because the natural workaround (press Enter again) destroys data. Fixing the underlying scroll math fixes all of them at once.

Test Plan

  • New e2e test Auto scroll respects mobile visual viewport > Pressing Enter scrolls new caret above the on-screen keyboard (in packages/lexical-playground/__tests__/e2e/AutoScroll.spec.mjs):
    • Sets a 400*600 viewport, fills a bullet list past overflow, then stubs window.visualViewport via a real EventTarget that reports height: 300 and dispatches the resize event to mirror the real "keyboard appears" sequence.
    • Reads the caret position read-only via the element-anchor child rect (no DOM mutation, so Lexical's mutation observer / reconciler is not triggered).
    • Asserts caretRect.bottom <= visualViewport.offsetTop + visualViewport.height (so iOS-style non-zero offsetTop is also covered).
    • Fails before the fix with caretRect.bottom ~ 600 (sat at innerHeight, behind the simulated keyboard);
    • passes after with caretRect.bottom ~ 280.
  • Existing "Auto scroll while typing" suite still passes. The fix only changes the body-scroll branch; tests that scroll the editor container itself (addScroll(...)) hit the unchanged else branch.
  • Manual repro: Chrome on Android (real device), long bullet list at the bottom of the editor; before -> keyboard hides the new item until the second character; after -> immediate scroll on Enter.

Known limitations of the test setup

  • The stub doesn't simulate the timing of a real keyboard animation (focus -> keyboard slides up -> relayout -> IME events). The synchronous read in scrollIntoViewIfNeeded doesn't depend on that timing, but a future iOS-specific test might want to.
  • iOS WebKit offsetTop > 0 cases (visual viewport offset when the URL bar collapses) are exercised by the assertion math but not by the stub values

scrollIntoViewIfNeeded compared the selection rect against
window.innerHeight (the layout viewport). On mobile the on-screen
keyboard does not change innerHeight, only the visual viewport, so the
new caret created by Enter often lands behind the keyboard. The
browser's native auto-scroll only kicks in once the user types a
character or two; in a list this leads users to press Enter again,
which exits the list.

Read window.visualViewport.offsetTop / .height when available so the
caret lands in the keyboard-clear area; fall back to innerHeight when
visualViewport is unavailable. getBoundingClientRect already reports in
layout-viewport coords, so offsetTop + height is the visible bottom in
those same coords and the surrounding scroll math is unchanged.

Adds an e2e test that stubs window.visualViewport via a real EventTarget
(dispatching a resize event to mirror the real keyboard-appears
sequence), fills a long bullet list past overflow, presses Enter, and
asserts the new caret stays within visualViewport.offsetTop +
visualViewport.height. The test fails before the fix (caret around
innerHeight) and passes after (caret within the visual viewport). The
existing Auto scroll tests are unaffected: they exercise the in-editor
overflow branch which is untouched.
@vercel

vercel Bot commented May 9, 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 9, 2026 8:27pm
lexical-playground Ready Ready Preview, Comment May 9, 2026 8:27pm

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 9, 2026
@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label May 9, 2026
etrepum
etrepum previously approved these changes May 9, 2026
@etrepum etrepum dismissed their stale review May 9, 2026 19:25

Test failures

@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.

It looks like this new e2e test does not work consistently

…against collab + sub-pixel rounding

Two flake sources surfaced in CI on the previous commit:

- Collab mode: clicking the .block-controls dropdown timed out because
  the dropdown sat outside the 400-px-wide viewport in the split collab
  layout. Switch from the local UI-click toggleBulletList to the shared
  keyboard-shortcut version (Ctrl/Cmd+Shift+8), which doesn't depend on
  toolbar position or viewport size.

- Firefox layout rounding: the assertion compared a fractional caret
  bottom (e.g. 300.27) against an integer visualViewport bound (300),
  failing by 0.27 px even though Lexical scrolled correctly. Allow a
  1-px tolerance — the original bug overshoots by ~280 px so this still
  catches the regression.

Verified locally on chromium / firefox / webkit, both rich-text and
rich-text-with-collab modes (10x / 5x / 3x repeats, 0 flakes).
@jWA86

jWA86 commented May 9, 2026

Copy link
Copy Markdown
Contributor Author

need to re-run the affected jobs.
I tested locally, verified the change against chromium / firefox / webkit, both rich-text and rich-text-with-collab, and the new test passes deterministically across all matrices.

@etrepum etrepum added this pull request to the merge queue May 10, 2026
Merged via the queue into facebook:main with commit 19a034b May 10, 2026
41 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. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants