docs/solutions/ui-bugs/2026-05-23-slate-react-mounted-root-editor-focus-for-root-chrome-history.md
Package-owned multi-root hooks can still fail if they focus a freshly created
root view instead of the mounted <Editable root> view. The canonical example
looked clean, but browser proof showed chrome and toolbar history clicks did not
restore visible focus.
useSlateRootChrome('header') selected the header model root, but
document.activeElement stayed on #multi-root-main.useSlateHistory().undo() removed the header text batch, but focus landed on
document.body or the clicked toolbar button.editor.api.dom.focus() no-opped when IS_FOCUSED was still true even though
the real DOM active element had moved.requestAnimationFrame from the example into the hook. Timing helped,
but it still focused an unmounted view editor.editor.api.dom.focus({ retries: 1 }) or { retries: 2 } from
root chrome/history. Small retry counts lose the race against dirty node maps.Store mounted root editors in the Slate React runtime context and let hooks use that mounted editor for DOM focus:
const focusEditor = getMountedViewEditor(root) ?? editor
focusSlateEditable(focusEditor)
Keep history mutations on the root view editor, but restore focus through the mounted root editor:
editor.update((tx) => {
tx.history.undo()
})
scheduleSlateReactFocus(() => {
const focusEditor = getMountedViewEditor(root) ?? editor
focusSlateEditable(focusEditor)
})
Make DOMEditor.focus treat the DOM active element as decisive:
if (IS_FOCUSED.get(editor) && root.activeElement === el) {
return
}
For blank editable-root clicks, only intercept the click when that editable is not already focused. Focus the concrete element first, then force root selection restore so the browser's click-coordinate caret does not leak into the model:
if (target instanceof HTMLElement && isEditableRootTarget(target)) {
target.focus({ preventScroll: true })
}
focusRoot({ forceSelection: true })
useSlateRootEditor(root) is useful for model reads and writes, but it can
create a view editor that is not associated with a DOM node. DOM focus must go
through the editor instance registered by the mounted <Slate root> created
inside <Editable root>.
Toolbar buttons add another trap: by the time onClick runs, browser focus may
have already left the editor. The history hook must remember the last real
selection root and refocus the mounted root after the click settles.
Editable padding clicks are different from outer root chrome clicks. Text clicks should stay native so the browser can place the caret. Clicks on the editable root itself should first resolve a range from the event coordinates, because right/bottom editor padding can still mean "put the caret at the visible body end." Only fall back to the root restore policy when no event range can be resolved.
There is a third target class between those two: a click on a Slate element line
box, for example the blank part of a paragraph's first line. That is not root
chrome and not a true blank-root click. Treat [data-slate-node="element"] as a
native editable descendant, just like [data-slate-string] and
[data-slate-node="text"], so line-end clicks stay browser-owned:
const NATIVE_EDITABLE_TARGET =
'[data-slate-string], [data-slate-zero-width], [data-slate-leaf], [data-slate-node="text"], [data-slate-node="element"]'
Native text clicks still need a narrow recovery path. After scrolling a sibling
root into view, the browser can deliver the click to the footer Slate string but
leave document.activeElement on the previous body editable. Root chrome should
remember that a native editable descendant was clicked, let the browser try
first, then on mouseup repair only if the clicked editable root still is not
focused:
if (isNativeEditableTarget(event.currentTarget, target)) {
const editableRoot = findEditableRootTarget(event.currentTarget, target)
if (editableRoot && !isAlreadyFocusedEditableRoot(editableRoot)) {
pendingNativeEditableClickRef.current = true
}
return
}
// mouseup
if (pendingNativeEditableClickRef.current) {
pendingNativeEditableClickRef.current = false
recoverNativeEditableClick(event)
return
}
The recovery should derive the Slate range from the real click coordinates and focus the mounted root editor. That keeps the fallback equivalent to the native text-click contract instead of reusing blank-root restore behavior:
const focusEditor = getMountedViewEditor(root) ?? editor
const range = focusEditor.api.dom.resolveEventRange(event.nativeEvent)
if (range) {
focusEditor.update((tx) => {
tx.selection.set(range)
})
}
focusSlateEditable(focusEditor)
The same event-range-first policy should be used for direct editable-root targets:
const range = focusEditor.api.dom.resolveEventRange(event.nativeEvent)
if (range) {
focusEditor.update((tx) => {
tx.selection.set(range)
})
focusSlateEditable(focusEditor)
return
}
restoreRoot()
The regression should click header text, type, click the body paragraph line box, then assert DOM focus, DOM selection root, exact body caret offset, and follow-up typing:
await page.mouse.click(headerBox.x + 230, headerBox.y + 24)
await page.keyboard.type('Header native ')
await page.mouse.click(mainBox.x + mainBox.width - 16, mainBox.y + 24)
await expect
.poll(() => readNativeSelection(page, 'multi-root-main'))
.toMatchObject({
activeElementId: 'multi-root-main',
insideRoot: true,
text: 'The body root carries the document content.',
})
await expect
.poll(async () => {
const selection = await readNativeSelection(page, 'multi-root-main')
return selection.anchorOffset
})
.toBe('The body root carries the document content.'.length)
document.activeElement.id, not just text
changes.DOMEditor.focus should never early-return from internal focus state unless
the DOM active element already matches the editor element.document.activeElement.id, the
native selection root, and follow-up typing in the clicked root on the first
click.