[lexical-dragon] Bug Fix: Handle makeChanges messages and the listener registration race#8665
[lexical-dragon] Bug Fix: Handle makeChanges messages and the listener registration race#8665brunoprietog wants to merge 3 commits into
Conversation
|
Hi @brunoprietog! Thank you for your pull request and welcome to our community. Action RequiredIn 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. ProcessIn 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 If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks! |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
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 = -1as 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.
56faba9 to
d97bb23
Compare
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.
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.
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.
d97bb23 to
99dd8df
Compare
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.
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.
9858735 to
1f12db6
Compare
1f12db6 to
4e19f84
Compare
potatowagon
left a comment
There was a problem hiding this comment.
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+editorsSet).installDragonSupport()lets SPA entrypoints register before Dragon's content script loads. - SSR-safe, double-teardown guarded,
Array.isArrayvalidation,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.
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.
4e19f84 to
d09a6b0
Compare
Description
The Dragon NaturallySpeaking web extension drives editable fields through window messages (protocol
nuanria_messaging, functionmakeChanges, args[elementStart, elementLength, text, selStart, selLength, formatCommand]).Dragon sends the number -1 as the
textargument 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-casestext === -1the same way.registerDragonSupportpasses the value straight toinsertRawText, which throwstext.split is not a functioninsideeditor.updateon 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_endof 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 theirstopImmediatePropagationcan 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:
textas selection-only, and validates the payload up front so nothing can throw insideeditor.update:argsmust be an array (destructuring a string inserts stray characters and destructuring a non-iterable throws), the offsets must be finite numbers (string offsets pass>= 0checks through coercion and then concatenate), and negative final offsets collapse the selection instead of leaving a range the next input would replace.formatCommandargument (execCommand names likebold, sent by voice commands like "bold that") by mapping the supported names to Lexical text formats, resolving the existing TODO.registerDragonSupportso existing behavior is unchanged, and exportsinstallDragonSupport()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 functionand 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-dragonpasses all 12 tests, covering the protocol (replacement, selection-only, the correction flow, malformed and out-of-range payloads, format commands) and the shared listener (installDragonSupportbefore any editor mounts blocks foreign listeners, dispatching to the focused editor among several, editors created after others are disposed).