Case ce-0546-samsung-text-prediction-link-adjacent-en · Scenario scenario-samsung-keyboard-text-prediction

insertCompositionText event and selection mismatch when typing next to a link with Samsung keyboard text prediction ON

OS: Android 10-14 Device: Mobile (Samsung Galaxy series) Any Browser: Chrome for Android 120+ Keyboard: Korean (IME) - Samsung Keyboard with Text Prediction ON Status: draft
samsung-keyboard text-prediction link anchor insertCompositionText getTargetRanges selection android chrome

Phenomenon

On Android Chrome with Samsung keyboard text prediction enabled, typing next to an anchor link in a contenteditable element causes the following issues:

  1. Both beforeinput and input events fire as insertCompositionText
  2. beforeinput’s getTargetRanges() is missing (undefined or returns empty array)
  3. The selection differs between beforeinput and input
  4. beforeinput’s selection includes the anchor link text with different start/end positions
  5. event.data contains all characters combined (not just the typed text)

Reproduction example

  1. Open Chrome browser on an Android device (Samsung Galaxy series, etc.).
  2. Enable text prediction feature in Samsung keyboard.
  3. Prepare HTML with an anchor link inside a contenteditable element (e.g., <a href="https://example.com">Link text</a>).
  4. Position the cursor right next to (after) the anchor link.
  5. Type text (e.g., “Hello”).
  6. Observe beforeinput and input events in the browser console or event log.

Observed behavior

When typing text next to an anchor link:

  1. beforeinput event:

    • inputType: 'insertCompositionText' (always)
    • isComposing: true
    • getTargetRanges() is missing (undefined or returns empty array)
    • window.getSelection() includes the anchor link text
    • Selection’s start and end positions are in an unexpected format
    • event.data contains combined text including both link text and typed text (e.g., “LinktextHello”)
  2. input event:

    • inputType: 'insertCompositionText' (always)
    • isComposing: true
    • window.getSelection() differs from beforeinput’s selection
    • The typed text is correctly inserted into the DOM
  3. Result:

    • Cannot use getTargetRanges() to determine exact insertion position
    • beforeinput’s selection information is inaccurate, causing event handlers to reference wrong positions
    • event.data contains combined text, making it difficult to accurately identify the typed text
    • Selection mismatch between beforeinput and input can cause state synchronization issues

Expected behavior

  • beforeinput’s getTargetRanges() should return the exact insertion position
  • beforeinput’s selection should accurately reflect the actual cursor position
  • event.data should contain only the typed text (not combined with link text)
  • Selections in beforeinput and input should match
  • Should fire with appropriate inputType instead of always insertCompositionText (for non-prediction typing)

Impact

This can lead to:

  • Inaccurate insertion position detection: Cannot determine exact insertion position without getTargetRanges()
  • Incorrect selection reference: beforeinput’s selection is inaccurate, causing event handlers to reference wrong positions
  • Incorrect text extraction: event.data contains combined text, making it difficult to accurately identify typed text
  • State synchronization issues: Selection mismatch between beforeinput and input causes application state to be inconsistent with DOM state
  • Failure to handle link-adjacent input: Difficulty in accurately processing input next to links

Browser Comparison

  • Android Chrome + Samsung Keyboard (Text Prediction ON): This issue occurs
  • Android Chrome + Samsung Keyboard (Text Prediction OFF): Works normally
  • Android Chrome + Gboard: Works normally
  • Android Chrome + SwiftKey: Works normally
  • iOS Safari: Different behavior pattern (text prediction works differently)

