docs/plans/2026-04-25-slate-v2-leaf-lifecycle-dom-shape-conformance-plan.md
Keep the Slate v2 architecture:
Slate model + operations
editor.read / editor.update lifecycle
transaction commits
authoritative editing epoch kernel
React 19.2 live-read / dirty-commit runtime
slate-browser generated proof gates
Do not pivot to legacy Slate React, Lexical, ProseMirror, or another editor.
Pivot harder inside the current architecture. The editing epoch kernel closes event timing and selection ownership. It does not yet close the next required layer:
transaction
-> leaf lifecycle cleanup
-> selection rebase
-> commit metadata
-> render projection
-> DOM repair
-> rendered DOM shape conformance
Harsh take: the current proof still lets model-correct edits render garbage. That is not release-grade browser editing.
Observed local browser path:
http://localhost:3100/examples/richtext.<textarea>!.Backspace / Option+Backspace. inside a non-empty block.Captured failure shape:
first block model text:
"This is editable rich text, much better than a "
first block DOM includes:
<code>
<span data-slate-zero-width="n" data-slate-length="0">
</span>
</code>
<span data-slate-zero-width="n" data-slate-length="0">
</span>
visible result:
fake blank visual lines inside a non-empty paragraph
This is not a keydown timing bug anymore. It is a document-shape and render-shape invariant bug.
Existing destructive editing proof asserts:
That is useful but too weak.
It does not assert:
innerText So the suite allowed fake visual lines while claiming model/DOM coherence.
For every inline-compatible element after destructive edits:
Allowed empty text leaf classes:
empty-block-anchor: the only editable text child in an empty blockinline-leading-spacer: required before an inline elementinline-trailing-spacer: required after an inline elementinline-between-spacer: required between inline elementstemporary-selection-anchor: allowed only inside a transaction before commit,
never as committed render truthEverything else is garbage and must be removed or merged.
For every non-empty block:
Node.string(block) modulo expected
browser whitespace normalizationdata-slate-zero-width="n" with is forbidden unless the model shape is
an empty block or an explicit line-break placeholderinnerText must not gain blank lines after leaf-boundary deletionAfter destructive edits:
Add a first-class leaf lifecycle owner in core.
Suggested package shape:
packages/slate/src/core/leaf-lifecycle.ts
packages/slate/src/core/render-shape.ts
packages/slate/src/core/selection-rebase.ts
The owner must be core-level because the invalid committed shape is independent of React. React can render it visibly, but React should not be responsible for guessing whether a deleted model leaf should still exist.
React then gets a narrow renderer policy:
model leaf class
-> zero-width mode
-> DOM shape
slate-browser gets a proof policy:
model tree
-> rendered block DOM
-> line/zero-width/selection shape
Goal: freeze the reported bug before refactoring.
Work in /Users/zbeyens/git/slate-v2:
playwright/integration/examples/richtext.test.ts.Backspace and Alt+Backspace through <textarea>!, code leaf,
punctuation, and normal leaf boundaries.innerTexttextContentinnerHTMLAcceptance:
nodes.Earliest gate:
PLAYWRIGHT_BASE_URL=http://localhost:3100 bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "leaf|zero-width|word-delete|Backspace|DOM shape" --workers=1 --retries=0
Goal: make invalid committed leaf shapes impossible.
Work:
packages/slate/test/leaf-lifecycle-contract.ts.Acceptance:
Earliest gates:
bun test ./packages/slate/test/leaf-lifecycle-contract.ts --bail 1
bun test ./packages/slate/test/transaction-target-runtime-contract.ts --bail 1
bun test ./packages/slate/test/commit-metadata-contract.ts --bail 1
Goal: remove dead or useless selection anchors after cleanup.
Work:
packages/slate/test/selection-rebase-contract.ts.Acceptance:
Earliest gates:
bun test ./packages/slate/test/selection-rebase-contract.ts --bail 1
bun test ./packages/slate/test/transforms-text-delete-contract.ts --bail 1
If transforms-text-delete-contract.ts does not exist yet, create the focused
contract beside the current text transform tests instead of widening the full
suite first.
Goal: make renderer output obey model leaf classes.
Work:
packages/slate-react/test/rendered-dom-shape-contract.tsx. EditableText / ZeroWidthString usage so an empty marked/code leaf in
a non-empty block cannot create a visual .renderLeafrenderTextrenderSegmentAcceptance:
Earliest gates:
bun test ./packages/slate-react/test/rendered-dom-shape-contract.tsx --bail 1
bun test ./packages/slate-react/test/primitives-contract.tsx --bail 1
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
Goal: make visual DOM shape a reusable proof primitive.
Work in packages/slate-browser/src/playwright/index.ts:
assertRenderedDOMShape.editor.assert.renderedBlockText(index, text)editor.assert.noUnexpectedZeroWidthBreaks(index)editor.assert.zeroWidthShape(index, expected)editor.assert.lineBoxCount(index, expected | { max })editor.assert.domSelectionTarget({ path, offset, allowZeroWidth })innerTexttextContentAcceptance:
slate-browser core tests cover the assertion helpers without launching the
full integration suite.Earliest gates:
bun run --cwd packages/slate-browser test:core --bail 1
bun test ./packages/slate-browser/test/core/scenario.test.ts --bail 1
bun test ./packages/slate-browser/test/browser/zero-width.browser.test.ts --bail 1
Goal: cover the full destructive editing state space that creates empty leaves.
Expand generated gauntlets for:
Run each across:
Every row must assert:
innerText Acceptance:
Earliest gate:
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" --workers=4 --retries=0
Goal: prove this class does not regress from legacy Slate.
Work:
../slate for:
<textarea>!Acceptance:
Suggested artifact:
docs/research/decisions/slate-v2-destructive-leaf-boundary-legacy-parity.md
Goal: prevent this regression from returning.
Work:
leaf-lifecycle-contractrendered-dom-shape-contractdestructive-leaf-boundary-gauntletlegacy-leaf-delete-paritybun check:full only if the new gates are release-proof guards, not
iteration-only checks.bun check fast.bun test:integration-local into bun check.Acceptance:
bun test:release-proof fails if DOM-shape proof artifacts are absent.bun check:full includes the DOM-shape release proof before full integration.active goal state remains done for the old completed plan until this
plan becomes the active implementation lane.Focused first:
bun test ./packages/slate/test/leaf-lifecycle-contract.ts --bail 1
bun test ./packages/slate/test/selection-rebase-contract.ts --bail 1
bun test ./packages/slate-react/test/rendered-dom-shape-contract.tsx --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 --project=chromium --grep "leaf|zero-width|DOM shape|Backspace|word-delete" --workers=1 --retries=0
Package gates by touched area:
bunx turbo build --filter=./packages/slate --filter=./packages/slate-react --filter=./packages/slate-dom --filter=./packages/slate-browser --force
bunx turbo typecheck --filter=./packages/slate --filter=./packages/slate-react --filter=./packages/slate-dom --filter=./packages/slate-browser --force
bun run lint:fix
bun run lint
Closure gates:
bun test:release-proof
bun check:full
This plan is complete only when:
slate-browser can assert rendered DOM shape genericallyDo not stop at a single richtext fix.
Stop only when:
If a checkpoint says “next move,” execute it. A red test is not a blocker; it is the start of the lane.
active goal state to pending.active goal state is being generated for Stop-hook continuation..tmp/slate-v2 before core
cleanup.active goal state,
docs/plans/2026-04-25-slate-v2-leaf-lifecycle-dom-shape-conformance-plan.md.ZeroWidthString; do not claim the
previous epoch proof covers visual DOM shape. nodes.PLAYWRIGHT_BASE_URL=http://localhost:3100 bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "rendered DOM shape" --workers=1 --retries=0..tmp/slate-v2/playwright/integration/examples/richtext.test.ts.domTextMatchesModel: false,
hasFakeBlankLines: true, and unexpectedZeroWidthBreakCount: 2; the first
block model text is This is editable rich text, much while DOM innerText
contains extra blank lines from two data-slate-zero-width="n" nodes with
.0,5 and 0,6; React renders them as line-break zero-width nodes
because the leaves reach rendering as empty text instead of being cleaned or
classified..tmp/slate-v2/playwright/integration/examples/richtext.test.ts. in React
first; prove/remediate the committed leaf shape in core..tmp/slate-v2/packages/slate/test/leaf-lifecycle-contract.ts
and make it fail on removable empty marked/code leaves after destructive
deletes.bun test ./packages/slate/test/leaf-lifecycle-contract.ts --bail 1bun test ./packages/slate/test/transaction-target-runtime-contract.ts --bail 1bun test ./packages/slate/test/commit-metadata-contract.ts --bail 1PLAYWRIGHT_BASE_URL=http://localhost:3100 bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "rendered DOM shape" --workers=1 --retries=0PLAYWRIGHT_BASE_URL=http://localhost:3100 bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "leaf|zero-width|DOM shape|Backspace|word-delete" --workers=1 --retries=0bunx turbo build --filter=./packages/slate --filter=./packages/slate-react --filter=./packages/slate-dom --forcebunx turbo typecheck --filter=./packages/slate --filter=./packages/slate-react --filter=./packages/slate-dom --forcebun run lint:fixbun run lint.tmp/slate-v2/packages/slate/src/core/leaf-lifecycle.ts.tmp/slate-v2/packages/slate/src/core/index.ts.tmp/slate-v2/packages/slate/src/transforms-text/delete-text.ts.tmp/slate-v2/packages/slate/test/leaf-lifecycle-contract.ts.tmp/slate-v2/playwright/integration/examples/richtext.test.ts["", ""]; after cleanup, the core contract and the browser DOM
shape regression both pass. in
React as the primary fix..tmp/slate-v2/packages/slate-react/test/rendered-dom-shape-contract.tsx.bun test ./packages/slate/test/selection-rebase-contract.ts --bail 1..tmp/slate-v2/packages/slate/test/selection-rebase-contract.ts..tmp/slate-v2/packages/slate/test/selection-rebase-contract.ts.bun test ./packages/slate-react/test/rendered-dom-shape-contract.tsx --bail 1bun test ./packages/slate-react/test/primitives-contract.tsx --bail 1bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1bun 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 1PLAYWRIGHT_BASE_URL=http://localhost:3100 bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "leaf|zero-width|DOM shape|Backspace|word-delete" --workers=1 --retries=0bunx turbo build --filter=./packages/slate --filter=./packages/slate-react --filter=./packages/slate-dom --forcebunx turbo typecheck --filter=./packages/slate --filter=./packages/slate-react --filter=./packages/slate-dom --forcebun run lint:fixbun run lint.tmp/slate-v2/packages/slate-react/test/rendered-dom-shape-contract.tsx.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsxdata-slate-zero-width="n" nodes containing in a non-empty block;
after the render policy change, non-empty blocks have no unexpected
zero-width line breaks while empty blocks still render one line-break
placeholder.slate-browser DOM-shape assertions so generated gauntlets stop carrying
one-off page scripts.bun run --cwd packages/slate-browser test:core --bail 1
after adding assertion helpers, then the focused richtext Playwright grep..tmp/slate-v2/packages/slate-react/test/rendered-dom-shape-contract.tsx.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsxZeroWidthString itself because empty-block line breaks are still
valid.bun test:integration-local into iteration; do
not widen to mobile/raw-device proof before the DOM-shape assertion primitive
exists.slate-browser rendered DOM shape assertion helpers
and core tests.slate-browser/playwright rendered DOM shape
snapshots, assertion helpers, a replayable scenario step, and wired the
richtext regression to the reusable assertion.bun run --cwd packages/slate-browser test:core --bail 1PLAYWRIGHT_BASE_URL=http://localhost:3100 bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "leaf|zero-width|DOM shape|Backspace|word-delete" --workers=1 --retries=0 failed once because Playwright imported stale built slate-browser/playwright output.bunx turbo build --filter=./packages/slate-browser --filter=./packages/slate-dom --filter=./packages/slate-react --forcePLAYWRIGHT_BASE_URL=http://localhost:3100 bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "leaf|zero-width|DOM shape|Backspace|word-delete" --workers=1 --retries=0bunx turbo build --filter=./packages/slate --filter=./packages/slate-browser --filter=./packages/slate-react --filter=./packages/slate-dom --forcebunx turbo typecheck --filter=./packages/slate --filter=./packages/slate-browser --filter=./packages/slate-react --filter=./packages/slate-dom --forcebun 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 1bun 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 1PLAYWRIGHT_BASE_URL=http://localhost:3100 bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "leaf|zero-width|DOM shape|Backspace|word-delete" --workers=1 --retries=0.tmp/slate-v2/packages/slate-browser/src/playwright/index.ts.tmp/slate-v2/packages/slate-browser/test/core/scenario.test.ts.tmp/slate-v2/playwright/integration/examples/richtext.test.tsslate-browser core tests now serialize
assertRenderedDOMShape for replay; the harness can assert rendered block
text, unexpected zero-width breaks, zero-width counts, line-box bounds, and
DOM selection target; the richtext destructive row passes through that
reusable assertion path.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" --workers=4 --retries=0bun run --cwd packages/slate-browser test:core --bail 1.tmp/slate-v2/packages/slate-browser/src/playwright/index.ts.tmp/slate-v2/packages/slate-browser/test/core/scenario.test.ts.tmp/slate-v2/playwright/integration/examples/richtext.test.tsbun run --cwd packages/slate-browser test:core --bail 1bunx turbo build --filter=./packages/slate-browser --filter=./packages/slate-react --filter=./packages/slate-dom --forcePLAYWRIGHT_BASE_URL=http://localhost:3100 bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "generated destructive" --workers=1 --retries=0PLAYWRIGHT_BASE_URL=http://localhost:3100 bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "generated (destructive|mixed)" --workers=1 --retries=0PLAYWRIGHT_BASE_URL=http://localhost:3100 bunx playwright test ./playwright/integration/examples/inlines.test.ts --project=chromium --grep "generated inline cut" --workers=1 --retries=0PLAYWRIGHT_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 failed once on the existing Delete before trailing punctuation row because selection rebased to the next paragraph after removing a suffix punctuation leaf.bun test ./packages/slate/test/selection-rebase-contract.ts --bail 1PLAYWRIGHT_BASE_URL=http://localhost:3100 bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --project=webkit --grep "Delete before trailing punctuation" --workers=1 --retries=0bun 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 1bunx turbo build --filter=./packages/slate --filter=./packages/slate-browser --filter=./packages/slate-react --filter=./packages/slate-dom --forcebunx turbo typecheck --filter=./packages/slate --filter=./packages/slate-browser --filter=./packages/slate-react --filter=./packages/slate-dom --forcebun run lint:fixbun run lintPLAYWRIGHT_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.tmp/slate-v2/packages/slate-browser/src/playwright/index.ts.tmp/slate-v2/packages/slate-browser/test/core/scenario.test.ts.tmp/slate-v2/packages/slate/src/core/leaf-lifecycle.ts.tmp/slate-v2/packages/slate/test/selection-rebase-contract.ts.tmp/slate-v2/playwright/integration/examples/richtext.test.ts.tmp/slate-v2/playwright/integration/examples/inlines.test.ts.tmp/slate-v2/packages/slate-browser/src/playwright/index.ts.tmp/slate-v2/packages/slate-browser/test/core/scenario.test.ts.tmp/slate-v2/packages/slate/src/core/leaf-lifecycle.ts.tmp/slate-v2/packages/slate/test/selection-rebase-contract.ts.tmp/slate-v2/playwright/integration/examples/richtext.test.ts.tmp/slate-v2/playwright/integration/examples/inlines.test.tsDelete before trailing punctuation as flaky;
it exposed a real same-block rebase invariant and got a core RED before the
patch.docs/research/decisions/slate-v2-destructive-leaf-boundary-legacy-parity.md
comparing v2 destructive delete behavior against ../slate.rg -n "zero-width|ZeroWidth|deleteBackward|deleteForward|Backspace|Delete|renderLeaf|leaf|selection" packages slate-react site examples test -g '*.ts' -g '*.tsx' -g '*.js'rg --files packages/slate-react packages/slate test site examples | rg "(string|leaf|editable|delete|selection|richtext|zero)"sed -n '1,220p' packages/slate-react/src/components/string.tsxsed -n '1,220p' packages/slate-react/src/components/leaf.tsxsed -n '560,900p' packages/slate-dom/src/plugin/dom-editor.tssed -n '70,130p' packages/slate-dom/src/plugin/with-dom.tssed -n '230,300p' packages/slate-dom/src/plugin/with-dom.tssed -n '1,240p' packages/slate/src/transforms-text/delete-text.tsrg --files packages/slate/test/transforms/delete | head -80docs/research/decisions/slate-v2-destructive-leaf-boundary-legacy-parity.mdZeroWidthString,
maps zero-width nodes explicitly in DOMEditor.toDOMPoint /
toSlatePoint, strips or converts zero-width nodes for clipboard payloads,
and carries broad delete fixtures; v2 now classifies destructive delete rows
as copied, improved, or intentionally rejected.bun run --cwd packages/slate-browser test:core --bail 1bun test:release-proofdocs/research/decisions/slate-v2-destructive-leaf-boundary-legacy-parity.mdbun check; do
not mark completion done until release proof knows about the DOM-shape and
parity artifacts.bun test:release-proof failed once because
packages/slate/src/core/leaf-lifecycle.ts called the public primitive
editor.removeNodes outside editor.update, violating the escape-hatch
source inventory.bun test:release-proofbunx turbo build --filter=./packages/slate --filter=./packages/slate-browser --filter=./packages/slate-react --filter=./packages/slate-dom --forcebunx turbo typecheck --filter=./packages/slate --filter=./packages/slate-browser --filter=./packages/slate-react --filter=./packages/slate-dom --forcebun run lint:fixbun run lintSLATE_BROWSER_SOAK_BASE_URL=http://localhost:3100 SLATE_BROWSER_SOAK_ITERATIONS=1 bun ./scripts/proof/persistent-browser-soak.mjs.tmp/slate-v2/packages/slate-browser/src/core/release-proof.ts.tmp/slate-v2/packages/slate-browser/test/core/release-proof.test.ts.tmp/slate-v2/package.json.tmp/slate-v2/scripts/proof/persistent-browser-soak.mjs.tmp/slate-v2/packages/slate/src/core/leaf-lifecycle.tsbun test:release-proof passes; touched package
build/typecheck/lint passes; one-iteration persistent soak passes and now
asserts no unexpected zero-width line breaks in the destructive editing
scenario.bun check:full remains the expensive final sweep because it includes
full integration; raw mobile device proof remains scoped unless the raw-device
lane provides Appium artifacts.bun check:fullbun completion-check.tmp/slate-v2/packages/slate-browser/src/core/release-proof.ts.tmp/slate-v2/packages/slate-browser/test/core/release-proof.test.ts.tmp/slate-v2/package.json.tmp/slate-v2/scripts/proof/persistent-browser-soak.mjs.tmp/slate-v2/packages/slate/src/core/leaf-lifecycle.tsbun check:full, then set
active goal state according to the result.bun check:full.tmp/slate-v2/test-results/release-proof/persistent-browser-soak.jsonbun check:full passed, including lint, package/site/root
typecheck, default unit tests, Vitest, bun test:release-proof, scoped mobile
proof, 5-iteration persistent-profile soak, and bun test:integration-local
with 540 Playwright rows.bun test:release-proofbun check:fulldocs/plans/2026-04-25-slate-v2-leaf-lifecycle-dom-shape-conformance-plan.mddocs/research/decisions/slate-v2-destructive-leaf-boundary-legacy-parity.mddocs/solutions/logic-errors/2026-04-25-slate-v2-destructive-delete-must-clean-empty-leaves-before-render.mdactive goal stateactive goal state.tmp/slate-v2/packages/slate/src/core/leaf-lifecycle.ts.tmp/slate-v2/packages/slate-browser/src/core/release-proof.ts.tmp/slate-v2/packages/slate-browser/test/core/release-proof.test.ts.tmp/slate-v2/package.json.tmp/slate-v2/scripts/proof/persistent-browser-soak.mjsbun check:full
passes; future raw-device proof belongs to the dedicated mobile lane.