iOS dictation re-fires input events with text split into words
OS: iOS 17.0+ · Device: iPhone or iPad Any · Browser: Safari 17.0+ · Keyboard: Voice Dictation
Open case →Scenario
On iOS, when using voice dictation to input text into contenteditable elements, the system may fire duplicate beforeinput and input events after the initial dictation completes. The text is split into words and events are re-fired, causing synchronization issues. Composition events do not fire during dictation, making it difficult to distinguish dictation from keyboard input.
On iOS, when using the built-in voice dictation feature to input text into contenteditable elements, the system may fire duplicate beforeinput and input events after the initial dictation completes. The dictated text is split into individual words and events are re-fired, causing synchronization issues between the event sequence and the actual DOM state.
When using iOS dictation:
beforeinput and input events fire with the complete textcompositionstart, compositionupdate, compositionend)beforeinput and input events fire during dictationWhen using iOS dictation, the following event pattern is observed:
beforeinput → input eventsbeforeinput → input events fire againcompositionstart, compositionupdate, compositionend events do NOT fire in any phaseSee the “Event Sequence” section below for detailed event order.
Important: There is no reliable way to detect dictation input in web applications on iOS. The following characteristics may help identify potential dictation input, but they are not definitive:
UITextInputContext.isDictationInputExpected are not available in web contextsThe sequence of events when inputting “만나서 반갑습니다” via iOS dictation:
User: Activates dictation → Speaks "만나서 반갑습니다"
Event 1: beforeinput
- inputType: 'insertText'
- data: '만나서 반갑습니다'
- isComposing: false
- DOM state: (before) ""
Event 2: input
- inputType: 'insertText'
- data: '만나서 반갑습니다'
- isComposing: false
- DOM state: (after) "만나서 반갑습니다"
After the initial input completes, after a short delay (typically 100-500ms), events are re-fired with text split into words:
Event 3: beforeinput
- inputType: 'insertText'
- data: '만나서'
- isComposing: false
- DOM state: (before) "만나서 반갑습니다" (already exists)
Event 4: input
- inputType: 'insertText'
- data: '만나서'
- isComposing: false
- DOM state: (after) "만나서 반갑습니다" (no change)
Event 5: beforeinput
- inputType: 'insertText'
- data: ' ' (space)
- isComposing: false
- DOM state: (before) "만나서 반갑습니다"
Event 6: input
- inputType: 'insertText'
- data: ' '
- isComposing: false
- DOM state: (after) "만나서 반갑습니다" (no change)
Event 7: beforeinput
- inputType: 'insertText'
- data: '반갑습니다'
- isComposing: false
- DOM state: (before) "만나서 반갑습니다"
Event 8: input
- inputType: 'insertText'
- data: '반갑습니다'
- isComposing: false
- DOM state: (after) "만나서 반갑습니다" (no change)
| Order | Event | inputType | data | DOM Changed |
|---|---|---|---|---|
| 1 | beforeinput | insertText | ’만나서 반갑습니다’ | - |
| 2 | input | insertText | ’만나서 반갑습니다’ | ✅ Changed |
| 3 | beforeinput | insertText | ’만나서’ | - |
| 4 | input | insertText | ’만나서’ | ❌ No change |
| 5 | beforeinput | insertText | ’ ‘ | - |
| 6 | input | insertText | ’ ’ | ❌ No change |
| 7 | beforeinput | insertText | ’반갑습니다’ | - |
| 8 | input | insertText | ’반갑습니다’ | ❌ No change |
compositionstart, compositionupdate, compositionend events do NOT fire in any phaseisComposing: falseCode example to monitor all events during iOS dictation input:
const element = document.querySelector('[contenteditable]');
const eventLog = [];
// Monitor all relevant events
const eventsToMonitor = [
'compositionstart',
'compositionupdate',
'compositionend',
'beforeinput',
'input',
'keydown',
'keyup',
'keypress'
];
eventsToMonitor.forEach(eventType => {
element.addEventListener(eventType, (e) => {
const eventData = {
timestamp: Date.now(),
type: eventType,
inputType: e.inputType || null,
data: e.data || null,
isComposing: e.isComposing || false,
textContent: element.textContent,
selectionStart: window.getSelection()?.anchorOffset || null,
selectionEnd: window.getSelection()?.focusOffset || null
};
eventLog.push(eventData);
console.log(`[${eventType}]`, eventData);
}, { capture: true });
});
// Print event log
function printEventLog() {
console.table(eventLog);
return eventLog;
}
Actual event sequence when inputting “만나서 반갑습니다” via dictation on iOS Safari:
[beforeinput] {
timestamp: 1000,
type: 'beforeinput',
inputType: 'insertText',
data: '만나서 반갑습니다',
isComposing: false,
textContent: '',
selectionStart: 0,
selectionEnd: 0
}
[input] {
timestamp: 1001,
type: 'input',
inputType: 'insertText',
data: '만나서 반갑습니다',
isComposing: false,
textContent: '만나서 반갑습니다',
selectionStart: 8,
selectionEnd: 8
}
// Approximately 200ms delay
[beforeinput] {
timestamp: 1201,
type: 'beforeinput',
inputType: 'insertText',
data: '만나서',
isComposing: false,
textContent: '만나서 반갑습니다',
selectionStart: 8,
selectionEnd: 8
}
[input] {
timestamp: 1202,
type: 'input',
inputType: 'insertText',
data: '만나서',
isComposing: false,
textContent: '만나서 반갑습니다', // No change
selectionStart: 8,
selectionEnd: 8
}
[beforeinput] {
timestamp: 1203,
type: 'beforeinput',
inputType: 'insertText',
data: ' ',
isComposing: false,
textContent: '만나서 반갑습니다',
selectionStart: 8,
selectionEnd: 8
}
[input] {
timestamp: 1204,
type: 'input',
inputType: 'insertText',
data: ' ',
isComposing: false,
textContent: '만나서 반갑습니다', // No change
selectionStart: 8,
selectionEnd: 8
}
[beforeinput] {
timestamp: 1205,
type: 'beforeinput',
inputType: 'insertText',
data: '반갑습니다',
isComposing: false,
textContent: '만나서 반갑습니다',
selectionStart: 8,
selectionEnd: 8
}
[input] {
timestamp: 1206,
type: 'input',
inputType: 'insertText',
data: '반갑습니다',
isComposing: false,
textContent: '만나서 반갑습니다', // No change
selectionStart: 8,
selectionEnd: 8
}
The following events do NOT fire during iOS dictation:
compositionstartcompositionupdatecompositionendkeydown (for dictation input)keyup (for dictation input)keypress (for dictation input)| Event Type | Initial Input | Duplicate Events | Fires? |
|---|---|---|---|
beforeinput | ✅ 1 time | ✅ 3 times | Yes |
input | ✅ 1 time | ✅ 3 times | Yes |
compositionstart | ❌ | ❌ | No |
compositionupdate | ❌ | ❌ | No |
compositionend | ❌ | ❌ | No |
keydown | ❌ | ❌ | No |
keyup | ❌ | ❌ | No |
keypress | ❌ | ❌ | No |
How ProseMirror handles input events on iOS Safari:
insertText input.beforeinput/input events without composition events.ProseMirror has applied the following fix to address IME composition issues on iOS Safari:
Problem: In Safari, re-selecting text during IME composition prevents compositionend from firing and causes duplicate compositionstart/compositionupdate events, leading to character duplication.
Solution: Modified prosemirror-view to avoid re-selection during composition:
// Example modification in ProseMirror's setSelection
if (!view.composing) {
view.docView.setSelection(anchor, head, view.root, force);
}
This change prevents selection updates during composition, maintaining the IME lifecycle.
isComposing state synchronization issues: On iOS Safari, isComposing is always false or composition events don’t fire, so ProseMirror’s view.composing state may not be accurately tracked.isComposing to an incorrect state, causing subsequent input to not display properly.isComposing state synchronization issues on iOS Safari (discuss.prosemirror.net)ProseMirror also experiences the same limitations of iOS dictation (no composition events, inaccurate isComposing) and has no special solution for dictation. Instead, it addresses IME composition issues by avoiding selection re-setting during composition.
Considerations regarding cursor position and selection state after dictation completion:
window.getSelection() may return an unexpected stateHow iOS dictation affects the undo/redo stack:
Problem scenario:
When both Voice Control and Dictation are enabled in iOS settings:
User guidance:
Reproduction status across different environments:
| iOS Version | Browser | Language | Reproduced | Notes |
|---|---|---|---|---|
| iOS 16.x | Safari | Korean | ✅ Confirmed | Text re-fired word-by-word |
| iOS 16.x | Safari | English | ✅ Confirmed | Text re-fired word-by-word |
| iOS 16.x | Chrome iOS | Korean | ✅ Confirmed | Same as Safari (WebKit engine) |
| iOS 17.x | Safari | Korean | ✅ Confirmed | Same behavior as iOS 16 |
| iOS 17.x | Safari | English | ✅ Confirmed | Same behavior as iOS 16 |
| iOS 17.x | Chrome iOS | Korean | ✅ Confirmed | Same as Safari |
| iOS 18.x | Safari | Korean | ⚠️ Unconfirmed | Testing needed |
| iOS 18.x | Safari | English | ⚠️ Unconfirmed | Testing needed |
Testing method:
contenteditable elementbeforeinput and input event logs in browser consoleNote:
This issue has been officially reported in the WebKit bug tracker:
compositionstart, compositionupdate, compositionend events do not fire when using dictation on iOS/iPadOSThis bug report documents that iOS dictation does not fire composition events, and confirms that macOS Safari works correctly.
Other major editor frameworks also experience the limitations of iOS dictation:
All editor frameworks share these common issues:
isComposing flag is inaccurateSimilar issues related to iOS dictation have been reported in React Native applications:
This suggests similar issues may occur in web applications.
Visual view of how this scenario connects to its concrete cases and environments. Nodes can be dragged and clicked.
Each row is a concrete case for this scenario, with a dedicated document and playground.
| Case | OS | Device | Browser | Keyboard | Status |
|---|---|---|---|---|---|
| ce-0293-ios-dictation-duplicate-events-safari | iOS 17.0+ | iPhone or iPad Any | Safari 17.0+ | Voice Dictation | draft |
Open a case to see the detailed description and its dedicated playground.
OS: iOS 17.0+ · Device: iPhone or iPad Any · Browser: Safari 17.0+ · Keyboard: Voice Dictation
Open case →Other scenarios that share similar tags or category.
During IME composition or in certain browser/IME combinations, the beforeinput event may have a different inputType than the corresponding input event. For example, beforeinput may fire with insertCompositionText while input fires with deleteContentBackward. This mismatch can cause handlers to misinterpret the actual DOM change and requires storing beforeinput's targetRanges for use in input event handling.
The selection (window.getSelection()) in beforeinput events can differ from the selection in corresponding input events. This mismatch can occur during IME composition, text prediction, or when typing adjacent to formatted elements like links. The selection in beforeinput may include adjacent formatted text, while input selection reflects the final cursor position.
In Safari desktop, when preventDefault() is called on keydown or beforeinput events for insertParagraph (Enter key), the IME composition state becomes corrupted. Subsequent text input fails to trigger proper input events, causing characters to not be inserted or composition to malfunction.
The getTargetRanges() method in beforeinput events may return an empty array or undefined in various scenarios, including text prediction, certain IME compositions, or specific browser/device combinations. When getTargetRanges() is unavailable, developers must rely on window.getSelection() as a fallback, but this may be less accurate.
Analysis of how out-of-order or missing composition events disrupt editor state synchronization.
Have questions, suggestions, or want to share your experience? Join the discussion below.