docs/solutions/logic-errors/2026-05-11-slate-react-scroll-range-measurement-must-restore-dom-methods.md
Repeated typing after manual scroll-away looked like a scroll helper bug, but
the durable failure was broader: Slate React could preserve stale
model-selection ownership after DOM repair/programmatic export, then skip the
live DOM caret path that native insertText needed.
The scroll helper also depended on scroll-into-view-if-needed by temporarily
overriding getBoundingClientRect, which made repeated caret visibility proof
fragile.
getBoundingClientRect mutation was correct, but too local.slate-react and site/out.
The Playwright row can keep serving stale package output.Use a real user-path browser row:
await scrollBlockIntoView(editor, lastBlockIndex)
await clickTextBlock(editor, lastBlockIndex)
await scrollContainersAwayFromCaret(editor)
await page.keyboard.insertText(' first-scroll')
await expectCaretVisibleInScrollableParent(editor)
await scrollContainersAwayFromCaret(editor)
await page.keyboard.insertText(' second-scroll')
await expectCaretVisibleInScrollableParent(editor)
Then make Slate React ownership reasoned instead of a bare boolean. Native
insertText can ignore stale repair/programmatic model preference, while
explicit model-owned guards still hold:
export const isEditableModelSelectionPreferredForInput = ({
inputController,
inputType,
}: {
inputController: EditableInputController
inputType: string
}) => {
if (!isEditableModelSelectionPreferred(inputController)) return false
if (inputType !== 'insertText') return true
const preference = inputController.state.modelSelectionPreference
if (!preference?.preferModelSelection) return false
return (
inputController.state.isComposing ||
preference.reason === 'browser-handle' ||
preference.reason === 'composition' ||
preference.reason === 'internal-control' ||
preference.reason === 'model-command' ||
preference.reason === 'shell-backed'
)
}
Repair paths should reveal the current DOM selection or the materialized post-update collapsed range:
const domRange = domNode.ownerDocument.createRange()
domRange.setStart(domNode, domOffset)
domRange.setEnd(domNode, domOffset)
domSelection.setBaseAndExtent(domNode, domOffset, domNode, domOffset)
scrollSelectionIntoView(editor, domRange)
Finally, replace the dependency-backed method mutation with a Slate-owned rect walker:
scrollRectIntoViewIfNeeded({
rect: toScrollRect(targetRect),
startElement: leafEl,
})
That let slate-react remove scroll-into-view-if-needed and
compute-scroll-into-view from its dependency graph.
Native text input is driven by the browser's current editable selection unless
a current model-owned reason explicitly owns the event. Repair-induced and
programmatic-export origins are not enough to suppress a later native
insertText DOM import.
Caret visibility also belongs after DOM selection export or post-input repair. At that point Slate has a real collapsed DOM range to reveal. Walking scrollable ancestors from inner to outer, then the viewport, keeps the scroll delta local and avoids mutating DOM measurement methods.
slate-react and the static example before Playwright proof when the
example is served from site/out.