From 09291f6e146279e4831353c0662b87c854156f0d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 10:02:55 -0700 Subject: [PATCH 1/2] Guard lexical's scrollIntoViewIfNeeded against Safari's bogus RTL caret rect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- rollup.config.mjs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/rollup.config.mjs b/rollup.config.mjs index b5cf8348e..97a8ecfde 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -10,6 +10,40 @@ import { promisify } from "util" /* global Buffer */ const brotliPromise = promisify(brotliCompress) +// Interim workaround for facebook/lexical#2495. On Safari, a collapsed caret in +// RTL text yields a degenerate, out-of-bounds bounding rect, so Lexical's +// scrollIntoViewIfNeeded scrolls the window up on every space/backspace and the +// editor jumps off-screen. 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 (the Rails-engine build bundles lexical; the npm build +// externalizes it). Remove once a fixed lexical release is adopted upstream. +function guardLexicalScrollIntoView() { + const PROD_ANCHOR = "if(null===r||null===i)return;let{top:o,bottom:s}=e,l=0,c=0,a=n;" + const PROD_GUARD = "if(null===r||null===i)return;const $lxsr=n.getBoundingClientRect();if(e.bottom<$lxsr.top||e.top>$lxsr.bottom)return;let{top:o,bottom:s}=e,l=0,c=0,a=n;" + const DEV_ANCHOR = " if (doc === null || defaultView === null) {\n return;\n }\n let {\n top: currentTop,\n bottom: currentBottom\n } = selectionRect;" + const DEV_GUARD = " if (doc === null || defaultView === null) {\n return;\n }\n // Injected by Lexxy — skip scroll on a bogus caret rect (Safari RTL). facebook/lexical#2495.\n const rootRect = rootElement.getBoundingClientRect();\n if (selectionRect.bottom < rootRect.top || selectionRect.top > rootRect.bottom) {\n return;\n }\n let {\n top: currentTop,\n bottom: currentBottom\n } = selectionRect;" + + let applied = 0 + return { + name: "guard-lexical-scroll-into-view", + transform(code, id) { + if (!id.includes("/node_modules/lexical/")) return null + let out = code + if (out.includes(PROD_ANCHOR)) out = out.replace(PROD_ANCHOR, PROD_GUARD) + if (out.includes(DEV_ANCHOR)) out = out.replace(DEV_ANCHOR, DEV_GUARD) + if (out === code) return null + applied++ + return { code: out, map: null } + }, + buildEnd() { + if (applied === 0) { + this.error("guard-lexical-scroll-into-view: lexical's scrollIntoViewIfNeeded anchor was not found. The workaround for facebook/lexical#2495 did not apply — re-verify it against the current lexical version.") + } + } + } +} + export default [ { input: "./src/index.js", @@ -29,6 +63,7 @@ export default [ "@rails/activestorage" ], plugins: [ + guardLexicalScrollIntoView(), nodeResolve(), commonjs(), // Inject Prism for prismjs language components that expect a global Prism From a979e368e52996ad8eb41b8718c72ca1cb333c04 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 10:32:59 -0700 Subject: [PATCH 2/2] Address review: one-sided guard, cross-platform filter, per-build counter - 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. --- rollup.config.mjs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/rollup.config.mjs b/rollup.config.mjs index 97a8ecfde..87a5d9050 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -14,21 +14,27 @@ const brotliPromise = promisify(brotliCompress) // RTL text yields a degenerate, out-of-bounds bounding rect, so Lexical's // scrollIntoViewIfNeeded scrolls the window up on every space/backspace and the // editor jumps off-screen. 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 +// entirely above the editor's own top edge; when the rect says it does, the rect +// is unreliable, so skip the scroll. The guard is one-sided on purpose — a rect +// below the editor is the normal "scroll the caret into view" path (including +// auto-scroll inside an overflow:auto editor root) and is left untouched. This +// injects that guard into the bundled // lexical at build time (the Rails-engine build bundles lexical; the npm build // externalizes it). Remove once a fixed lexical release is adopted upstream. function guardLexicalScrollIntoView() { const PROD_ANCHOR = "if(null===r||null===i)return;let{top:o,bottom:s}=e,l=0,c=0,a=n;" - const PROD_GUARD = "if(null===r||null===i)return;const $lxsr=n.getBoundingClientRect();if(e.bottom<$lxsr.top||e.top>$lxsr.bottom)return;let{top:o,bottom:s}=e,l=0,c=0,a=n;" + const PROD_GUARD = "if(null===r||null===i)return;const $lxsr=n.getBoundingClientRect();if(e.bottom<$lxsr.top)return;let{top:o,bottom:s}=e,l=0,c=0,a=n;" const DEV_ANCHOR = " if (doc === null || defaultView === null) {\n return;\n }\n let {\n top: currentTop,\n bottom: currentBottom\n } = selectionRect;" - const DEV_GUARD = " if (doc === null || defaultView === null) {\n return;\n }\n // Injected by Lexxy — skip scroll on a bogus caret rect (Safari RTL). facebook/lexical#2495.\n const rootRect = rootElement.getBoundingClientRect();\n if (selectionRect.bottom < rootRect.top || selectionRect.top > rootRect.bottom) {\n return;\n }\n let {\n top: currentTop,\n bottom: currentBottom\n } = selectionRect;" + const DEV_GUARD = " if (doc === null || defaultView === null) {\n return;\n }\n // Injected by Lexxy — skip scroll on a bogus above-the-editor caret rect (Safari RTL). facebook/lexical#2495.\n const rootRect = rootElement.getBoundingClientRect();\n if (selectionRect.bottom < rootRect.top) {\n return;\n }\n let {\n top: currentTop,\n bottom: currentBottom\n } = selectionRect;" let applied = 0 return { name: "guard-lexical-scroll-into-view", + buildStart() { + applied = 0 + }, transform(code, id) { - if (!id.includes("/node_modules/lexical/")) return null + if (!/[\\/]node_modules[\\/]lexical[\\/]/.test(id)) return null let out = code if (out.includes(PROD_ANCHOR)) out = out.replace(PROD_ANCHOR, PROD_GUARD) if (out.includes(DEV_ANCHOR)) out = out.replace(DEV_ANCHOR, DEV_GUARD)