Phenomenon
One of the most elusive and frustrating behaviors on Android involves the “Ghost Buffer.” When users perform a sequence of typing, spacing, and backspacing at the start of a block or after a specific punctuation, the Android IME (Gboard, Samsung Keyboard) often fails to synchronize its last-committed word with the browser’s DOM. This results in the editor suddenly duplicating the last word or character when the user resumes typing.
Reproduction Steps
- Open a
contenteditableeditor on Android Chrome. - Type a word (e.g., “Hello”).
- Type a space.
- Press Backspace to delete the space.
- Immediately type another letter or another space.
- Observe the duplication in the DOM.
Observed Behavior
inputevent explosion: The browser sends multipleinsertTextorinsertCompositionTextevents in rapid succession.- Buffer Re-submission: The IME sends the entire internal buffer (e.g., “Hello”) again, even though it was already committed to the DOM.
- Caret Displacement: The caret often jumps to the end of the duplicated string, forcing the user to manually delete the extra text.
Expected Behavior
The IME should maintain a strict 1:1 mapping with the DOM nodes. A backspace should correctly clear the internal buffer’s “last character” state without triggering a re-submission of the entire preceding word.
Impact
- Severe Typing Resistance: Users have to constantly stop and fix duplicated text, making long-form writing nearly impossible.
- State Corruption: If the editor uses a structured model, these “unauthorized” mutations can bypass the model’s logic, leading to a de-sync between the UI and the data.
Browser Comparison
- Chrome for Android: High frequency of occurrence across various Android versions.
- Firefox for Android: Generally more stable in buffer management but has its own “stutter” issues.
- iOS Safari: Almost never exhibits this specific buffer re-submission bug.
References & Solutions
Mitigation: Composition Locking
Many frameworks (Lexical, ProseMirror) implement a “mutation guard” that detects if the DOM has changed in a way that the model didn’t authorize during a composition session, and if so, it forced-reconciles the DOM back to the model state.
/* Conceptual Mitigation */
element.addEventListener('input', (e) => {
if (isAndroid && e.inputType === 'insertCompositionText') {
// Detect if the incoming text dramatically differs from the expected diff
if (isLikelyDuplication(e.data, currentModel)) {
e.stopImmediatePropagation();
forceReconcileModelToDom();
}
}
});