Scenario

Selection restoration after DOM manipulation is unreliable

After programmatically manipulating the DOM in a contenteditable element, restoring the text selection (cursor position) is unreliable across browsers. The selection may be lost, moved to an incorrect position, or become invalid.

selection
Scenario ID
scenario-selection-restoration

Details

After programmatically manipulating the DOM in a contenteditable element, restoring the text selection (cursor position) is unreliable across browsers. The selection may be lost, moved to an incorrect position, or become invalid.

Observed Behavior

Scenario 1: Inserting content programmatically

  • Chrome/Edge: Selection may be lost or moved
  • Firefox: Selection restoration more unreliable
  • Safari: Selection most likely to be lost

Scenario 2: Replacing content

  • Chrome/Edge: Selection may become invalid
  • Firefox: Similar issues
  • Safari: Selection restoration inconsistent

Scenario 3: Wrapping content in elements

  • Chrome/Edge: Selection may move inside new element
  • Firefox: Selection position unpredictable
  • Safari: Most inconsistent behavior

Scenario 4: Removing and re-adding elements

  • Chrome/Edge: Selection may be lost completely
  • Firefox: Similar issues
  • Safari: Selection restoration most unreliable

Impact

  • Loss of cursor position after operations
  • Poor user experience
  • Difficulty implementing reliable editing features
  • Need for complex selection restoration logic

Browser Comparison

  • Chrome/Edge: Generally better selection restoration
  • Firefox: More likely to lose selection
  • Safari: Most unreliable selection restoration

Workaround

Implement robust selection restoration:

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,
    commonAncestorContainer: range.commonAncestorContainer
  };
}

function restoreSelection(saved) {
  if (!saved) return false;
  
  try {
    const range = document.createRange();
    range.setStart(saved.startContainer, saved.startOffset);
    range.setEnd(saved.endContainer, saved.endOffset);
    
    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);
    return true;
  } catch (e) {
    // Selection is invalid, try to find nearest valid position
    return restoreSelectionFallback(saved);
  }
}

function restoreSelectionFallback(saved) {
  // Find the common ancestor
  let node = saved.commonAncestorContainer;
  
  // Walk up to find a valid text node or element
  while (node && node.nodeType !== Node.TEXT_NODE && node.nodeType !== Node.ELEMENT_NODE) {
    node = node.parentNode;
  }
  
  if (!node) return false;
  
  try {
    const range = document.createRange();
    if (node.nodeType === Node.TEXT_NODE) {
      const length = node.textContent.length;
      range.setStart(node, Math.min(saved.startOffset, length));
      range.setEnd(node, Math.min(saved.endOffset, length));
    } else {
      range.selectNodeContents(node);
      range.collapse(true);
    }
    
    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);
    return true;
  } catch (e) {
    return false;
  }
}

// Use before DOM manipulation
element.addEventListener('beforeinput', (e) => {
  if (e.inputType === 'insertText' || e.inputType === 'insertParagraph') {
    const saved = saveSelection();
    e.savedSelection = saved; // Store for later restoration
  }
});

// Restore after manipulation
function manipulateDOM(callback) {
  const saved = saveSelection();
  callback();
  
  // Restore selection after DOM updates
  requestAnimationFrame(() => {
    if (!restoreSelection(saved)) {
      restoreSelectionFallback(saved);
    }
  });
}

References

Scenario flow

Visual view of how this scenario connects to its concrete cases and environments. Nodes can be dragged and clicked.

React Flow mini map

Variants

Each row is a concrete case for this scenario, with a dedicated document and playground.

Case OS Device Browser Keyboard Status
ce-0110-selection-lost-after-dom-update Windows 11 Desktop or Laptop Any Chrome 120.0 US draft
ce-0130-selection-invalid-after-wrap Windows 11 Desktop or Laptop Any Firefox 120.0 US draft
ce-0138-selection-collapse-on-click macOS 14.0 Desktop or Laptop Any Safari 17.0 US draft
ce-0149-selection-range-invalid-after-insert Windows 11 Desktop or Laptop Any Firefox 120.0 US draft
ce-0172-selection-invalid-after-replace Windows 11 Desktop or Laptop Any Safari 17.0 US draft

Browser compatibility

This matrix shows which browser and OS combinations have documented cases for this scenario. Click on a cell to view the specific case.

Confirmed
Draft
No case documented

Cases

Open a case to see the detailed description and its dedicated playground.

Related Scenarios

Other scenarios that share similar tags or category.

Tags: selection, cursor

Cursor disappears with contenteditable="false" elements

When contenteditable='false' elements are placed inside a contenteditable container, the cursor may disappear or become invisible in certain browsers, making it difficult for users to determine the text insertion point.

0 cases
Tags: selection, cursor

selection.addRange not working correctly in Safari

When setting cursor position using `selection.addRange()` in a contenteditable element, it works correctly in Chrome and Firefox but fails in Safari. The selection "pops out" of intended marker element and moves to the next sibling's text node instead of staying within the marker.

3 cases
Tags: selection

Browser zoom causes caret and selection positioning issues

When the browser is zoomed (or content is scaled via CSS transforms), caret position and text selection in contenteditable elements can become inaccurate. Clicking at a certain position places the caret elsewhere, and selection highlights may not match the visual selection.

1 case
Tags: cursor

Text caret is invisible on position:relative elements

When editing content inside an element with `position:relative`, the text caret (cursor) is completely invisible. Text can be typed and appears in the editor, but there's no visual feedback of where the insertion point is located.

2 cases

Comments & Discussion

Have questions, suggestions, or want to share your experience? Join the discussion below.