Overview
Working with contenteditable can be challenging due to browser inconsistencies, complex event handling, and edge cases. This guide covers common pitfalls developers encounter and strategies for debugging issues.
⚠️ Why contenteditable is Difficult
contenteditable has many edge cases because:
- Browser implementations differ significantly (Chrome, Firefox, Safari, Edge)
- OS and keyboard variations affect behavior (IME composition, mobile keyboards)
- Event timing and order vary across platforms
- Selection and Range APIs have subtle differences
- DOM mutations can interfere with browser's native behavior
- Undo/redo stack management is complex
Selection & Range Pitfalls
⚠️ Pitfall: Assuming Selection is Always Valid
Problem: After DOM mutations, the selection may become invalid or point to removed nodes.
// ❌ BAD: Selection may be invalid after DOM changes
const selection = window.getSelection();
const range = selection.getRangeAt(0);
// ... modify DOM ...
range.toString(); // May throw error if nodes were removed
// ✅ GOOD: Always check selection validity
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
// ... modify DOM ...
if (range.startContainer.isConnected && range.endContainer.isConnected) {
range.toString(); // Safe
}⚠️ Pitfall: Not Normalizing Selection
Problem: Selection ranges may span across block boundaries or include unexpected nodes.
// ❌ BAD: Selection may include unwanted nodes
const range = selection.getRangeAt(0);
const contents = range.extractContents(); // May include block elements
// ✅ GOOD: Normalize selection to text nodes only
function normalizeSelection(range) {
// Expand to include full text nodes
range.selectNodeContents(range.commonAncestorContainer);
// Then collapse to start/end of actual selection
// (Implementation depends on your needs)
}⚠️ Pitfall: Selection Lost After Programmatic DOM Changes
Problem: When you modify the DOM programmatically, the browser may lose the selection.
// ❌ BAD: Selection lost after DOM update
const selection = window.getSelection();
const range = selection.getRangeAt(0);
element.innerHTML = newContent; // Selection lost!
// ✅ GOOD: Save and restore selection
function saveSelection() {
const selection = window.getSelection();
if (selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
return {
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset,
};
}
function restoreSelection(saved) {
const selection = window.getSelection();
const range = document.createRange();
range.setStart(saved.startContainer, saved.startOffset);
range.setEnd(saved.endContainer, saved.endOffset);
selection.removeAllRanges();
selection.addRange(range);
}Event Handling Pitfalls
⚠️ Pitfall: Relying Only on beforeinput
Problem: beforeinput may not fire in all cases (Safari limitations, mobile keyboards, IME composition).
// ❌ BAD: Only listening to beforeinput
element.addEventListener('beforeinput', (e) => {
e.preventDefault();
handleInput(e);
}); // May miss some inputs!
// ✅ GOOD: Listen to both beforeinput and input
element.addEventListener('beforeinput', (e) => {
e.preventDefault();
handleInput(e);
});
element.addEventListener('input', (e) => {
// Fallback for cases where beforeinput didn't fire
if (!wasHandled(e)) {
handleInput(e);
}
});⚠️ Pitfall: Not Handling IME Composition State
Problem: During IME composition, events behave differently and preventing default may cancel composition.
// ❌ BAD: Preventing all inputs during composition
let isComposing = false;
element.addEventListener('compositionstart', () => {
isComposing = true;
});
element.addEventListener('beforeinput', (e) => {
if (isComposing) {
e.preventDefault(); // May cancel composition!
}
});
// ✅ GOOD: Only prevent non-composition inputs
element.addEventListener('compositionstart', () => {
isComposing = true;
});
element.addEventListener('compositionend', () => {
isComposing = false;
});
element.addEventListener('beforeinput', (e) => {
if (isComposing && e.inputType !== 'insertCompositionText') {
// Don't prevent composition-related inputs
return;
}
e.preventDefault();
handleInput(e);
});⚠️ Pitfall: Event Order Assumptions
Problem: Event order (beforeinput, input, composition events) varies across browsers and platforms.
// ❌ BAD: Assuming event order
element.addEventListener('beforeinput', () => {
console.log('1. beforeinput');
});
element.addEventListener('input', () => {
console.log('2. input'); // May fire before beforeinput in some cases!
});
// ✅ GOOD: Don't rely on event order
// Use flags or state management instead
let inputHandled = false;
element.addEventListener('beforeinput', (e) => {
inputHandled = true;
handleInput(e);
});
element.addEventListener('input', (e) => {
if (!inputHandled) {
handleInput(e); // Fallback
}
inputHandled = false;
});DOM Manipulation Pitfalls
⚠️ Pitfall: Clearing Undo Stack
Problem: Programmatic DOM changes can clear the browser's native undo/redo stack.
// ❌ BAD: Direct DOM manipulation clears undo stack
element.addEventListener('beforeinput', (e) => {
e.preventDefault();
element.innerHTML = newContent; // Undo stack cleared!
});
// ✅ GOOD: Use Range API or preserve undo stack
element.addEventListener('beforeinput', (e) => {
e.preventDefault();
const selection = window.getSelection();
const range = selection.getRangeAt(0);
// Use Range API to modify content
range.deleteContents();
const textNode = document.createTextNode(e.data);
range.insertNode(textNode);
// Or use document.execCommand carefully (deprecated but preserves undo)
// document.execCommand('insertText', false, e.data);
});⚠️ Pitfall: Not Sanitizing Pasted HTML
Problem: Pasting HTML can introduce XSS vulnerabilities or unwanted formatting.
// ❌ BAD: Directly inserting pasted HTML
element.addEventListener('paste', (e) => {
e.preventDefault();
const html = e.clipboardData.getData('text/html');
element.innerHTML += html; // XSS risk!
});
// ✅ GOOD: Sanitize HTML before insertion
import DOMPurify from 'dompurify';
element.addEventListener('paste', (e) => {
e.preventDefault();
const html = e.clipboardData.getData('text/html');
const sanitized = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u'],
ALLOWED_ATTR: []
});
const selection = window.getSelection();
const range = selection.getRangeAt(0);
range.deleteContents();
const temp = document.createElement('div');
temp.innerHTML = sanitized;
const fragment = document.createDocumentFragment();
while (temp.firstChild) {
fragment.appendChild(temp.firstChild);
}
range.insertNode(fragment);
});IME & Composition Pitfalls
⚠️ Pitfall: Ignoring Composition Events
Problem: During IME composition, beforeinput and input events behave differently, and preventing default may cancel composition.
// ❌ BAD: Not tracking composition state
element.addEventListener('beforeinput', (e) => {
e.preventDefault();
// May cancel active composition!
});
// ✅ GOOD: Track composition state
let isComposing = false;
element.addEventListener('compositionstart', () => {
isComposing = true;
});
element.addEventListener('compositionend', () => {
isComposing = false;
});
element.addEventListener('beforeinput', (e) => {
// Don't prevent composition-related inputs
if (isComposing && e.inputType === 'insertCompositionText') {
return; // Let browser handle it
}
e.preventDefault();
handleInput(e);
});⚠️ Pitfall: macOS Korean IME Formatting Issues
Problem: On macOS with Korean IME, formatting commands (Cmd+B, Cmd+I) may not fire beforeinput when cursor is collapsed during composition.
// ❌ BAD: Only listening to beforeinput for formatting
element.addEventListener('beforeinput', (e) => {
if (e.inputType === 'formatBold') {
e.preventDefault();
applyBold();
}
}); // May miss formatting on macOS Korean IME!
// ✅ GOOD: Also listen to keyboard events as fallback
element.addEventListener('beforeinput', (e) => {
if (e.inputType === 'formatBold') {
e.preventDefault();
applyBold();
}
});
element.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
e.preventDefault();
applyBold(); // Fallback for macOS Korean IME
}
});Browser-Specific Pitfalls
⚠️ Pitfall: Safari beforeinput Limitations
Problem: Safari has limited beforeinput support. Some inputType values may not fire.
// ❌ BAD: Assuming beforeinput works for all inputTypes
element.addEventListener('beforeinput', (e) => {
switch (e.inputType) {
case 'formatBold':
case 'formatItalic':
case 'insertParagraph':
e.preventDefault();
handleInput(e);
break;
}
}); // May not work in Safari!
// ✅ GOOD: Check browser support and provide fallbacks
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
element.addEventListener('beforeinput', (e) => {
if (isSafari && !isInputTypeSupported(e.inputType)) {
return; // Let browser handle it
}
e.preventDefault();
handleInput(e);
});
// Provide keyboard event fallbacks for Safari
if (isSafari) {
element.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
e.preventDefault();
applyBold();
}
});
}⚠️ Pitfall: Chrome/Firefox DOM Structure Differences
Problem: insertParagraph creates different DOM structures across browsers (Chrome: <p>, Firefox: <p><br>, Safari: <div>).
// ❌ BAD: Assuming consistent DOM structure
element.addEventListener('beforeinput', (e) => {
if (e.inputType === 'insertParagraph') {
e.preventDefault();
// Assumes <p> is created - may be <div> in Safari!
}
});
// ✅ GOOD: Normalize DOM structure after insertion
element.addEventListener('beforeinput', (e) => {
if (e.inputType === 'insertParagraph') {
e.preventDefault();
insertParagraph();
normalizeDOM(); // Ensure consistent structure
}
});
function normalizeDOM() {
// Convert all <div> paragraphs to <p> if needed
// Remove empty <br> tags
// Ensure consistent structure across browsers
}Debugging Strategies
1. Enable Event Logging
Log all events to understand the event flow:
const eventTypes = [
'beforeinput', 'input', 'keydown', 'keyup',
'compositionstart', 'compositionupdate', 'compositionend',
'selectionchange', 'focus', 'blur'
];
eventTypes.forEach(type => {
element.addEventListener(type, (e) => {
console.log(`[${type}]`, {
inputType: e.inputType,
data: e.data,
key: e.key,
selection: window.getSelection()?.toString(),
// ... other relevant properties
});
});
});2. Monitor Selection Changes
Track selection changes to detect when selection is lost or invalid:
document.addEventListener('selectionchange', () => {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
console.log('Selection:', {
collapsed: range.collapsed,
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset,
isValid: range.startContainer.isConnected && range.endContainer.isConnected
});
} else {
console.warn('Selection lost!');
}
});3. Compare DOM Before/After
Store DOM state before operations to detect unexpected changes:
function getDOMSnapshot(element) {
return {
html: element.innerHTML,
textContent: element.textContent,
selection: saveSelection()
};
}
element.addEventListener('beforeinput', (e) => {
const before = getDOMSnapshot(element);
console.log('Before:', before);
// ... handle input ...
setTimeout(() => {
const after = getDOMSnapshot(element);
console.log('After:', after);
console.log('Changes:', diffDOM(before, after));
}, 0);
});4. Test Across Browsers & Platforms
Always test in multiple browsers and platforms. Use browser detection to log environment:
function logEnvironment() {
console.log({
userAgent: navigator.userAgent,
platform: navigator.platform,
language: navigator.language,
browser: detectBrowser(),
os: detectOS(),
isMobile: /Mobile|Android|iPhone/i.test(navigator.userAgent)
});
}
// Log on page load
logEnvironment();Common Issues Checklist
Use this checklist when debugging contenteditable issues:
- Selection Issues:
- ✓ Is selection valid after DOM changes? (check
isConnected) - ✓ Is selection saved/restored when needed?
- ✓ Is selection normalized to avoid spanning block boundaries?
- ✓ Is selection valid after DOM changes? (check
- Event Issues:
- ✓ Are you listening to both
beforeinputandinput? - ✓ Is composition state tracked correctly?
- ✓ Are you handling Safari's limited
beforeinputsupport? - ✓ Are keyboard event fallbacks provided for unsupported
inputTypevalues?
- ✓ Are you listening to both
- DOM Issues:
- ✓ Is pasted HTML sanitized? (XSS prevention)
- ✓ Is undo stack preserved when using
preventDefault()? - ✓ Is DOM structure normalized across browsers?
- ✓ Are programmatic DOM changes not clearing the undo stack?
- IME Issues:
- ✓ Is composition state tracked?
- ✓ Are composition-related inputs not being prevented?
- ✓ Are macOS Korean IME formatting issues handled?
- ✓ Are mobile keyboard text prediction features considered?
- Browser Compatibility:
- ✓ Tested in Chrome, Firefox, Safari, Edge?
- ✓ Tested on macOS, Windows, Linux, iOS, Android?
- ✓ Tested with different keyboard layouts (US, Korean, Japanese, Chinese)?
- ✓ Are browser-specific workarounds implemented?