docs/solutions/ui-bugs/2026-04-21-slate-react-model-owned-input-must-ignore-stale-dom-target-ranges.md
When slate-react owns a text insertion or keyboard navigation event, stale
browser target ranges and delayed selectionchange events can overwrite the
correct Slate selection. That makes later keystrokes land in old DOM positions
even though the first model write was correct.
/examples/richtext showed Undo Me scattered across unrelated text after
page.keyboard.type('Undo Me') under a Mac Chrome user agent.page.keyboard.insertText('Undo Me') passed because it inserted the whole
string in one event, hiding the stale-caret follow-up bug./examples/custom-placeholder could type a, then b, then Enter and
produce model blocks ["ab", "b"] instead of ["ab", ""].beforeinput target-range repair run on every plain
insertText event stole the caret back from stale DOM selection.selectionChangeOrigin was still
repair-induced or programmatic-export was too fragile. Delayed native
typing can clear that transient flag before the next structural keydown.Treat plain text input as model-owned after Slate handles text insertion or keyboard navigation. While that mode is active:
insertTextselectionchange writes back into Slatedata-slate-dom-sync="true" capabilityAlso keep the DOM bridge maps current for text nodes, not just element nodes, so DOM-to-Slate resolution has a valid path when fallback repair is needed.
The keydown guard belongs on selection provenance, not only transient event origin:
const hasAuthoritativeModelSelection = ({ inputController }) =>
inputController.state.selectionSource === 'model-owned' &&
(inputController.preferModelSelectionForInputRef.current ||
hasProgrammaticSelectionOrigin(inputController.state.selectionChangeOrigin))
The browser can report a target range from the old DOM caret after Slate has already moved the model caret. If Slate then trusts that target range, the next character is inserted at the old DOM position. Once Slate owns the input lane, the model selection is the source of truth until the user explicitly selects elsewhere.
Structural keys such as Enter used to force a DOM selection import even when
text repair had already made Slate's model selection authoritative. That
reopened a stale collapsed DOM caret at offset 1, so splitting ab moved the
last character into the second block. Reading the controller's selection source
keeps real native selection import working while blocking stale repair fallout.
The capability gate keeps the fast DOM-owned plain text lane strict: custom leaf/text/segment rendering, projections, placeholders, zero-width nodes, and composition paths fall back to React/model-owned updates.
keyboard.insertText(...) and keyboard.type(...); the first can
hide per-character caret drift.slate-react before static Playwright rows, because examples consume
built package output.type('ab') rows. The timeout exposes selection origin cleanup.