Skip to content

[lexical-dragon] Bug Fix: Handle makeChanges messages and the listener registration race#8665

Open
brunoprietog wants to merge 3 commits into
facebook:mainfrom
brunoprietog:fix-dragon-selection-only-messages
Open

[lexical-dragon] Bug Fix: Handle makeChanges messages and the listener registration race#8665
brunoprietog wants to merge 3 commits into
facebook:mainfrom
brunoprietog:fix-dragon-selection-only-messages

Conversation

@brunoprietog

@brunoprietog brunoprietog commented Jun 10, 2026

Copy link
Copy Markdown

Description

The Dragon NaturallySpeaking web extension drives editable fields through window messages (protocol nuanria_messaging, function makeChanges, args [elementStart, elementLength, text, selStart, selLength, formatCommand]).

Dragon sends the number -1 as the text argument to mean "change the selection without touching the text". This is how Select-and-Say corrections and cursor moves by voice arrive, and the extension's own contenteditable handler special-cases text === -1 the same way. registerDragonSupport passes the value straight to insertRawText, which throws text.split is not a function inside editor.update on the first voice correction. The editor is left permanently broken, and the raw DOM edits the extension applies afterwards diverge from the editor state, so content after the correction point is lost when Lexical reconciles.

There is a second problem: browsers invoke a window's message listeners in registration order, and the extension's content script registers its listener at document_end of the initial page load. Per-editor handlers register when the editor mounts, which on most real pages (navigations in single page apps, dialogs, lazily rendered fields) is later, so their stopImmediatePropagation can never keep the extension from applying the edit through raw DOM mutations first, and edits end up applied twice.

So this PR, one commit per change:

  • Treats a non-string text as selection-only, and validates the payload up front so nothing can throw inside editor.update: args must be an array (destructuring a string inserts stray characters and destructuring a non-iterable throws), the offsets must be finite numbers (string offsets pass >= 0 checks through coercion and then concatenate), and negative final offsets collapse the selection instead of leaving a range the next input would replace.
  • Applies the formatCommand argument (execCommand names like bold, sent by voice commands like "bold that") by mapping the supported names to Lexical text formats, resolving the existing TODO.
  • Following the direction suggested in Bug: registerDragonSupport registers too late to block the Dragon Web Extension #8664, moves the package to a single shared listener that dispatches to the focused editor among the registered ones, installed lazily by registerDragonSupport so existing behavior is unchanged, and exports installDragonSupport() so apps can call it synchronously at their entrypoints to register the listener before the extension's content script. The race and the entrypoint pattern are documented in the package README.

These are the first unit tests for the package.

Closes #8664. Related but not addressed here: #3384 (offsets beyond the first text node).

Test plan

Before

Dictating into a Lexical editor with Dragon and saying "correct <word>" throws TypeError: text.split is not a function and the editor stops accepting updates. There is no way to register the message handling before an editor mounts, so on pages where editors mount lazily the extension's listener always runs first (verified against the unpacked extension content scripts in a real browser).

After

pnpm exec vitest run --project unit packages/lexical-dragon passes all 12 tests, covering the protocol (replacement, selection-only, the correction flow, malformed and out-of-range payloads, format commands) and the shared listener (installDragonSupport before any editor mounts blocks foreign listeners, dispatching to the focused editor among several, editors created after others are disposed).

Copilot AI review requested due to automatic review settings June 10, 2026 04:29
@meta-cla

meta-cla Bot commented Jun 10, 2026

Copy link
Copy Markdown

Hi @brunoprietog!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@vercel

vercel Bot commented Jun 10, 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 Jun 10, 2026 4:42pm
lexical-playground Ready Ready Preview, Comment Jun 10, 2026 4:42pm

Request 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

Note

Copilot was unable to run its full agentic suite in this review.

Adds richer support for Dragon NaturallySpeaking “makeChanges” messages by handling selection-only updates and applying text formatting commands from the extension.

Changes:

  • Treats text = -1 as a selection-only update (no text insertion).
  • Applies formatting (e.g. “bold that”) based on document.execCommand-style command names.
  • Adds unit tests covering selection-only updates, corrections, and formatting.

Reviewed changes

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

File Description
packages/lexical-dragon/src/index.ts Adds selection-only handling and execCommand→Lexical text formatting support in the Dragon message handler
packages/lexical-dragon/src/tests/unit/LexicalDragon.test.ts Adds unit tests validating selection-only behavior and formatting commands

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

Comment thread packages/lexical-dragon/src/index.ts Outdated
Comment thread packages/lexical-dragon/src/index.ts Outdated
Comment thread packages/lexical-dragon/src/__tests__/unit/LexicalDragon.test.ts Outdated
@brunoprietog brunoprietog force-pushed the fix-dragon-selection-only-messages branch from 56faba9 to d97bb23 Compare June 10, 2026 04:38
@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 Jun 10, 2026
brunoprietog added a commit to basecamp/lexxy that referenced this pull request Jun 10, 2026
Dragon's web extension drives editable fields through window messages
(protocol "nuanria_messaging"). The @lexical/dragon handler that Lexxy
picks up through the RichTextExtension dependency chain crashes on
selection-only messages: Dragon sends the number -1 as the text argument
to mean "don't change the text" (Select-and-Say corrections, cursor
moves by voice), and insertRawText(-1) throws inside editor.update. The
editor is left permanently broken, so the extension falls back to raw
DOM edits that diverge from the editor state, and content disappears
when Lexical reconciles.

Install a corrected handler when the Lexxy module loads instead of per
editor. The extension's content script registers its message listener at
document_end of the initial page load, so per-editor handlers always run
after it and their stopImmediatePropagation arrives too late. The
module-load listener wins that race and absorbs every makeChanges
addressed to a Lexxy editor, malformed ones included, since both the
extension's raw DOM edits and the broken stock handler behind us would
corrupt the editor.

The sentinel fix is also submitted upstream as facebook/lexical#8665,
and the registration race is reported as facebook/lexical#8664. This
handler can only be removed once both are resolved there.
brunoprietog added a commit to basecamp/lexxy that referenced this pull request Jun 10, 2026
Dragon's web extension drives editable fields through window messages
(protocol "nuanria_messaging"). The @lexical/dragon handler that Lexxy
picks up through the RichTextExtension dependency chain crashes on
selection-only messages: Dragon sends the number -1 as the text argument
to mean "don't change the text" (Select-and-Say corrections, cursor
moves by voice), and insertRawText(-1) throws inside editor.update. The
editor is left permanently broken, so the extension falls back to raw
DOM edits that diverge from the editor state, and content disappears
when Lexical reconciles.

Install a corrected handler when the Lexxy module loads instead of per
editor. The extension's content script registers its message listener at
document_end of the initial page load, so per-editor handlers always run
after it and their stopImmediatePropagation arrives too late. The
module-load listener wins that race and absorbs every makeChanges
addressed to a Lexxy editor, malformed ones included, since both the
extension's raw DOM edits and the broken stock handler behind us would
corrupt the editor.

The sentinel fix is also submitted upstream as facebook/lexical#8665,
and the registration race is reported as facebook/lexical#8664. This
handler can only be removed once both are resolved there.
brunoprietog added a commit to basecamp/lexxy that referenced this pull request Jun 10, 2026
Dragon's web extension drives editable fields through window messages
(protocol "nuanria_messaging"). The @lexical/dragon handler that Lexxy
picks up through the RichTextExtension dependency chain crashes on
selection-only messages: Dragon sends the number -1 as the text argument
to mean "don't change the text" (Select-and-Say corrections, cursor
moves by voice), and insertRawText(-1) throws inside editor.update. The
editor is left permanently broken, so the extension falls back to raw
DOM edits that diverge from the editor state, and content disappears
when Lexical reconciles.

Install a corrected handler when the Lexxy module loads instead of per
editor. The extension's content script registers its message listener at
document_end of the initial page load, so per-editor handlers always run
after it and their stopImmediatePropagation arrives too late. The
module-load listener wins that race and absorbs every makeChanges
addressed to a Lexxy editor, malformed ones included, since both the
extension's raw DOM edits and the broken stock handler behind us would
corrupt the editor.

The sentinel fix is also submitted upstream as facebook/lexical#8665,
and the registration race is reported as facebook/lexical#8664. This
handler can only be removed once both are resolved there.
@brunoprietog brunoprietog force-pushed the fix-dragon-selection-only-messages branch from d97bb23 to 99dd8df Compare June 10, 2026 05:53
@brunoprietog brunoprietog changed the title [lexical-dragon] Bug Fix: Handle selection-only and format makeChanges messages [lexical-dragon] Bug Fix: Handle makeChanges messages and the listener registration race Jun 10, 2026
brunoprietog added a commit to basecamp/lexxy that referenced this pull request Jun 10, 2026
Dragon's web extension drives editable fields through window messages
(protocol "nuanria_messaging"). The @lexical/dragon handler that Lexxy
picks up through the RichTextExtension dependency chain crashes on
selection-only messages: Dragon sends the number -1 as the text argument
to mean "don't change the text" (Select-and-Say corrections, cursor
moves by voice), and insertRawText(-1) throws inside editor.update. The
editor is left permanently broken, so the extension falls back to raw
DOM edits that diverge from the editor state, and content disappears
when Lexical reconciles.

Install a corrected handler when the Lexxy module loads instead of per
editor. The extension's content script registers its message listener at
document_end of the initial page load, so per-editor handlers always run
after it and their stopImmediatePropagation arrives too late. The
module-load listener wins that race and absorbs every makeChanges
addressed to a Lexxy editor, malformed ones included, since both the
extension's raw DOM edits and the broken stock handler behind us would
corrupt the editor.

The fixes and an installDragonSupport() entrypoint API are also
submitted upstream as facebook/lexical#8665. Once that ships in a
release Lexxy adopts, this handler can be replaced with a call to that
function at the entrypoint.
brunoprietog added a commit to basecamp/lexxy that referenced this pull request Jun 10, 2026
Dragon's web extension drives editable fields through window messages
(protocol "nuanria_messaging"). The @lexical/dragon handler that Lexxy
picks up through the RichTextExtension dependency chain crashes on
selection-only messages: Dragon sends the number -1 as the text argument
to mean "don't change the text" (Select-and-Say corrections, cursor
moves by voice), and insertRawText(-1) throws inside editor.update. The
editor is left permanently broken, so the extension falls back to raw
DOM edits that diverge from the editor state, and content disappears
when Lexical reconciles.

Install a corrected handler when the Lexxy module loads instead of per
editor. The extension's content script registers its message listener at
document_end of the initial page load, so per-editor handlers always run
after it and their stopImmediatePropagation arrives too late. The
module-load listener wins that race and absorbs every makeChanges
addressed to a Lexxy editor, malformed ones included, since both the
extension's raw DOM edits and the broken stock handler behind us would
corrupt the editor.

The fixes and an installDragonSupport() entrypoint API are also
submitted upstream as facebook/lexical#8665. Once that ships in a
release Lexxy adopts, this handler can be replaced with a call to that
function at the entrypoint.
@brunoprietog brunoprietog force-pushed the fix-dragon-selection-only-messages branch from 9858735 to 1f12db6 Compare June 10, 2026 08:00

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

Reviewed by Navi (Tater Thoughts Bobblehead) on behalf of @potatowagon.

LGTM — Dragon (voice dictation) support improvements.

