Skip to content

Fix Dragon NaturallySpeaking corrections breaking the editor#1101

Open
brunoprietog wants to merge 2 commits into
mainfrom
dragon-dictation-support
Open

Fix Dragon NaturallySpeaking corrections breaking the editor#1101
brunoprietog wants to merge 2 commits into
mainfrom
dragon-dictation-support

Conversation

@brunoprietog

@brunoprietog brunoprietog commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

A customer who dictates with Dragon NaturallySpeaking reported that correcting a word by voice in any rich text field destroys everything after the corrected word (Basecamp card). The cause is in the handler that Lexical ships for Dragon's web extension messages, which Lexxy picks up through the RichTextExtension dependency chain: Dragon sends the number -1 as the text argument to mean "change the selection without touching the text", the handler passes it straight to insertRawText, and the resulting exception inside editor.update leaves the editor permanently broken. From then on the extension edits the DOM behind Lexical's back and content disappears when the two diverge. On top of that, the stock handler registers per editor, which is always after the extension's content script, so its stopImmediatePropagation can never actually block the extension's raw DOM edits.

So this PR:

  • Replaces the stock handler with our own DragonSupport, installed once when the Lexxy module loads, early enough to win the listener registration race against the extension's content script.
  • Honors the -1 sentinel, so corrections and cursor moves by voice work without breaking the editor.
  • Absorbs every makeChanges addressed to a Lexxy editor, malformed payloads included, since both the extension's raw DOM edits and the broken stock handler behind us would corrupt the editor.
  • Applies Dragon's format commands ("bold that"), mapping the execCommand names the extension sends to Lexical text formats.
  • Adds Playwright coverage for the protocol: replacement, selection-only, the full correction flow, malformed and out-of-range payloads, format commands, unknown commands, and clamped selections not toggling the pending format.

Everything here is also submitted upstream in facebook/lexical#8665, including an installDragonSupport() entrypoint API for the registration race, with the maintainer on board with that direction. Once it ships in a release we adopt, our handler can be replaced with a call to that function at the entrypoint.

I validated the handler against the real extension's unpacked content scripts in both registration orders, but final confirmation with Dragon itself will have to come through the customer, since it needs their setup.

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 first-class support for Dragon NaturallySpeaking’s web-extension messaging protocol to prevent Dragon corrections/selection changes from crashing the Lexical editor and causing subsequent DOM/state divergence, while also supporting Dragon format commands (e.g. “bold that”). It installs a page-level handler at module load time to reliably intercept and apply Dragon’s makeChanges messages through Lexical APIs.

Changes:

  • Add a global DragonSupport window message handler that honors Dragon’s text === -1 selection-only sentinel and applies supported format commands.
  • Install DragonSupport at module load so it can intercept messages early and stop the extension’s raw DOM mutation handler.
  • Add Playwright coverage for replacement, selection-only updates, correction flow, supported/unknown format commands, and clamped selections.

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.

Reviewed changes

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

File Description
test/browser/tests/dragon.test.js Adds Playwright tests covering Dragon’s makeChanges protocol scenarios.
src/index.js Installs Dragon support at module load to intercept Dragon messages early.
src/editor/dragon_support.js Implements a Lexical-safe Dragon message handler (selection-only sentinel + format commands).

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

Comment thread src/editor/dragon_support.js Outdated
Comment thread src/editor/dragon_support.js
Comment thread src/editor/dragon_support.js Outdated
Comment thread src/editor/dragon_support.js Outdated
@brunoprietog brunoprietog force-pushed the dragon-dictation-support branch 4 times, most recently from fc25839 to a50e108 Compare June 10, 2026 06:21
@brunoprietog brunoprietog marked this pull request as ready for review June 10, 2026 07:29
Copilot AI review requested due to automatic review settings June 10, 2026 07:29

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 2 comments.

Comment thread src/editor/vendor/dragon_support.js
Comment thread src/editor/dragon_support.js
@brunoprietog brunoprietog force-pushed the dragon-dictation-support branch from a50e108 to 8ff4f45 Compare June 10, 2026 07:57
@jorgemanrubia

Copy link
Copy Markdown
Member

