docs/solutions/ui-bugs/2026-04-27-slate-react-void-keyboard-navigation-needs-post-native-sync-and-shift-model-ownership.md
/examples/images exposed DOM/model drift around selectable block voids. Plain
ArrowLeft/ArrowRight already moved through images correctly, but vertical native
movement and Shift-extended horizontal movement split the browser selection from
Slate selection.
ArrowDown from the end of the paragraph before an image put the DOM
selection in the image spacer at [1,0]@0, while Slate stayed at
[0,0]@113.useSelected() still
read the stale model selection.Shift+ArrowRight from [0,0]@113 let the DOM expand to the image wrapper,
but Slate stayed collapsed. A second press moved Slate to the image while the
DOM focus had already moved to the following wrapper.selectionchange after ArrowDown. Chrome fired importable
selectionchange work before the final native selection around the void spacer
was stable.Keep horizontal Shift movement model-owned:
if (Hotkeys.isExtendBackward(nativeEvent)) {
return {
axis: 'horizontal',
extend: true,
kind: 'move-selection',
reverse: true,
}
}
if (Hotkeys.isExtendForward(nativeEvent)) {
return { axis: 'horizontal', extend: true, kind: 'move-selection' }
}
Then have the caret engine execute it through editor.move({ edge: 'focus' })
instead of native DOM extension:
if (Hotkeys.isExtendForward(nativeEvent)) {
event.preventDefault()
editor.update(() => {
editor.move({ edge: 'focus', reverse: isRTL })
})
return caretMovementHandled()
}
For native vertical movement, keep the browser in charge of layout but import the settled DOM selection after the keydown default action:
if (
!readOnly &&
decision.intent === 'native-selection-move' &&
(event.key === 'ArrowUp' || event.key === 'ArrowDown')
) {
setTimeout(() => {
syncEditorSelectionFromDOM({ editor, inputController })
})
}
Guard the behavior in /examples/images with browser rows for:
Block-void images have a real zero-width text spacer, but the browser can land
on wrapper-shaped DOM endpoints during native movement. Slate's model must only
accept canonical text points. Horizontal Shift movement does not need browser
layout, so model-owned editor.move({ edge: 'focus' }) is the right owner.
Vertical movement does need native layout, so Slate must wait until the browser
settles the selection, then import the final DOM point.