Skip to content

Guard lexical's scrollIntoViewIfNeeded against Safari's bogus RTL caret rect - #1202

Open
jeremy wants to merge 2 commits into
mainfrom
rtl-scroll-fix
Open

Guard lexical's scrollIntoViewIfNeeded against Safari's bogus RTL caret rect#1202
jeremy wants to merge 2 commits into
mainfrom
rtl-scroll-fix

Conversation

@jeremy

@jeremy jeremy commented Jul 16, 2026

Copy link
Copy Markdown
Member

Problem

Typing Arabic (or any RTL script) in a Lexxy comment box on Safari makes the page jump upward on every space/backspace, scrolling the editor off-screen. Reported via a customer (BC on-call card 10101960010) with a screen recording; confirmed the page scrolls up ~270px per keystroke and the editor lands at the bottom edge. Chrome/Edge are unaffected.

Root cause

The jump comes from Lexical's own scrollIntoViewIfNeeded (LexicalSelection.ts), not from Lexxy or bc3:

  1. On Safari a collapsed caret reports selection.type === "Range" (Chrome/WebKit-headless report "Caret"). Lexical's $updateDOMSelection has a special case for exactly this — the !(domSelection.type === 'Range' && isCollapsed) term with the "Badly interpreted range selection when collapsed - #1482" comment. On Safari that term is false, so Lexical skips its early-return, re-applies the DOM selection, and runs the scroll block on every keystroke.
  2. The scroll block reads the caret rect from getRangeAt(0).getBoundingClientRect(). Safari returns a degenerate/out-of-bounds rect (negative/zero top) for a collapsed caret in RTL text — worst at whitespace/line boundaries, i.e. after space/backspace.
  3. currentTop < targetTopscrollBy(0, negative), fired twice per keystroke → the window jumps up and the editor scrolls away from the caret.

Lexxy already knows Safari caret rects are unreliable — Selection#isRectUnreliable + the marker-span workaround in #getReliableRectFromRange — but that guards Lexxy's own rect reads; it can't reach the rect Lexical reads internally for the scroll.

Fix

A caret that lives inside the editor cannot have a rect lying entirely outside the editor's own box. When it does, the rect is bogus, so skip the scroll. This is the browser-agnostic geometry guard a Lexical maintainer floated back in 2022 (facebook/lexical#2495, closed unmerged).

Because the defect is in Lexical (a dependency) and the Rails-engine build bundles Lexical (the npm build externalizes it), this injects the guard into the bundled lexical at build time via a small rollup transform plugin. It patches both the readable dev variant and the minified prod variant, and fails the build loudly if Lexical's anchor ever changes — so a version bump can't silently reintroduce the bug. It mutates nothing in node_modules and adds no runtime cost.

I chose a build-time transform over patch-package because Lexical ships its prod bundle as a single minified line, which makes the generated patch ~270 KB of noise. The transform keeps the change small, readable, and self-documenting.

Validation

Reproduced and validated in real WebKit (Playwright, matching the customer's Safari 26.5), against Lexical 0.44.0 (main). The bug needs Safari's native RTL-keyboard input path, which automation can't drive, so I faithfully simulated the two documented Safari behaviors (collapsed type === "Range" + a bogus caret rect) — remove either and the jump disappears, matching the customer video.

Build Space → scrollBy Net jump Legit caret-follow (in-bounds, below fold)
Baseline (unfixed) -40, -40 −80px
This fix (none) 0px +448px down ✅ preserved

The guard is surgical: it suppresses the spurious scroll and still scrolls to reveal a genuinely off-screen caret (real in-bounds rect below the fold).

Upstream

The bug is unfixed on Lexical main and latest (0.47.0). I've opened an upstream PR to facebook/lexical with the same guard as a unit test, and commented on #2495. Once a fixed Lexical release is adopted, this build-time transform can be dropped.

Notes

No automated regression test here: the bug only manifests under Safari's native RTL input, which neither Chrome system tests nor Playwright-WebKit can drive. The build-time assertion guards against silent breakage on a Lexical bump; the runtime behavior is covered by the upstream unit test.

…et rect

On Safari, a collapsed caret in RTL text yields a degenerate, out-of-bounds
bounding rect. Lexical's scrollIntoViewIfNeeded feeds that rect straight to the
scroller, so every space/backspace scrolls the window up and the editor jumps
off-screen. Chrome/Edge are unaffected: they return correct caret rects, and a
collapsed caret reports selection.type "Caret" there rather than Safari's
"Range" — the latter is what routes Lexical into the scroll path (see Lexical's
own "#1482" comment).

A caret that lives inside the editor cannot lie entirely outside the editor's
own box; when the rect says it does, the rect is unreliable, so skip the scroll.
This injects that guard into the bundled lexical at build time via a small
rollup transform (the Rails-engine build bundles lexical; the npm build
externalizes it). The build fails loudly if lexical's anchor ever changes, so a
version bump can't silently reintroduce the bug.

Upstreamed to facebook/lexical (see #2495).
Copilot AI review requested due to automatic review settings July 16, 2026 17:03
@jeremy
jeremy requested a review from jorgemanrubia July 16, 2026 17:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a build-time Rollup transform to patch Lexical’s scrollIntoViewIfNeeded logic to avoid Safari’s bogus RTL caret bounding rect triggering repeated upward scrollBy calls (causing the editor to jump off-screen). The workaround is injected into the bundled Lexical dependency during the Rails-engine asset build and fails the build if the expected Lexical anchor text changes.

Changes:

  • Add a Rollup transform plugin that injects a geometry guard into Lexical’s scroll code (dev/prod anchor variants).
  • Fail the build loudly if the Lexical anchor can’t be found (to avoid silent regressions on Lexical bumps).

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread rollup.config.mjs
Comment thread rollup.config.mjs Outdated

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 09291f6e14

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rollup.config.mjs Outdated
…nter

- Drop the lower clause from the injected guard (Codex P2). A caret below a
  scrollable overflow:auto editor root legitimately has top > rootRect.bottom
  until Lexical scrolls the root's scrollTop; suppressing that broke auto-scroll
  past the fold. Only the above-the-editor case is geometrically impossible for
  a real in-editor caret. Now matches the upstream one-sided guard in
  facebook/lexical#8848.
- Match lexical module IDs with a separator-agnostic regex so the transform
  applies on Windows builds (backslash separators) too.
- Reset the applied counter in buildStart so the buildEnd anchor assertion is
  per-build under rollup --watch / incremental rebuilds.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.

Comment thread rollup.config.mjs

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a979e368e5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rollup.config.mjs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants