docs/solutions/ui-bugs/2026-05-21-slate-v2-multi-root-chrome-clicks-must-activate-root-before-focus.md
The multi-root document example made only the active root editable, and the runtime accepted rootless selection points imported through a root-bound view. Direct editable clicks passed in the test harness, but real clicks on inactive root text could focus the header without durable header-root selection.
#multi-root-header became the active element, but window.getSelection()
stayed outside the header after a text-surface click.hello into rendered olleh.#multi-root-header
directly without asserting native selection ownership.readOnly. That flips the DOM to
contenteditable=false, so the browser cannot perform a native caret
placement on the first click.document.activeElement can be correct while the
native selection still belongs to no editable root.insertText('olleh'). That proves nothing; the
test must send ordered h, e, l, l, o key presses and fail if the
editor reverses them.Add a browser row that clicks the inactive header text surface, asserts the native selection is inside the header, then proves typing lands in the header only:
const box = await header.boundingBox()
await page.mouse.click(box.x + 230, box.y + 24)
await expect(header).toBeFocused()
await expect
.poll(() =>
page.evaluate(() => {
const headerElement = document.getElementById('multi-root-header')
const selection = window.getSelection()
return Boolean(
headerElement &&
selection?.anchorNode &&
headerElement.contains(selection.anchorNode)
)
})
)
.toBe(true)
await page.keyboard.insertText('Surface caret ')
await expect(header).toContainText('Surface caret ')
await expect(main).not.toContainText('Surface caret ')
Then keep all root editables natively editable. The active-root state can still
drive labels, autoFocus, and chrome-click handoff, but it must not turn the
inactive editor text surface into contenteditable=false:
<Editable
onMouseDown={activateRoot}
readOnly={false}
/>
Chrome clicks still need a handoff because labels and badges are not caret targets. The section capture path activates the root and focuses the editable at the end only when the click target is outside the editable.
Also stamp rootless selection points when a root-bound view imports them. A
selection imported through the header view must be stored as a header selection,
otherwise header insert_text operations do not transform it and repeated keys
reuse the old offset:
const normalizeSelectionRoot = (selection: Selection, root: string) => {
if (!selection) return selection
const normalizePointRoot = (point: Point) => {
const { root: _root, ...pointWithoutRoot } = point
return root === 'main' ? pointWithoutRoot : { ...pointWithoutRoot, root }
}
return {
anchor: normalizePointRoot(selection.anchor),
focus: normalizePointRoot(selection.focus),
}
}
Lock the browser example with ordered key presses, not direct text insertion:
await page.mouse.click(box.x + 230, box.y + 24)
await expect(header).toBeFocused()
for (const key of ['h', 'e', 'l', 'l', 'o']) {
await header.press(key)
}
await expect(header).toContainText('hello')
await expect(header).not.toContainText('olleh')
Content text needs native browser caret placement. If inactive roots render read-only, the first click cannot create a DOM selection in that root, even if React later focuses the element. Keeping each root contenteditable lets normal text clicks set the caret, while root-local selection ownership keeps edits scoped to the clicked root.
Stamping the root onto imported selection points makes PointApi.transform
compare the selection against later header-root operations correctly. After the
first h, the header selection advances to offset 1; the next key inserts after
h instead of back at offset 0.
document.activeElement, native selection
ownership, and follow-up typing ownership.