Skip to content

[lexical] Bug Fix: Apply targetRange for insertReplacementText so dictation replaces words instead of deleting them#8711

Draft
jorgemanrubia wants to merge 2 commits into
facebook:mainfrom
jorgemanrubia:fix-dictation-insert-replacement-text
Draft

[lexical] Bug Fix: Apply targetRange for insertReplacementText so dictation replaces words instead of deleting them#8711
jorgemanrubia wants to merge 2 commits into
facebook:mainfrom
jorgemanrubia:fix-dictation-insert-replacement-text

Conversation

@jorgemanrubia

Copy link
Copy Markdown

Description

macOS/iOS dictation, autocorrect, and spellcheck "Replace" revise an already-inserted word by firing a beforeinput event with inputType: 'insertReplacementText'. The word to replace comes from event.getTargetRanges() and the replacement text from event.dataTransfer (event.data is typically null for this input type).

In $handleBeforeInput, the OS-provided targetRange is only synced to the Lexical selection by the pre-switch applyDOMRange call when selection.isCollapsed(). Dictation drives input through composition events, which can leave a stale, non-collapsed selection in place when the replacement event arrives. In that case applyDOMRange is skipped and the replacement is routed to whatever the selection currently covers instead of the OS-targeted word — so the misunderstood word is effectively deleted (or the wrong text replaced), leaving a gap in the sentence.

This is the same failure mode already worked around elsewhere in core: for the iOS 10-key Korean IME (deleteContentBackward with a targetRange) and for cross/same-editor drags in lexical-clipboard (clipboard.ts, which notes that a non-collapsed selection makes the beforeinput handler "skip applyDOMRange and route the insert to the wrong location").

For insertReplacementText the targetRange is authoritative, so this PR applies it before dispatching CONTROLLED_TEXT_INSERTION_COMMAND when the range is non-collapsed. The change is scoped to insertReplacementText only — insertFromYank/insertFromDrop keep their existing selection semantics.

Closes #6940

Test plan

New unit suite LexicalDictationReplacement.test.ts (modeled on LexicalIosKoreanIME.test.ts) simulates the insertReplacementText beforeinput event with a targetRange + text/plain DataTransfer.

Before

On main, with a stale non-collapsed selection present, the replacement lands on the wrong text:

  • "replaces the targeted word when a stale non-collapsed selection exists" → produces "best is a test" instead of "this is a best"
  • "replaces a multi-word targetRange that straddles two text nodes" → fails likewise

After

All three cases pass:

  • collapsed caret at end → replaces correctly (baseline, passed before and after)
  • multi-word range straddling two text nodes with a stale selection → replaces correctly
  • single word with a stale non-collapsed selection → replaces correctly

Full lexical core unit suite (706 tests) plus lexical-rich-text and lexical-clipboard suites pass; eslint/prettier/tsc clean.

Note: jsdom can't exercise real on-device dictation, so these tests reproduce the deterministic insertReplacementText + non-collapsed-selection mechanism rather than a full device dictation session. Validation on a real iOS/macOS device is still worthwhile to confirm the end-to-end scenario in #6940.

…tation replaces words instead of deleting them

macOS/iOS dictation, autocorrect and spellcheck Replace revise an
already-inserted word with an insertReplacementText beforeinput event
whose getTargetRanges() identifies the word to replace and whose
dataTransfer carries the replacement text.

The pre-switch applyDOMRange only syncs the selection to the OS
targetRange when the selection is collapsed, so a stale non-collapsed
selection (as dictation leaves behind via composition events) routes the
replacement to the wrong text, deleting the misunderstood word and
leaving a gap instead of replacing it. The targetRange is authoritative
for this input type, so apply it before inserting.

Fixes facebook#6940
@meta-cla

meta-cla Bot commented Jun 17, 2026

Copy link
Copy Markdown

Hi @jorgemanrubia!

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 17, 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 19, 2026 10:29am
lexical-playground Ready Ready Preview, Comment Jun 19, 2026 10:29am

Request Review

@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 17, 2026
@meta-cla

meta-cla Bot commented Jun 17, 2026

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@mayrang

mayrang commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Tested dictation in en / ko / ja on iPhone Safari against current main. ko / ja drop / shuffle words far more often than en at completion — especially Korean, where in some attempts almost the entire sentence collapses down to a single fragment (e.g. typing a full two-sentence greeting leaves just 엄청 behind).