🤖 Suggestion: validate the makeChanges args before entering editor.update.

The PR's goal is to absorb malformed payloads because an exception inside editor.update leaves the editor permanently broken — but #applyMakeChanges runs the update with no validation of the args' types and no try/catch, so a payload that slips past the current guards and throws mid-update reproduces the exact failure mode being fixed. Concretely:

  • #setAddressedRange checks this.elementStart >= 0, but "4" >= 0 is true for a string, and then this.elementStart + this.elementLength becomes string concatenation ("4" + "9""49"), which flows into setTextNodeRange as an offset.
  • Meanwhile #setFinalSelection guards with Number.isFinite(this.selStart + this.selLength), which correctly rejects strings — so the two range-setters apply different strictness to the same protocol.
  • #finalStart isn't clamped to 0: with a negative selStart, Math.min(-5, size) stays -5. The out-of-range test passes, so Lexical tolerates it today, but that relies on Lexical internals rather than this handler's own contract.

Suggestion: validate the positional args up front — the four offsets must be numbers, and text must be a string or the -1 sentinel — and bail before calling editor.update otherwise, while still absorbing the event with stopImmediatePropagation. That makes the "malformed payloads included" guarantee hold by construction rather than by Lexical's tolerance. A try/catch around the update body is weaker insurance (Lexical may already be in a bad state by the time it throws) but is cheap extra belt-and-braces on top.

Related cheap hardening while you're in there: guard #parsedMessageFrom with typeof event.data === "string" (the stock handler does this) — right now non-string event.data gets coerced by JSON.parse and takes the exception path. Adding event.data.includes("nuanria_messaging") before parsing would also skip parsing the host app's own postMessage traffic while an editor is focused.

@jorgemanrubia jorgemanrubia left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Great one @brunoprietog

Comment thread src/index.js Outdated
Comment thread test/browser/tests/dragon.test.js Outdated

@jorgemanrubia jorgemanrubia left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@brunoprietog minor comments and a suggestion via Fable that looks quite legit 👏

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

I presume that the existing @lexical/dragon extension does not properly support the sentinel value?

Given that support was extension-shaped and intercepted the event, is there an opportunity to use a Lexxy extension while still preventing the double-handling with a capturing handler?

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.
Voice commands like "bold that" arrive in the sixth makeChanges argument
as execCommand names, which is how the Dragon extension's own handler
applies them. Map the supported names to Lexical text formats and apply
them to the final selection, skipping selections that the protocol's
offset clamping left collapsed so they don't toggle the pending format.
This matches the upstream @lexical/dragon change submitted alongside
this one.
Copilot AI review requested due to automatic review settings June 10, 2026 16:32
@brunoprietog brunoprietog force-pushed the dragon-dictation-support branch from 8ff4f45 to 0b2cc32 Compare June 10, 2026 16:32

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

@brunoprietog

Copy link
Copy Markdown
Collaborator Author

Right, the stock handler breaks on the sentinel: it passes the -1 straight to insertRawText, which throws inside editor.update and leaves the editor permanently broken (it also destructures the args without checking they're an array).

About the extension shape, that was the first thing I tried. The problem is that capture doesn't buy any priority here. The message event's target is window itself, and at the target listeners run in plain registration order, so stopImmediatePropagation only helps if you registered before Dragon's content script, which registers at document_end of the initial page load. An extension registers when an editor mounts, and that's almost always later, since most editors mount on navigation or after some interaction. I verified it against the unpacked extension code in Chromium: with per-editor registration the extension's listener always runs first and the edit lands twice. That's why the install has to happen at module load, which doesn't fit the per-editor lifecycle of an extension.

Upstream is heading the same way: in facebook/lexical#8664 the maintainer suggested an exported function apps call synchronously at their entrypoint, without constructing an editor, and that's now part of facebook/lexical#8665. Once that ships in a release we adopt, this file becomes a call to their installDragonSupport() at the entrypoint.

We could still wrap the dispatch in a Lexxy extension, but since the handler resolves the focused editor from the DOM it wouldn't add much beyond ceremony, and the early call in index.js would still be needed. Happy to go that way if you'd prefer it for consistency, though.

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.

4 participants