Notes and possible direction for workarounds

  • getTargetRanges() alternative: When getTargetRanges() is missing, use window.getSelection() but verify actual cursor position is not inside link:

    element.addEventListener('beforeinput', (e) => {
      if (e.inputType === 'insertCompositionText') {
        const targetRanges = e.getTargetRanges?.() || [];
        
        if (targetRanges.length === 0) {
          // Alternative when getTargetRanges() is missing
          const selection = window.getSelection();
          if (selection && selection.rangeCount > 0) {
            const range = selection.getRangeAt(0).cloneRange();
            
            // Verify actual cursor position is not inside link
            let container = range.startContainer;
            if (container.nodeType === Node.TEXT_NODE) {
              container = container.parentElement;
            }
            
            // Find position outside link element
            const link = container.closest('a');
            if (link) {
              // Adjust to position after link
              const afterLink = document.createRange();
              afterLink.setStartAfter(link);
              afterLink.collapse(true);
              // Use afterLink for processing
            } else {
              // Use range as-is
            }
          }
        } else {
          // Use targetRanges
        }
      }
    });
  • event.data sanitization: Extract only the actually typed text from combined text:

    element.addEventListener('beforeinput', (e) => {
      if (e.inputType === 'insertCompositionText' && e.data) {
        // Determine actual text to be inserted by checking DOM state
        const selection = window.getSelection();
        if (selection && selection.rangeCount > 0) {
          const range = selection.getRangeAt(0);
          const beforeText = getTextBeforeCursor(range);
          const afterText = getTextAfterCursor(range);
          
          // Extract actually typed text from event.data
          // (Implementation may require DOM state comparison)
        }
      }
    });
  • Selection normalization: Normalize selections in beforeinput and input to match:

    let beforeInputSelection = null;
    
    element.addEventListener('beforeinput', (e) => {
      const selection = window.getSelection();
      if (selection && selection.rangeCount > 0) {
        beforeInputSelection = normalizeSelection(selection.getRangeAt(0));
      }
    });
    
    element.addEventListener('input', (e) => {
      const selection = window.getSelection();
      if (selection && selection.rangeCount > 0) {
        const inputSelection = normalizeSelection(selection.getRangeAt(0));
        
        // Compare beforeInputSelection and inputSelection
        if (!selectionsMatch(beforeInputSelection, inputSelection)) {
          // Handle mismatch
          handleSelectionMismatch(beforeInputSelection, inputSelection);
        }
      }
      beforeInputSelection = null;
    });
    
    function normalizeSelection(range) {
      // Normalize to actual cursor position outside link
      let container = range.startContainer;
      if (container.nodeType === Node.TEXT_NODE) {
        container = container.parentElement;
      }
      
      const link = container.closest('a');
      if (link && range.startContainer === link) {
        // Adjust to position after link
        const normalized = document.createRange();
        normalized.setStartAfter(link);
        normalized.collapse(true);
        return normalized;
      }
      
      return range.cloneRange();
    }
  • DOM state comparison: Store DOM state at beforeinput and compare with input to identify actual changes:

    let domBefore = null;
    let selectionBefore = null;
    
    element.addEventListener('beforeinput', (e) => {
      if (e.inputType === 'insertCompositionText') {
        domBefore = element.innerHTML;
        const selection = window.getSelection();
        if (selection && selection.rangeCount > 0) {
          selectionBefore = selection.getRangeAt(0).cloneRange();
        }
      }
    });
    
    element.addEventListener('input', (e) => {
      if (e.inputType === 'insertCompositionText') {
        const domAfter = element.innerHTML;
        const actualChange = compareDOM(domBefore, domAfter, selectionBefore);
        // Process based on actual changes
        handleActualChange(actualChange);
      }
      domBefore = null;
      selectionBefore = null;
    });
  • Text prediction detection and handling: Detect when text prediction is active and apply special handling:

    let isTextPredictionActive = false;
    
    // Detect text prediction activation (via user agent or event pattern)
    function detectTextPrediction() {
      // Detect pattern where insertCompositionText always fires
      // Or check user agent
      const ua = navigator.userAgent;
      return /Samsung/i.test(ua) && /Android/i.test(ua);
    }
    
    element.addEventListener('beforeinput', (e) => {
      if (e.inputType === 'insertCompositionText' && detectTextPrediction()) {
        isTextPredictionActive = true;
        // Special handling for text prediction
        handleTextPredictionInput(e);
      }
    });

Code example

const editor = document.querySelector('div[contenteditable]');
let beforeInputState = null;

editor.addEventListener('beforeinput', (e) => {
  if (e.inputType === 'insertCompositionText') {
    // Store state at beforeinput
    const selection = window.getSelection();
    const range = selection && selection.rangeCount > 0 
      ? selection.getRangeAt(0).cloneRange() 
      : null;
    
    beforeInputState = {
      targetRanges: e.getTargetRanges?.() || [],
      selection: range,
      data: e.data,
      domBefore: editor.innerHTML,
      timestamp: Date.now()
    };
    
    // Alternative handling when getTargetRanges() is missing
    if (beforeInputState.targetRanges.length === 0 && range) {
      // Check and normalize link-adjacent position
      const normalizedRange = normalizeRangeForLinkAdjacent(range);
      beforeInputState.normalizedRange = normalizedRange;
    }
    
    // Sanitize event.data (extract actual typed text from combined text)
    if (e.data) {
      const actualInputText = extractActualInputText(e.data, range);
      beforeInputState.actualInputText = actualInputText;
    }
  }
});