Walking the event stream (beforeinput + MutationObserver flush + commit) chunk by chunk on a Japanese sample (trace-friendlier — Korean failures often skip straight to the final state):

  • progressive non-collapsed insertText events arrive as full transcripts up through the last one (e.g. こんにちは。今日はとてもいい天気ですね。私は友達とカフェでコーヒーを飲みます。, intact)
  • on dictation stop webkit fires one more non-collapsed insertText whose targetRange covers the whole text but whose data collapses to a short alternative (e.g. こんにちは。)
  • the browser then re-emits collapsed insertText events word by word to rebuild — one segment (e.g. いい) silently drops in that pass

Likely root, though I'm not sure: the shrink-then-rebuild on stop looks like webkit's own dictation reinterpret pass. Lexical's wrapper / per-chunk reconcile might amplify it but doesn't look like the trigger. Could be something else.

One small thing — the trigger described in this PR's description is insertReplacementText (autocorrect / spellcheck Replace), but the dictation-stop failure I'm seeing goes through insertText, so the targetRange patch here may not cover the #6940 scenario end-to-end. Might be worth a second look.

This is just what came out of debugging, hope it's useful either way.

…ns too

Dictation can revise an already-inserted word through an insertText
beforeinput event (data set, targetRange at the word to revise), not only
insertReplacementText. The insertText path never synced the OS targetRange
to a stale non-collapsed selection, so the revision was routed to the live
selection instead of the OS-targeted word — the same facebook#6940 deletion the
insertReplacementText case fixes, just through a different inputType.

Apply the authoritative targetRange before inserting, scoped to
non-collapsed targetRanges and stale non-collapsed selections so the
common collapsed-caret typing path is untouched.

Verified on a real iPhone (iOS 17 Safari): a synthetic insertText revision
with a stale non-collapsed selection yields "best is a test" without this
change and "this is a best" with it.
@jorgemanrubia

Copy link
Copy Markdown
Author

🤖 Thanks @mayrang — great catch, and exactly right.

The insertText path wasn't covered: the pre-switch applyDOMRange only syncs the OS targetRange to a collapsed selection, and the insertText branch (unlike the insertReplacementText case here) never applied it otherwise — so a stale non-collapsed selection routes the revision to the wrong text, same #6940 deletion through a different inputType.

I reproduced it on a real iPhone (iOS 17 Safari): a synthetic insertText revision over a stale selection yields "best is a test" on main and "this is a best" with the fix. Pushed a follow-up commit (plus a unit case) applying the authoritative targetRange on the insertText path too, scoped to non-collapsed targetRanges + stale selections so collapsed-caret typing is untouched.

On the dictation-stop collapse-then-rebuild you traced: the rebuild arrives as collapsed insertText events this fix deliberately leaves alone, and the shrink-to-fragment looks like WebKit's own reinterpret pass. So this extends the misroute fix but doesn't claim to resolve every on-device dictation-stop sequence — real-device validation is still worthwhile. Thanks again for digging into the event stream.

@etrepum

etrepum commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

@jorgemanrubia if this is ready and you would like this to be considered for approval and potential merge you'll want to click the "Ready for review" button

@mayrang

mayrang commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Tested this branch (e7584c0) on a real iPhone with iOS Safari dictation — ko / ja / en, same procedure as before (system mic, 2-3 sentences, stop).

English dictation works correctly. Korean and Japanese still lose text on dictation stop, same pattern as main: the shrink-to-fragment event fires with a short data value, and the collapsed insertText rebuild pass drops segments.

The stale-selection misroute that this PR fixes is a real improvement for the insertReplacementText and non-collapsed insertText paths (autocorrect, spellcheck replace). The dictation-stop collapse is a separate WebKit reinterpret mechanism — targetRange points at the full text and data is already the truncated alternative, so applying applyDOMRange faithfully routes it to the right place but the content itself is what WebKit chose to send.

For the ko/ja dictation-stop loss, the fix direction that came closest in my earlier debugging was skipping Lexical's processing of the shrink event entirely — letting the DOM stay intact so WebKit's word-by-word rebuild pass has the correct state to work against. Concretely: early-return from $handleBeforeInput when the event looks like a dictation finalize (non-collapsed insertText, data shorter than the selected range). The problem is that skipping means Lexical's state and the DOM diverge for the duration of the rebuild, and anything that touches DOM in that window (mutation observer flush, selection sync, reconciler) can cascade into a desync. I tried a variant that also patched the DOM back after the shrink, and it broke things further. I haven't found a better approach for this part.

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: Speech-to-text (dictation) on iOS deletes words after completion

3 participants