Common Pitfalls & Debugging

Common mistakes, pitfalls, and debugging strategies when working with contenteditable.

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?
  • Event Issues:
    • ✓ Are you listening to both beforeinput and input?
    • ✓ Is composition state tracked correctly?
    • ✓ Are you handling Safari's limited beforeinput support?
    • ✓ Are keyboard event fallbacks provided for unsupported inputType values?
  • 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?

Related resources