docs/solutions/ui-bugs/2026-05-20-slate-react-state-field-setters-must-preserve-external-focus.md
The Document State example exposed two browser-facing failures: the styled editor root was not actually clickable, and title-field keystrokes stole focus back to the editor. The state field API looked clean, but the browser behavior was not honest.
State-only history replay has the same ownership hazard. Undoing a title-field history batch updates only editor state, but a React render can still export the stale model selection back to the DOM and focus the contenteditable.
Even after the ownership fix, stale focus retries must fail closed. A focus repair request can be superseded by an external title input before the DOM node map settles; exhausting retries must leave focus unchanged, not crash the app.
The title input must also own its keyboard history shortcut. A controlled input backed by a Slate state field should not let the browser's native input undo create a second ordinary state patch. It should run Slate history for every available history batch and keep focus in the title input, even when repeated undo crosses from title state into editor content.
editor.selection.select(...)-based Playwright coverage stayed green while
real mouse clicks failed.<Editable>.document.activeElement became
the editor root and the DOM selection moved back into editor text.DOMEditor.focus could keep retrying while the node
map was dirty and eventually throw into the Next error overlay.historic, so the example was bypassing Slate state-field history.<Editable> made the
contenteditable easier to find, but the wrapper intercepted clicks because
Slate's default editable style includes z-index: -1.metadata.selection.dom = 'preserve' to state updates was not enough
until the React DOM-selection bridge actually checked that policy before
exporting the model selection back to the DOM.Keep wrappers around styled editables non-intercepting, and override the
editable root zIndex when the example gives it its own border/background:
<div className={editorSurfaceCss} id="document-state-editor-surface">
<Editable
className={editorCss}
id="document-state"
spellCheck={spellcheckEnabled}
style={{ zIndex: 0 }}
/>
</div>
Make useSetStateField safe for external controls by default:
editor.update(
(tx) => {
tx.setField(field, value)
},
{
metadata: {
selection: { dom: 'preserve', focus: false, scroll: false },
},
}
)
Then make the Slate React selection bridge honor selection.dom: 'preserve'
before it mutates the browser selection.
State-only history replay should use the same selection policy and should not restore the saved editor selection:
editor.update(fn, {
metadata: {
history: { mode: 'skip' },
selection: { dom: 'preserve', focus: false, scroll: false },
},
tag: 'historic',
})
Only operation-backed history batches should restore selectionBefore.
Finally, make DOMEditor.focus fail closed when retry budget is exhausted:
if (options.retries <= 0) {
return
}
For the example's title input, intercept undo/redo shortcuts before the browser uses native input history. If a Slate history batch exists, run it with DOM selection preservation:
event.preventDefault()
event.stopPropagation()
if (hasHistoryBatch) {
editor.update(
(tx) => tx.history.undo(),
{
metadata: {
selection: { dom: 'preserve', focus: false, scroll: false },
},
}
)
}
restoreTitleFocus()
State fields are often edited from controls outside the contenteditable surface.
Those writes must update Slate state and history without treating the stale
editor selection as the browser's current selection. Preserving the DOM
selection keeps focus in the input, while focus: false and scroll: false
avoid the related focus and scroll side effects.
State-only history replay is still a state-field write from the browser's point of view. Replaying the patch should change metadata, comments, settings, title, or annotations without making the editor reclaim focus.
Focus retry exhaustion is also not a model invariant. It is a best-effort DOM repair path during a transient render gap. Returning preserves the live external focus owner and lets later selection sync do normal work.
Intercepting every title-field history shortcut makes the example honest: the title field is still part of the document state/history model, and repeated undo/redo walks the same history stack as the editor. The active DOM owner still remains the title input because the command carries selection preservation metadata.
The example clickability fix is separate: a styled editable root with border, background, and padding needs to sit in front of its parent. Otherwise the page can look editable while click hit-testing lands on an ancestor.
page.keyboard.insertText, not only model selection helpers.<Editable> only for test scoping, the wrapper should not
create a click target over the editor.selection.dom: 'preserve' needs a direct unit
assertion and a browser row proving the DOM is actually preserved.tx.history.undo() still exports stale
editor selection.tags:historic for keyboard title undo/redo. Focus staying put is not
enough if the example bypasses Slate history.