docs/solutions/ui-bugs/2026-04-21-slate-react-history-hotkeys-must-repair-dom-after-model-undo.md
Keyboard undo and redo in slate-react can mutate the Slate model while leaving browser-owned text DOM stale. The same ownership bug appears when browser history events originate inside native controls embedded in editable voids. This breaks the real editing experience even when model-level history and handle-only tests look green.
/examples/richtext accepted typed text, but Cmd+Z left the text visible.undo() path passed because it already forced a render after model history changes.Editor.string(editor, []) no longer contained the inserted text after Cmd+Z, while editor.textContent still did./examples/editable-voids inserted a new editable void, typed into its native input, then Cmd+Z cleared only the native input while the inserted void block stayed in the document./examples/scroll-into-view could keep the first repeated typing visible, but after undo a delayed native selection update moved the model and DOM caret to the start of the edited block.slate-react; the site can consume stale package output.historyUndo and historyRedo still represent the editor transaction that created or changed the owning Slate node.selectionchange.Route keyboard and native history events through Slate-owned history, then force a view repair only when the history batch is not already handled by direct DOM text sync:
if (Hotkeys.isUndo(nativeEvent)) {
event.preventDefault()
if (
applyModelOwnedHistoryIntent({
direction: 'undo',
editor,
}) &&
shouldForceRenderAfterModelOwnedHistory(editor)
) {
forceRender()
}
return
}
Use the same policy for redo and native historyUndo / historyRedo events.
The render decision should be based on the last committed operations:
export const shouldForceRenderAfterModelOwnedHistory = (editor: Editor) => {
const commit = Editor.getLastCommit(editor)
return (
!commit ||
commit.operations.some(
(operation) =>
operation.type !== 'insert_text' &&
operation.type !== 'remove_text' &&
operation.type !== 'set_selection'
)
)
}
Classify native browser history before the internal-control early return:
if (event.inputType === 'historyUndo' || event.inputType === 'historyRedo') {
return 'history'
}
if (internalTarget) {
return 'internal-control'
}
Then apply model-owned native history before stopping internal-control
beforeinput propagation:
if (applyModelOwnedNativeHistoryEvent({ editor, event })) {
event.preventDefault()
event.stopImmediatePropagation()
if (shouldForceRenderAfterModelOwnedHistory(editor)) {
repair.requestEditableRepair({ forceRender: true, kind: 'force-render' })
}
handledDOMBeforeInputRef.current = true
return
}
if (decision.internalTarget) {
event.stopImmediatePropagation()
return
}
Mounted editor text and block renderers can opt out of rerendering after synced text-only commits, but generic selector hooks must still report model text updates to app code.
For model-owned history hotkeys, request the same caret repair lane as other model commands instead of only forcing a render:
const getModelOwnedHistoryRepair = (
editor: ReactEditor
): EditableRepairRequest => ({
focus: true,
forceRender: shouldForceRenderAfterModelOwnedHistory(editor),
kind: 'repair-caret',
selectionSourceTransition: {
preferModelSelection: true,
reason: 'model-command',
selectionSource: 'model-owned',
},
})
While that history repair is pending, delayed native selectionchange events
must not cancel the queued model-owned repair or import stale DOM selection over
the undo target.
The native/direct DOM text lane can leave React with no urgent render to perform
after history changes. Undo correctly mutates the Slate model, but the DOM can
still contain browser-inserted text until slate-react explicitly reconciles
the view.
Non-text history batches still need that explicit repair. Text-only history batches are different: direct DOM text sync already owns the visible text. A broad React render there can race the browser-owned DOM and produce duplicate text or node-removal crashes during mobile replay.
Internal controls need a smaller exception. Their ordinary native input events must not leak into the outer editor, but browser history events are not ordinary native typing. If a focused input sits inside a void block that Slate just inserted, Cmd+Z should undo the Slate insertion before the browser clears the input's private value.
The scroll-into-view caret bug was the same ownership rule in a more subtle
shape. The history batch restored the right model selection, but Chrome still
held a stale DOM caret at offset 0. If the history path only syncs once, a
late native selectionchange can overwrite the repaired model selection.
Keeping history model-owned until the caret repair completes prevents the stale
DOM point from winning.
Editor.string(editor, []) and visible editor DOM after direct/native DOM input.