docs/solutions/ui-bugs/2026-04-27-slate-react-mouseup-must-import-final-dom-selection-for-overlays.md
/examples/hovering-toolbar could fail to show the toolbar after selecting
text with a real mouse drag. Programmatic selection stayed green, so the
coverage missed the user path.
window.getSelection()?.toString() returned selected text after the drag.__slateBrowserHandle.getSelection() stayed collapsed at the drag start in
headless Chromium.opacity: 0 and top/left: -10000px.page.pause() and only used selectText().editor.getSelection() inside the toolbar effect. That still relies
on some other render to happen.useSlate() to useSlateSelection(). That
is the correct React subscription, but it exposes the deeper issue: Slate had
not imported the final expanded mouse selection into the model.3101
server can be stale; pin stress proof to the known current server when
investigating a live dev fix.Make selection-owned UI subscribe to the explicit selection selector:
const editor = useSlateStatic<CustomEditor>()
const selection = useSlateSelection()
useEffect(() => {
if (!selection || Range.isCollapsed(selection)) {
el.removeAttribute('style')
return
}
}, [editor, inFocus, selection])
Then import the final DOM selection on editor mouseup for non-internal
targets:
const handleMouseUp = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (isInteractiveInternalTarget(editor, event.target)) {
attributes.onMouseUp?.(event)
return
}
const handled =
(attributes.onMouseUp?.(event) as boolean | void) ??
event.defaultPrevented
if (!handled) {
syncEditorSelectionFromDOM({ editor, inputController })
}
},
[attributes.onMouseUp, editor, inputController]
)
Finally, add a real mouse-drag browser regression that asserts both DOM and model selection:
await selectTextWithMouse(page.locator('span[data-slate-string="true"]').first())
await expect
.poll(() => page.evaluate(() => window.getSelection()?.toString() ?? ''))
.not.toBe('')
await expect.poll(() => hasExpandedModelSelection(page)).toBe(true)
await expect(page.getByTestId('menu')).toHaveCSS('opacity', '1')
Chromium can leave the throttled selectionchange pipeline with a collapsed
intermediate selection during drag. The DOM selection becomes expanded by the
time mouseup fires, but no final import is guaranteed.
mouseup is the last native event in the drag-selection user action, so it is
the right point to reconcile current DOM selection into Slate model selection.
The internal-target guard keeps buttons, inputs, and other app-owned controls
from being pulled into editor selection import.
The toolbar also needs selector discipline: overlay visibility is selection
state, so React should subscribe to useSlateSelection() and keep the editor
object from useSlateStatic().
locator.selectText() or model-handle selection.useSlateSelector or useSlateSelection.
useSlate() is too broad for React performance and too vague for ownership.PLAYWRIGHT_BASE_URL=http://localhost:3100 bun test:stress when
the dev server is the truth.