Skip to content

Keep focus out of the editor on outside selection changes (#1147) - #1210

Closed
lyubomir-bozhinov wants to merge 2 commits into
basecamp:mainfrom
lyubomir-bozhinov:fix/1147-focus-steal-between-stacked-images
Closed

Keep focus out of the editor on outside selection changes (#1147)#1210
lyubomir-bozhinov wants to merge 2 commits into
basecamp:mainfrom
lyubomir-bozhinov:fix/1147-focus-steal-between-stacked-images

Conversation

@lyubomir-bozhinov

Copy link
Copy Markdown

Fixes #1147.

The bug

On a page with an input above the editor, uploading two images stacked vertically, typing between them, then focusing the outside input and typing — the caret snaps back into Lexxy after the first keystroke. Reported on Firefox; only reproduces on Firefox 152 (146 and 151 do not).

Root cause

A caret between two stacked block images leaves a leading and trailing ProvisionalParagraphNode in place. ProvisionalParagraphExtension marks all provisional paragraphs dirty on every SELECTION_CHANGE_COMMAND so their caret-spacer visibility can follow the selection. That handler had no focus guard.

Firefox 152 fires selectionchange for an outside input's caret (older Gecko and Blink do not). That dispatches SELECTION_CHANGE_COMMAND, marks the provisional paragraphs dirty, and the resulting reconcile writes the editor's stored selection back to the DOM — pulling focus into the editor and away from the input.

The fix

Bail out of $markAllProvisionalParagraphsDirty when the editor isn't focused. A provisional paragraph's visibility follows the editor's own caret, so there is nothing to update — and no selection to restore — while the editor is unfocused. This mirrors the existing isEditorFocused guard in rewritable_history_extension.js.

One guard clause; no behavior change while the editor is focused.

Testing

  • Reproduced RED→GREEN against real Firefox 152 (Playwright's firefox-beta build): without the fix only the first outside keystroke lands before focus jumps back; with it, all keystrokes land and focus stays put.
  • Added test/browser/tests/attachments/focus_between_stacked_images.test.js:
    • the real user scenario (typing in an outside input), which guards the regression on Firefox 152+, and
    • a signal-replay test that dispatches the selection-change signal while the editor is unfocused. The underlying focus-steal is browser-agnostic once the signal fires, so this test fails without the fix on all three CI browsers (Chromium, Firefox, WebKit) and passes with it — giving the regression teeth even though CI's bundled Firefox predates 152.
  • Full browser suite green (1758 passed); the two cursor_moves_away failures are pre-existing and reproduce identically on main.

A caret between two stacked block images leaves a leading and trailing
provisional paragraph in place. `$markAllProvisionalParagraphsDirty` ran on
every `SELECTION_CHANGE_COMMAND`, so a selection change anywhere on the page
marked those dirty and the reconcile wrote the editor's selection back to the
DOM — pulling focus into the editor and away from wherever the user was typing.

Firefox 152 fires `selectionchange` for an outside input's caret, which older
Gecko and Blink do not, so the report only reproduced there: typing in a field
above the editor snapped the caret back into Lexxy after the first keystroke.

Guard the handler on `isEditorFocused`, mirroring the rewritable-history
extension: a provisional paragraph's visibility follows the editor's own caret,
so there is nothing to update — and no selection to restore — while the editor
is unfocused.

Fixes basecamp#1147

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

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 fixes a Firefox-specific focus-steal regression where selectionchange events originating outside the Lexxy editor could trigger a reconcile that restores the editor selection back into the DOM, pulling focus away from the user’s active input. The fix adds a focus guard to the provisional paragraph selection-change handler and introduces a Playwright regression test covering the reported scenario.

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.

Changes:

  • Bail out of $markAllProvisionalParagraphsDirty when the editor is not focused to avoid selection restoration/focus steal.
  • Add a new Playwright test reproducing the “caret between stacked images + outside input typing” scenario and a selection-change replay coverage path.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/extensions/provisional_paragraph_extension.js Adds an editor-focus guard to prevent selection-driven reconciles when the editor is unfocused.
test/browser/tests/attachments/focus_between_stacked_images.test.js Adds a regression test that ensures outside-input focus is preserved when selection changes occur elsewhere on the page.

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

Comment on lines +57 to +65
async function dispatchSelectionChange(page) {
const dispatched = await page.locator("lexxy-editor").evaluate((el) => {
const command = [ ...el.editor._commands.keys() ].find((candidate) => candidate?.type === "SELECTION_CHANGE_COMMAND")
if (!command) return false
el.editor.dispatchCommand(command, undefined)
return true
})
expect(dispatched, "SELECTION_CHANGE_COMMAND must be resolvable for this regression to have teeth").toBe(true)
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch on the private internal — fixed in b4ce8a6. It now imports the real SELECTION_CHANGE_COMMAND through a small fixture module and dispatches that, so there's no more reliance on editor._commands or a dev-only .type.

I kept the direct command dispatch rather than a native selectionchange event, though. I checked, and dispatching selectionchange on document while focus is in the outside input does not reproduce this on the bundled Firefox (146) or on Chromium/WebKit: Lexical ignores a selectionchange whose selection sits outside its root, so no reconcile fires and the test would pass with or without the fix. Firefox 152 is the outlier that dispatches the command in that state — which is the whole bug. Replaying the command directly is the faithful stand-in for 152's behavior, and it keeps the regression failing-without-the-fix on all three engines in CI.

Comment on lines +45 to +49
const between = [ ...content.children ].find((child, index) =>
child.classList.contains("provisional-paragraph") && content.children[index - 1]?.tagName === "FIGURE")
const range = document.createRange()
range.selectNodeContents(between)
range.collapse(true)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed — added an explicit throw in b4ce8a6 so a changed DOM structure fails with a clear message instead of a cryptic error from inside evaluate.

await placeCaretBetweenStackedImages(page)
})

test("a selection change elsewhere on the page does not pull focus back into the editor", async ({ page, editor }) => {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed — dropped the unused editor fixture from that test in b4ce8a6.

Dispatch the real exported SELECTION_CHANGE_COMMAND through a small fixture
module instead of reaching into `editor._commands`, so the test no longer
depends on a private Lexical internal. Replaying the command directly (rather
than a native selectionchange event) is deliberate: Firefox below 152 ignores a
selectionchange whose selection sits outside the editor, so the native event
would make the regression vacuous on the bundled browser.

Also throw a clear error when the provisional paragraph between the images is
missing, and drop the unused `editor` fixture from the first test.
Copilot AI review requested due to automatic review settings July 22, 2026 07:07

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 3 out of 3 changed files in this pull request and generated no new comments.

@lyubomir-bozhinov

Copy link
Copy Markdown
Author

Closing this — #1209 already fixed it, and did it better.

I only spotted the overlap now: cd72f36 landed on main on July 20, a day before I got the reproduction working here. It addresses the same root cause (Firefox 152+ keeps window.getSelection() anchored inside the editor after focus moves away, then fires selectionchange for every outside keystroke, so Lexical reconciles the stale selection and pulls focus back), but does it at the right level: tagging the selectionchange update with skip-dom-selection covers both focus-restoration vectors, whereas this PR only guarded the provisional-paragraph handler and would have left the other selectionchange paths exposed.

I confirmed the overlap rather than assuming it — running the stacked-images reproduction against Firefox 153:

Same browser both times, so #1209 is what resolves it.

Thanks @jeremy — the write-up on cd72f36 was a genuinely useful read, particularly the detail that 151 and earlier move the document selection along with focus. That is exactly why this one resisted reproduction until I got a 152 build in front of it.

#1204 and #1205 are unrelated and still reproduce on current main; I have re-tested both merged with latest main and they pass.

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.

[Firefox] When typing in another input field, the cursor is automatically moved to the Lexxy editor instead.

2 participants