What I verified:

  • 3 commits: selection-only fix, format commands, and installDragonSupport() for the early-listener-registration race.
  • Solid architectural improvement: refactored from per-editor listeners to a shared module-level listener with reference-counted lifecycle (installCount + editors Set). installDragonSupport() lets SPA entrypoints register before Dragon's content script loads.
  • SSR-safe, double-teardown guarded, Array.isArray validation, typeof text === "string" guard for the -1 sentinel.
  • 9 unit tests (227 lines) cover registration race, multi-editor dispatch, editor disposal, text replacement, selection-only, correction-after-selection, malformed payloads, format command, collapsed-selection-format, unknown-format. README updated with race-condition docs.
  • CLA + Vercel pass.

No concerns. Recommend approving once the full CI matrix completes green.

brunoprietog added a commit to basecamp/lexxy that referenced this pull request Jun 10, 2026
Dragon's web extension drives editable fields through window messages
(protocol "nuanria_messaging"). The @lexical/dragon handler that Lexxy
picks up through the RichTextExtension dependency chain crashes on
selection-only messages: Dragon sends the number -1 as the text argument
to mean "don't change the text" (Select-and-Say corrections, cursor
moves by voice), and insertRawText(-1) throws inside editor.update. The
editor is left permanently broken, so the extension falls back to raw
DOM edits that diverge from the editor state, and content disappears
when Lexical reconciles.

Install a corrected handler when the Lexxy module loads instead of per
editor. The extension's content script registers its message listener at
document_end of the initial page load, so per-editor handlers always run
after it and their stopImmediatePropagation arrives too late. The
module-load listener wins that race and absorbs every makeChanges
addressed to a Lexxy editor, validating the payload up front so nothing
can throw inside editor.update: both the extension's raw DOM edits and
the broken stock handler behind us would corrupt the editor otherwise.

The fixes and an installDragonSupport() entrypoint API are also
submitted upstream as facebook/lexical#8665. Once that ships in a
release Lexxy adopts, this handler can be replaced with a call to that
function at the entrypoint.
Dragon NaturallySpeaking sends -1 (a number, not a string) as the text
argument of makeChanges to mean "change the selection without touching
the text". This is how Select-and-Say corrections and cursor moves by
voice arrive: the Dragon web extension's own contenteditable handler
special-cases text === -1 the same way.

registerDragonSupport passed the number straight to insertRawText, which
throws "text.split is not a function" inside editor.update on the first
voice correction. The editor is left permanently broken, and the
extension's raw DOM edits afterwards diverge from the editor state, so
content following the correction is lost.

Also validate the payload up front so nothing can throw inside
editor.update: args must be an array (destructuring a string inserts
stray characters and destructuring a non-iterable throws), the offsets
must be finite numbers (string offsets pass >= 0 checks through coercion
and then concatenate), and negative final offsets collapse the selection
instead of leaving a range the next input would replace.
Dragon sends an execCommand name in the sixth makeChanges argument for
voice commands like "bold that" on a selection. The web extension's own
handler applies it with document.execCommand, so map the supported names
to Lexical text formats and apply them to the final selection. Use a Map
so inherited object keys are never treated as formats, and skip
selections that the offset clamping left collapsed so they don't toggle
the pending format. Resolves the formatCommand TODO.
…tion

Browsers invoke a window's message listeners in registration order, and
the Dragon extension's content script registers its listener at
document_end of the initial page load. Per-editor handlers register when
the editor mounts, which on most real pages (navigations in single page
apps, dialogs, lazily rendered fields) is later, so their
stopImmediatePropagation can never keep the extension from applying the
edit through raw DOM mutations first, and edits end up applied twice.

Move the package to a single shared listener that dispatches to the
focused editor among the registered ones, installed lazily by
registerDragonSupport for backwards compatibility, and export
installDragonSupport so apps can call it synchronously at their
entrypoints to register the listener before the extension's content
script. Closes facebook#8664.
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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: registerDragonSupport registers too late to block the Dragon Web Extension

3 participants