Phenomenon
During IME composition, beforeinput may fire with inputType: 'insertCompositionText' while the corresponding input event fires with inputType: 'deleteContentBackward'. This mismatch can occur in various browser/IME combinations, including but not limited to iOS Safari with Korean IME, Firefox with certain IMEs, and other mobile browsers. This mismatch makes it impossible to correctly understand the DOM change using only the input event’s inputType.
Reproduction example
- Focus a
contenteditableelement. - Activate an IME (Korean, Japanese, Chinese, or other language).
- Start composing text (e.g., for Korean: type “ㅎ” then “ㅏ” then “ㄴ” to compose “한”).
- Continue typing to update composition (e.g., for Korean: type “ㄱ” then “ㅡ” then “ㄹ” to update to “한글”).
- Observe
beforeinputandinputevents in the browser console or event log. - Check if
beforeinput.inputTypematchesinput.inputType- they may differ.
Observed behavior
When updating composition text:
-
beforeinput event:
inputType: 'insertCompositionText'isComposing: truedata: '한글'(the new composition text)getTargetRanges()returns ranges indicating where the composition text will be inserted- The ranges typically include the previous composition text that will be replaced
-
input event:
inputType: 'deleteContentBackward'(mismatch!)data: nullor empty- The actual DOM change may be a deletion rather than the insertion indicated by
beforeinput - The composition text may be deleted instead of updated
-
Result:
- Handlers that rely on
inputTypeto determine what happened will misinterpret the change - The
targetRangesfrombeforeinputare lost and not available ininput - Application state may become inconsistent with DOM state
- Handlers that rely on
Expected behavior
- The
inputevent’sinputTypeshould match thebeforeinputevent’sinputType - If
beforeinputfires withinsertCompositionText,inputshould also haveinsertCompositionText - The
input.datashould matchbeforeinput.data(or reflect the actual committed text) - The DOM change should match what was indicated in
beforeinput
Impact
This can lead to:
- Incorrect DOM change detection: Handlers think a deletion occurred when it was actually an insertion
- Lost targetRanges context: The
targetRangesfrombeforeinputare crucial but not available ininput - Incorrect undo/redo: Undo/redo stacks record the wrong operation type
- State synchronization issues: Application state becomes inconsistent
- Event handler failures: Handlers expecting matching
inputTypevalues fail
Browser Comparison
- iOS Safari: Frequently fires
insertCompositionTextinbeforeinputbutdeleteContentBackwardininput, especially with Korean and Japanese IME - macOS Safari: May exhibit similar mismatches, particularly with certain IME combinations
- Firefox: May have mismatches in certain IME scenarios, especially on mobile devices
- Chrome/Edge: Generally consistent
inputTypebetween events, but may have edge cases - Android Chrome: Higher likelihood of mismatches due to text prediction and IME variations
- Mobile browsers: Generally higher likelihood of mismatches across different IMEs
Notes and possible direction for workarounds
-
Store targetRanges from beforeinput: Save
targetRangesfor use ininputhandler:let lastBeforeInputTargetRanges = null; let lastBeforeInputType = null; element.addEventListener('beforeinput', (e) => { lastBeforeInputTargetRanges = e.getTargetRanges?.() || []; lastBeforeInputType = e.inputType; }); element.addEventListener('input', (e) => { if (lastBeforeInputType && e.inputType !== lastBeforeInputType) { // Mismatch detected - use targetRanges to understand actual change if (lastBeforeInputTargetRanges && lastBeforeInputTargetRanges.length > 0) { // Process based on targetRanges rather than inputType handleActualChange(lastBeforeInputTargetRanges, e); } } lastBeforeInputTargetRanges = null; lastBeforeInputType = null; }); -
Compare DOM state: When mismatch occurs, compare DOM before and after to understand actual change:
let domBefore = null; element.addEventListener('beforeinput', (e) => { domBefore = element.innerHTML; }); element.addEventListener('input', (e) => { const domAfter = element.innerHTML; if (lastBeforeInputType && e.inputType !== lastBeforeInputType) { // Compare domBefore and domAfter to understand actual change const actualChange = compareDOM(domBefore, domAfter); handleChange(actualChange); } domBefore = null; }); -
Don’t rely solely on inputType: Always verify with DOM inspection when handling composition events
-
Handle gracefully: Have fallback logic that doesn’t depend on
inputTypematching