Phenomenon
On Android Chrome with Samsung keyboard text prediction enabled, typing text next to a link in a contenteditable element causes the selection in beforeinput event to differ from the selection in input event.
Reproduction example
- Open Chrome browser on an Android device (Samsung Galaxy series, etc.).
- Enable text prediction feature in Samsung keyboard.
- Prepare HTML with an anchor link inside a
contenteditableelement (e.g.,<a href="https://example.com">Link text</a>). - Position the cursor right next to (after) the anchor link.
- Type text (e.g., โHelloโ).
- Observe the selection in
beforeinputandinputevents in the browser console.
Observed behavior
When typing text next to a link:
-
beforeinput event:
window.getSelection().getRangeAt(0).startContainermay be the<a>element- Selection includes link text
startOffsetandendOffsetare in unexpected format
-
input event:
window.getSelection().getRangeAt(0).startContaineris the text node after the link- Selection reflects actual cursor position
- Different container and offset from
beforeinputselection
-
Result:
- Selection information stored in
beforeinputhandler cannot be used ininputhandler - State synchronization issues occur
- Position tracking is inaccurate
- Selection information stored in
Expected behavior
- Selections in
beforeinputandinputshould match - Both events should have the same container and offset
- Selection should not include link element but only reflect actual cursor position
Impact
- State synchronization issues: Selection stored in
beforeinputcannot be used ininput - Incorrect position tracking: Selection mismatch causes inaccurate position tracking
- Undo/redo inconsistencies: Undo/redo stacks may record incorrect positions
Browser Comparison
- Android Chrome + Samsung Keyboard (Text Prediction ON): This issue occurs
- Android Chrome + Samsung Keyboard (Text Prediction OFF): Works normally
- Android Chrome + Gboard: Works normally
- Other browsers: Similar issues may occur with other IMEs or text prediction
Notes and possible direction for workarounds
- Selection normalization: Normalize selections in both
beforeinputandinputfor comparison - Store DOM state: Store DOM state instead of selection for comparison
- Use getTargetRanges(): Use
getTargetRanges()when available (but may also be empty array in this case)
Code example
const editor = document.querySelector('div[contenteditable]');
let beforeInputSelection = null;
editor.addEventListener('beforeinput', (e) => {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0).cloneRange();
// Normalize selection (exclude link)
beforeInputSelection = normalizeSelectionForLink(range);
}
});
editor.addEventListener('input', (e) => {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0).cloneRange();
const inputSelection = normalizeSelectionForLink(range);
// Compare selections
if (beforeInputSelection && !selectionsMatch(beforeInputSelection, inputSelection)) {
console.warn('Selection mismatch detected');
// Handle mismatch
}
}
beforeInputSelection = null;
});
function normalizeSelectionForLink(range) {
let container = range.startContainer;
if (container.nodeType === Node.TEXT_NODE) {
container = container.parentElement;
}
const link = container.closest('a');
if (link && range.startContainer === link) {
// Adjust to position after link
const normalized = document.createRange();
try {
normalized.setStartAfter(link);
normalized.collapse(true);
return normalized;
} catch (e) {
return range;
}
}
return range.cloneRange();
}
function selectionsMatch(range1, range2) {
if (!range1 || !range2) return false;
return range1.startContainer === range2.startContainer &&
range1.startOffset === range2.startOffset;
}