editor.addEventListener('input', (e) => {
  if (e.inputType === 'insertCompositionText' && beforeInputState) {
    const selection = window.getSelection();
    const range = selection && selection.rangeCount > 0 
      ? selection.getRangeAt(0).cloneRange() 
      : null;
    
    // Compare selections between beforeinput and input
    if (range && beforeInputState.selection) {
      const selectionsMatch = compareSelections(
        beforeInputState.selection, 
        range
      );
      
      if (!selectionsMatch) {
        console.warn('Selection mismatch between beforeinput and input');
        // Handle mismatch
      }
    }
    
    // Verify actual DOM changes
    const domAfter = editor.innerHTML;
    const actualChange = compareDOM(
      beforeInputState.domBefore, 
      domAfter, 
      beforeInputState.normalizedRange || beforeInputState.selection
    );
    
    // Process based on actual changes
    handleCompositionInput(actualChange, beforeInputState);
    
    beforeInputState = null;
  }
});

function normalizeRangeForLinkAdjacent(range) {
  let container = range.startContainer;
  if (container.nodeType === Node.TEXT_NODE) {
    container = container.parentElement;
  }
  
  const link = container.closest('a');
  if (link) {
    // Adjust to position after link
    const normalized = document.createRange();
    try {
      normalized.setStartAfter(link);
      normalized.collapse(true);
      return normalized;
    } catch (e) {
      // No text node may exist after link
      return range;
    }
  }
  
  return range;
}

function extractActualInputText(combinedText, range) {
  // Extract only the actually typed text from combined text
  // This may require DOM state comparison
  // Simple example: remove link text (actual implementation needs more sophisticated logic)
  const link = range?.startContainer?.parentElement?.closest('a');
  if (link && combinedText.startsWith(link.textContent)) {
    return combinedText.slice(link.textContent.length);
  }
  return combinedText;
}

function compareSelections(range1, range2) {
  if (!range1 || !range2) return false;
  
  const pos1 = {
    container: range1.startContainer,
    offset: range1.startOffset
  };
  const pos2 = {
    container: range2.startContainer,
    offset: range2.startOffset
  };
  
  return pos1.container === pos2.container && pos1.offset === pos2.offset;
}

function compareDOM(domBefore, domAfter, range) {
  // Analyze DOM changes
  // Actual implementation may be more complex
  return {
    inserted: extractInsertedText(domBefore, domAfter, range),
    deleted: extractDeletedText(domBefore, domAfter, range)
  };
}

function handleCompositionInput(actualChange, beforeInputState) {
  // Process based on actual changes
  console.log('Actual change:', actualChange);
  console.log('Input text:', beforeInputState.actualInputText);
  // Update editor state, manage undo/redo stack, etc.
}

Web Standards and Documentation

Known Issues

  1. getTargetRanges() Empty Array Issue

  2. Samsung Keyboard and contenteditable Compatibility Issues

  3. Chromium Code Reviews - Samsung Keyboard Related

  4. insertCompositionText Handling Issues on Android

  5. Link Selection Issues in contenteditable

React and Framework Issues

Solutions and Recommendations

  1. Guide Users to Disable Text Prediction

    • Instruct users to disable text prediction in Samsung keyboard settings
    • Settings > General Management > Samsung Keyboard Settings > Predictive text OFF
  2. Recommend Alternative Keyboards

    • Suggest using alternative keyboards like Gboard or Microsoft SwiftKey
  3. Feature Detection and Fallback Implementation

    • Detect getTargetRanges() availability before use
    • Use window.getSelection() when empty array is returned
    • Determine actual changes through DOM state comparison
Before
Cursor positioned after a link
Step 1: Type text next to link
Link text Hello
Attempting to type 'Hello' next to link
vs
✅ Expected
Link text Hello
Expected: beforeinput and input selections match, data contains only typed text

Playground for this case

Use the reported environment as a reference and record what happens in your environment while interacting with the editable area.

Reported environment
OS: Android 10-14
Device: Mobile (Samsung Galaxy series) Any
Browser: Chrome for Android 120+
Keyboard: Korean (IME) - Samsung Keyboard with Text Prediction ON
Your environment
Sample HTML:
Event log
Use this log together with the case description when filing or updating an issue.
0 events
Interact with the editable area to see events here.

Comments & Discussion

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