docs/solutions/logic-errors/2026-05-13-slate-history-leading-selection-imports-are-batch-preconditions.md
slate-history captured batch.selectionBefore from the commit-wide
selectionBefore. When a commit first imported the DOM caret with
set_selection and then inserted text, undo restored the stale model selection
from before the caret import.
set_selection(start -> middle) followed by
insert_text undid the text but restored [0,0]@0 instead of [0,0]@3.Build each history batch from the first saveable operation. Selection-only operations before that point are preconditions for the edit, so apply them to the batch start selection and trim them from stored operations:
const prepareHistoryBatch = (
selectionBefore: Range | null,
operations: readonly Operation[]
) => {
const firstSaveableIndex = operations.findIndex(shouldSave)
if (firstSaveableIndex === -1) {
return null
}
let batchSelectionBefore = cloneRange(selectionBefore)
for (let index = 0; index < firstSaveableIndex; index++) {
const operation = operations[index]!
if (operation.type === 'set_selection') {
batchSelectionBefore = applySelectionPatch(
batchSelectionBefore,
operation.newProperties
)
}
}
return {
operations: [...operations.slice(firstSaveableIndex)],
selectionBefore: batchSelectionBefore,
}
}
For multi-root documents, the precondition range must also preserve root
identity. A leading set_selection operation may carry the root on the
operation rather than on each point, so applySelectionPatch needs the
operation root when it clones points:
const clonePoint = (point: Range['anchor'], root?: string) => {
const nextRoot = point.root ?? root
return {
offset: point.offset,
path: [...point.path],
...(nextRoot && nextRoot !== 'main' ? { root: nextRoot } : {}),
}
}
batchSelectionBefore = applySelectionPatch(
batchSelectionBefore,
operation.newProperties,
operation.root
)
Undo and redo should set batch.selectionBefore unconditionally, including
null, because a valid history precondition can be "no selection":
tx.selection.set(batch.selectionBefore)
tx.operations.replay(batch.operations)
When the saved range is rooted, restore it only if it belongs to the invoking view root. A single browser selection cannot both keep the body caret active and restore an off-focus header caret. Cross-root history should change the other root's content without moving the active view selection:
const root = getRangeRootOrMain(batch.selectionBefore)
if (root !== tx.view.root()) {
return
}
tx.operations.replay([rootedSetSelectionOperation])
Keep set_selection operations after the first saveable operation in the
batch. Those operations are part of the user-visible edit result, and redo must
replay them.
For toolbar-driven multi-root examples, run document history through the active root view and then refocus that editable while preserving its DOM range. State-only undo should preserve title/input focus. Cross-root content undo should not globally force the active root to follow the restored batch root.
History units are grouped around model-changing operations. A leading DOM selection import only establishes where the model change should happen; it is not itself the undoable edit.
Capturing the batch start after leading selection imports matches the old Slate
history behavior without returning to operation-by-operation plugin wrapping.
It also keeps Slate v2 operation-first history intact for collaboration rebase,
because selectionBefore still flows through the existing range transform path.
Rooted history adds one more invariant: the range precondition, the invoking view root, and the replayed selection operation must agree on ownership. If the range is cloned rootless, or if a rooted range is replayed through the wrong view, document text can undo correctly while the active caret moves somewhere the user did not ask for.