docs/solutions/logic-errors/2026-04-25-slate-v2-destructive-delete-must-clean-empty-leaves-before-render.md
Repeated destructive deletes through richtext leaf boundaries can leave empty marked/code leaves in a non-empty paragraph. If React renders those leaves as line-break zero-width nodes, the visible editor gains fake blank lines even when model text and selection appear correct.
<textarea>!
segment created blank lines in the first paragraph.innerText,
textContent, and zero-width DOM nodes diverged. in React alone was the wrong owner. React can defend
rendering, but the invalid committed model shape still exists.Make destructive leaf cleanup a core lifecycle owner, then prove the rendered
DOM shape through slate-browser.
Core cleanup removes empty text leaves unless they are structurally required:
const requiredInlineSpacer = isRequiredInlineSpacer(editor, children, index)
const requiredEmptyBlockAnchor = !parentHasText && emptyTextChildren <= 1
if (requiredInlineSpacer || requiredEmptyBlockAnchor) {
continue
}
maybeRebaseSelectionBeforeRemoval(editor, childPath, affinity)
removeNodes(editor, { at: childPath, voids: true })
React rendering keeps the same invariant from leaking visually:
empty block anchor -> one line-break placeholder
required inline spacer -> non-line-breaking sentinel
mark placeholder -> non-line-breaking sentinel
removable empty marked/code/decorated leaf -> must not reach render truth
slate-browser must assert rendered DOM shape in generated destructive
gauntlets:
await editor.assert.renderedDOMShape({
blockIndex: 0,
innerText: firstBlockModelText,
noUnexpectedZeroWidthBreaks: true,
textContent: firstBlockModelText,
zeroWidthBreakCount: 0,
})
Release proof should include the guard names:
leaf-lifecycle-contractselection-rebase-contractrendered-dom-shape-contractdestructive-leaf-boundary-gauntletlegacy-leaf-delete-parityThe root bug is not "Backspace timing" once the editing epoch is model-owned. The root bug is committed tree shape plus render shape:
destructive edit
-> empty leaf survives
-> React treats it as a line-break zero-width node
-> non-empty paragraph gains fake visual lines
Owning the lifecycle in core prevents invalid empty leaves from becoming render
truth. Keeping a React rendered-DOM contract catches custom render paths. Adding
slate-browser DOM-shape assertions closes the exact gap that model text and
selection tests missed.
nodes.