Phenomenon
Safari’s WebKit engine has a flaw in how it resolves “Logical to Physical” selection mapping within table structures. When a user initiates and commits an IME composition (common in CJK languages) inside a <td> cell that contains no other text or nodes, the final insertion point is incorrectly calculated as being outside the cell.
Reproduction Steps
- Render a structure with an empty table cell:
<table><tr><td></td></tr></table>. - Set
contenteditable="true"on the cell or its container. - Use a CJK IME (e.g., Japanese Kana, Chinese Pinyin).
- Click inside the cell and type enough to start a composition (e.g., type “a”).
- Confirm the selection by pressing Enter.
Observed Behavior
compositionstartandcompositionupdate: These fire correctly inside the<td>.compositionend: Fires normally.- DOM Mutation: After composition ends, the character is removed from the temporary composition state but is re-inserted as static text outside the
<td>. - Caret Position: The caret often jumps to the very beginning of the document or the start of the table block.
Expected Behavior
The browser should ensure that the Selection range remaines anchored to the parent <td> element throughout the entire composition lifecycle.
Impact
- Data Integrity: Text appears in the wrong logical order or outside the intended container.
- Visual Corruption: The table remains empty while the text floats above or below it, breaking the document’s visual layout.
- Undo History: Since the insertion happened in a “leaked” position, the undo operation might fail to remove the text correctly.
Browser Comparison
- Safari 17/18: Exhibit the bug.
- Chrome/Firefox: Correct behavior; the range remains scoped to the
<td>.
References & Solutions
Mitigation: Placeholder Node
A known workaround used in ProseMirror is to ensure the cell is never “truly empty” during selection or to manually reset the selection inside the cell upon compositionend.
// Force an invisible placeholder or check range on compositionend
element.addEventListener('compositionend', (e) => {
const sel = window.getSelection();
const range = sel.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) {
console.warn('Safari Selection Leak Detected! Fixing...');
// Manually insert data at the correct location
insertAtLastKnownValidPath(e.data);
}
});