docs/solutions/logic-errors/2026-04-22-slate-react-internal-controls-must-be-native-owned.md
Interactive controls rendered inside a Slate editor, such as inputs, buttons,
selects, textareas, and nested editors, are app/native-owned targets. If the
root Editable treats their keyboard, beforeinput, input, mouse, or selection
events as editor-owned, it can steal focus, mutate Slate selection, or repair
DOM from the wrong target.
<input> inside an editable void inserted into the outer
editor or stopped after one character.selectionchange from an embedded input could rewrite outer editor
selection.root.focus() after editing an input inside an editable void could send
the next physical keyboard text into a nested editor, even though the outer
model selection was still correct.selectionchange. WebKit still routed later input
back through the root stack.navigator.clipboard after every shortcut. WebKit and mobile can
deny those APIs even when the editor behavior is correct.Add a shared target policy for interactive internal controls:
export const isInteractiveInternalTarget = (
editor: ReactEditor,
target: EventTarget | null
) => {
const element = isDOMElement(target)
? target
: isDOMText(target)
? target.parentElement
: null
const control = element?.closest(
'input, textarea, select, button, [data-slate-editor="true"]'
)
return (
control instanceof HTMLElement &&
control !== ReactEditor.toDOMNode(editor, editor) &&
ReactEditor.hasDOMNode(editor, control) &&
!ReactEditor.hasEditableTarget(editor, control)
)
}
Use that policy at every root-owned event boundary:
For keyboard and input paths, stop propagation when the target is internal:
if (isInteractiveInternalTarget(editor, event.target)) {
event.stopPropagation()
return
}
For native beforeinput / input, use stopImmediatePropagation() so root
native listeners do not continue participating in an app-owned control event.
Native controls and nested editors need one extra split. Inputs, buttons, selects, and textareas should keep focus while they are active, but their blur handoff should restore the outer document selection when the next focus is not caused by a pointer gesture. Nested editors stay native-owned.
export const isNativeInternalControlTarget = (
editor: ReactEditor,
target: EventTarget | null
) => {
const element = isDOMElement(target)
? target
: isDOMText(target)
? target.parentElement
: null
const control = element?.closest(
'input, textarea, select, button, [role="button"]'
)
return (
control instanceof HTMLElement &&
control !== ReactEditor.toDOMNode(editor, editor) &&
ReactEditor.hasDOMNode(editor, control) &&
!ReactEditor.hasEditableTarget(editor, control)
)
}
On native-control blur, export the preserved model selection only when there was no pointer intent:
if (
isNativeInternalControlTarget(editor, event.target) &&
!nativePointerFocusRef.current
) {
syncDOMSelectionToEditor()
}
Cut needs the same model-owned repair discipline as other model-owned edits:
Transforms.select(editor, { anchor: collapsePoint, focus: collapsePoint })
preferModelSelectionForInputRef.current = true
ReactEditor.focus(editor)
domRepairQueue.repairCaretAfterModelOperation()
The editor root should only own events whose target is part of the editable Slate surface. Internal controls may live in the editor DOM, but their input semantics belong to the browser or app component. Separating those targets prevents Slate from interpreting native control events as editor edits.
The cut repair works because the collapsed post-cut selection is a Slate model
decision. Marking it model-owned and repairing the DOM caret prevents a delayed
browser selectionchange from clearing the restored selection.
The native-control blur export fixes the editable-void focus handoff because
browser document selection can drift into the void shell while the input owns
focus. If raw root.focus() runs after that drift, browsers may focus a nested
contenteditable inside the void. Exporting the preserved outer selection on
blur gives the following root focus a real outer caret without stealing focus
while the input is still typing.
root.focus(), then proves follow-up text lands in the outer editor.cut event.