docs/research/decisions/slate-v2-destructive-leaf-boundary-legacy-parity.md
Slate v2 copies legacy Slate's user-visible editing contract for destructive leaf-boundary deletes, but intentionally does not copy legacy's committed empty leaf tolerance.
The v2 invariant is stricter:
destructive edit
-> transaction
-> remove or classify empty leaves
-> rebase selection to a surviving valid point
-> commit
-> render without fake visual lines
-> slate-browser asserts visible DOM shape
Legacy Slate keeps more empty-leaf shapes alive and makes them renderable through zero-width sentinels. That is a useful compatibility lesson, not the v2 architecture target.
Legacy Slate proves these durable behaviors:
delete-text.ts; browser DOM
mutation is not the final source of document truth.data-slate-string,
data-slate-zero-width, mark placeholders, void spacers, non-editable
content, Android IME quirks, and Firefox newline quirks.V2 keeps those as compatibility constraints.
Legacy render code treats any empty leaf in a non-empty parent as a zero-width selection target. That is correct for inline spacers and mark placeholders, but too permissive after destructive deletion.
V2 hardens the contract:
and
create fake visual lines.This is an intentional improvement, not a legacy regression.
| Row | Legacy behavior | V2 behavior | Classification | Required proof |
|---|---|---|---|---|
Richtext repeated word delete through <textarea>! | Delete keeps model text coherent; empty leaves may still be renderable as zero-width sentinels. | Invalid empty code/plain suffix leaves are removed before render; first block DOM text matches model text; follow-up typing works. | Improved | richtext.test.ts row keeps rendered DOM shape after repeated leaf-boundary word-delete. |
| Backward delete over an empty marked/code suffix leaf | Selection mapping can tolerate zero-width leaves. | Core cleanup removes removable empty leaves and rebases selection to the previous surviving same-block point. | Improved | leaf-lifecycle-contract.ts; selection-rebase-contract.ts. |
| Forward Delete before trailing punctuation | Legacy can preserve an addressable zero-width suffix or move through DOM mapping. | Selection rebases to the previous surviving same-block point instead of jumping to the next paragraph or staying in a removed suffix leaf. | Improved | selection-rebase-contract.ts; richtext.test.ts row keeps caret editable after browser Delete before trailing punctuation. |
| Range delete across selected text | Delete produces the expected text and keeps the editor typeable. | Same user-visible behavior, plus DOM selection, follow-up typing, and rendered shape assertions. | Copied and hardened | Richtext selected-range destructive rows. |
| Mark boundary delete | Zero-width mark placeholders can preserve composition/mark behavior. | Mark placeholders stay non-line-breaking; removable empty marked leaves do not survive as visual line breaks. | Improved | rendered-dom-shape-contract.tsx; generated richtext destructive gauntlet. |
| Code leaf delete | Code leaf rendering can wrap a zero-width string. | Empty code leaf artifacts inside non-empty blocks are cleaned or rendered without fake ` | ||
| ` line boxes. | Improved | leaf-lifecycle-contract.ts; richtext DOM-shape row. | ||
| Inline boundary delete/cut | Invisible spacer leaves around inline content are required. | Required inline spacers survive; generated inline cut typing gauntlet proves no unexpected zero-width line breaks. | Copied and hardened | inlines.test.ts generated inline cut typing gauntlet. |
| Void inline boundary | Legacy uses a separate invisible spacer around non-editable void content. | V2 keeps the spacer classification rule; raw void/mobile depth remains a separately scoped proof lane. | Copied, scoped | Existing inline/void spacer contracts plus future raw-device proof. |
| Decorated/highlighted text delete | Legacy has leaf splitting and DOM selection mapping for render-only wrappers. | V2 treats decorations/projections as render shape, not committed empty-leaf permission; generated rows assert visible DOM shape where rows exist. | Improved | Highlighted text destructive browser rows plus slate-browser DOM-shape assertions. |
| Empty block after repeated delete | One editable empty text anchor renders a line-break placeholder. | Same invariant is explicit: empty blocks may render one placeholder ` | ||
| `. | Copied | rendered-dom-shape-contract.tsx empty-block row. | ||
| Clipboard/cut after zero-width leaves | Serialized DOM strips zero-width implementation detail. | Browser helpers and generated cut rows assert selected text/DOM shape without leaking fake line breaks. | Copied and hardened | slate-browser selected text helpers; inline cut typing gauntlet. |
| Legacy plugin monkeypatching around delete | Examples/plugins may override instance methods and read editor.selection. | Not copied. V2 requires editor.update, primitive editor methods, extension middleware/methods, and live selection reads. | Rejected | Public API hard-cut guards from the read/update runtime plan. |
The local release claim is browser-engine proof, not raw-device proof.
Covered:
rejection.Not covered by this parity artifact:
Those are release-scope boundaries, not excuses to let the richtext regression back in.
Current accepted proof for this decision:
bun test ./packages/slate/test/leaf-lifecycle-contract.ts ./packages/slate/test/selection-rebase-contract.ts ./packages/slate/test/transaction-target-runtime-contract.ts ./packages/slate/test/commit-metadata-contract.ts --bail 1
bun test ./packages/slate-react/test/rendered-dom-shape-contract.tsx ./packages/slate-react/test/primitives-contract.tsx ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run --cwd packages/slate-browser test:core --bail 1
PLAYWRIGHT_BASE_URL=http://localhost:3100 bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/highlighted-text.test.ts ./playwright/integration/examples/inlines.test.ts --project=chromium --project=firefox --project=webkit --project=mobile --grep "destructive|leaf|zero-width|DOM shape|Backspace|Delete|word-delete|generated inline cut|generated mixed" --workers=4 --retries=0
bunx turbo build --filter=./packages/slate --filter=./packages/slate-browser --filter=./packages/slate-react --filter=./packages/slate-dom --force
bunx turbo typecheck --filter=./packages/slate --filter=./packages/slate-browser --filter=./packages/slate-react --filter=./packages/slate-dom --force
bun run lint:fix
bun run lint
The broad destructive browser grep passed 64 rows across Chromium, Firefox, WebKit, and mobile viewport after the same-block forward-delete rebase fix.
Do not fix this class in React alone.
React rendering may defend against a bad shape, but the authoritative fix is:
If a future row needs a zero-width in a non-empty block, it must name the
model class that makes the line break valid. Otherwise it is garbage.