Phenomenon
In Chromium, when the user deletes an empty inline element (e.g. <span>, <b>, <i>) inside a contenteditable and then types a character, the editing engine recreates the deleted inline element and wraps the newly typed character. This comes from legacy execCommand/editing spec behavior (“recording and restoring overrides”). The input event fires after the DOM has already been modified by the browser, so the editor sees a DOM that no longer matches the state it had before the delete (e.g. no span). No beforeinput with inputType clearly describes “recreate deleted inline”; the visible effect is a new wrapper around the typed character.
Reproduction Steps
- Create a
contenteditablediv containing:hello <span></span> world(empty span between two text runs). - Place the caret immediately after the space that follows “hello” (i.e. before the empty span).
- Press Backspace once so the empty span is removed (or place caret after the span and press Delete).
- Type a single character (e.g. “x”).
- Inspect the DOM: a
<span>x</span>(or similar) appears instead of a plain text node “x”.
Observed Behavior
- Event sequence:
keydown(Backspace) → default delete removes empty span →input. Thenkeydown(“x”) → default insert →beforeinput(e.g.insertText) →input. Afterinput, the DOM contains a new inline wrapper around the typed character. - Consistency: Adding any character or space inside the span before deleting it (so the span is not “empty”) often avoids recreation. Using
display: block(or other non-inline) on the span can also change or avoid the behavior. - Other engines: Safari and Firefox may not recreate the span in the same way; behavior is Chromium-specific in practice.
Expected Behavior
Per predictable editing semantics, deleting an inline element and then typing should result in the typed character being inserted as a normal text node (or merged into an adjacent text node). The browser should not re-invent a previously deleted inline wrapper. The Input Events spec does not define “recreate deleted inline” as a standard action.
Impact
- State corruption: React/Vue/Svelte treat the DOM as derived from their state; unexpected insertion of a
<span>breaks reconciliation and can cause duplicate or wrong content. - Undo/redo: Custom history that records “delete span” then “insert text” will not match the final DOM (which has a new span).
- Serialization: HTML export may contain extra formatting (e.g.
<span>x</span>) that the user did not intend.
Browser Comparison
- Chrome (Blink): Recreates empty inline on type; confirmed in 124.x.
- Safari (WebKit): May not recreate in the same scenario; structure-dependent.
- Firefox (Gecko): Typically does not recreate the deleted empty inline in the same way.
Solutions
- Normalize on input: In the
inputhandler, walk the editable root and remove or merge redundant inline elements (e.g.span:empty, or unwrap single-text-node spans that the editor did not create). - Avoid empty inlines: When building content, avoid leaving empty
<span>/<b>/<i>nodes; use a zero-width space (\u200B) or ensure the node has content so the engine does not treat it as “empty” for this path. - beforeinput + preventDefault: For
insertText/insertCompositionText, you can preventDefault and apply your own DOM update so the browser does not run its insert (and thus does not recreate the inline). This requires correctgetTargetRanges()usage and caret restoration.