docs/plans/2026-05-07-slate-v2-inline-delete-boundary-repro-ralplan.md
The next lane should be #5972: empty editable inline Backspace in the inlines example.
Do not take the mobile/IME cluster next. It is larger, but exact closure needs raw-device proof. Do not take split-specific #1654 next unless a fresh split repro appears. The structural-delete slice already improved #1654 and explicitly left split-specific closure unclaimed.
#5972 is the better next autonomous lane because it is issue-backed, browser-visible, narrow, and already has a ready-now test candidate. The plan is not to change public API. The plan is to prove the bug, route ownership by evidence, then patch the owning primitive.
needs repro into a browser-proven fix or a documented current non-repro./examples/inlines removes the empty inline without deleting the preceding character in the simple case..tmp/slate-v2 inlines example and browser tests.slate and slate-react.normalizePoint API.slate, slate-react, slate-dom, or the example only after the red browser proof identifies the owner.Issue evidence:
gitcrawl doctor --json reports local Slate corpus present with 659 open threads and last sync 2026-05-04T14:58:11.123944Z.gitcrawl threads ianstormtaylor/slate --numbers 5972 --include-closed --json reports #5972 open, labeled bug, with steps: clear text inside the inline/input element, then delete it.gitcrawl search ianstormtaylor/slate --query "inline input delete preceding character" --mode hybrid --limit 10 --json returns only #5972.docs/slate-issues/test-candidate-map/5994-5918.md:206 marks #5972 ready-now.docs/slate-issues/open-issues-dossiers/5994-5918.md:419 records the basic case as valid and direct v2 relevance.Live Slate v2 evidence:
.tmp/slate-v2/site/examples/ts/inlines.tsx:29 defines the inlines example with link, editable button, and read-only badge inline children..tmp/slate-v2/site/examples/ts/inlines.tsx:149 registers link, button, and badge as inline elements..tmp/slate-v2/site/examples/ts/inlines.tsx:105 customizes only left/right movement to unit: 'offset'; Backspace is left to the runtime..tmp/slate-v2/playwright/integration/examples/inlines.test.ts:34 already proves editable inline end typing..tmp/slate-v2/playwright/integration/examples/inlines.test.ts:65 proves following text start is distinct from inline end..tmp/slate-v2/playwright/integration/examples/inlines.test.ts:178 proves cut of inline link text keeps the caret editable..tmp/slate-v2/playwright/integration/examples/inlines.test.ts.Likely owner files:
.tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts:148 classifies Backspace/Delete as delete intent..tmp/slate-v2/packages/slate-react/src/editable/keyboard-input-strategy.ts:241 turns destructive keydown into a model-owned command..tmp/slate-v2/packages/slate-react/src/editable/mutation-controller.ts:95 maps default Backspace to tx.text.deleteBackward({ unit: 'character' })..tmp/slate-v2/packages/slate/src/transforms-text/delete-text.ts:421 already contains inline point relocation helpers..tmp/slate-v2/packages/slate/src/core/leaf-lifecycle.ts:72 owns cleanup of empty text leaves while preserving required inline spacers..tmp/slate-v2/packages/slate/src/core/normalize-node.ts:263 owns explicit inline children normalization and spacer insertion..tmp/slate-v2/packages/slate-react/src/editable/clipboard-input-strategy.ts:207 already has cut-specific empty inline removal and caret reset logic; that is a useful pattern, not proof that Backspace is fixed.Principles:
Fixes #5972 requires matching red/green proof.Top drivers:
Viable options:
| Option | Pros | Cons | Verdict |
|---|---|---|---|
| Browser-first #5972 row, route owner by trace | Matches issue, keeps closure honest, avoids architecture guesswork | Requires a red browser test before code | Chosen |
| Core-only delete target patch first | Fast if the bug is purely delete-text.ts | Can paper over a DOM selection import bug | Rejected until proof |
| React keydown special-case for editable inline | Easy local hook | Duplicates delete semantics outside the model primitive | Rejected |
| Example-specific Backspace handler | Small diff | Would prove the example can hack around Slate, not that Slate v2 fixed the bug | Rejected except as evidence-only fallback |
| Public inline delete policy API | Flexible | Product-shaped and premature | Rejected |
Consequences:
Related to Fixes.| System | Source Used | Mechanism Observed | Slate Target | Steal | Reject | Verdict |
|---|---|---|---|---|---|---|
| Lexical | ../lexical/packages/lexical-rich-text/src/index.ts:581; ../lexical/packages/lexical/src/LexicalSelection.ts:1743 | Backspace dispatches DELETE_CHARACTER_COMMAND, then RangeSelection.deleteCharacter owns text vs decorator/node behavior. | Keep destructive input model-owned and route delete through one planner. | Command-owned delete with node-type-aware edge handling. | Lexical class nodes and $ public style. | partial |
| Lexical | ../lexical/packages/lexical/src/LexicalSelection.ts:1627 | For movement, Lexical may ask DOM selection.modify, then imports the result. | If #5972 is a selection-import bug, import DOM selection before model delete. | DOM-as-measurement, model-as-truth. | Waiting for browser DOM delete as truth. | agree |
| ProseMirror | ../prosemirror-commands/src/commands.ts:8, :30, :138, :736 | Backspace chains deleteSelection, joinBackward, then selectNodeBackward. | Split deletion into selection delete, structure boundary delete, and node-boundary selection. | Explicit command chain and fallback selection behavior. | Integer-position model and schema-first API. | partial |
| ProseMirror | ../prosemirror/model/src/schema.ts:390, :441 | inline, atom, selectable, and isolating schema flags influence editing boundaries. | Use existing Slate schema flags (inline, void, readOnly, isIsolating) as internal delete policy inputs. | Boundary flags as internal policy. | Making ProseMirror node specs the raw Slate public model. | partial |
| Tiptap | ../tiptap/packages/core/src/extensions/keymap.ts:13; ../tiptap/packages/core/src/commands/join.ts:59; ../tiptap/packages/core/src/commands/selectNodeBackward.ts:17 | Product DX wraps ProseMirror commands behind extension keyboard shortcuts. | Keep raw Slate minimal; examples may customize keydown, but core deletion must not rely on product extension shortcuts. | Discoverable commands as DX inspiration. | focus().chain().run() ceremony for raw Slate. | diverge |
| Tiptap | ../tiptap/packages/core/src/helpers/getSchemaByResolvedExtensions.ts:80; ../tiptap/packages/core/src/NodeView.ts:261 | Extension schema carries inline, atom, selectable, isolating; atom node views are black boxes. | Slate should treat true void/read-only inline controls as boundary objects, but editable inline content remains content. | Clear atom vs editable-content distinction. | NodeView black-box model as default Slate renderer API. | partial |
No public API change.
Hard cuts:
deleteEmptyInline option;normalizePoint;onBeforeDeleteInline;inlineDeletionPolicy prop;If app authors need custom product behavior, the existing onKeyDown and extension surfaces are enough. The raw runtime must make the official example correct without extra public ceremony.
Target shape:
browser Backspace
-> import current DOM/model selection if needed
-> model-owned delete command
-> delete planner sees empty editable inline boundary
-> remove empty inline only
-> select the adjacent valid point
-> repair DOM selection from model
-> no preceding character deletion
Owner decision by trace:
| Evidence | Owner |
|---|---|
Model selection is already inside/after the cleared inline, but delete-text.ts chooses the previous character range | packages/slate/src/transforms-text/delete-text.ts |
| DOM selection after clearing the inline is not imported to the expected model point | packages/slate-react/src/editable/*selection* or slate-dom bridge |
| Backspace fires on an interactive internal native control instead of the editable runtime | packages/slate-react/src/editable/input-controller.ts and example DOM shape |
| Empty inline cleanup after delete removes the right node but rebases selection wrong | packages/slate/src/core/leaf-lifecycle.ts or normalize-node.ts |
| Existing v2 does not reproduce | update dossier as current non-repro; do not claim Fixes #5972 |
renderElement must keep editable inline children mounted.onKeyDown may keep left/right unit: 'offset'; Backspace stays runtime-owned.Plate migration pressure:
slate-yjs pressure:
No slate-yjs fixture is required for the first #5972 browser slice unless the fix changes operation shape in a non-local way.
ClawSweeper pass:
gitcrawl doctor --jsongitcrawl threads ianstormtaylor/slate --numbers 5972 --include-closed --jsongitcrawl search ianstormtaylor/slate --query "inline input delete preceding character" --mode hybrid --limit 10 --jsonFixed issue claims:
Planned exact claim after implementation:
Fixes #5972: Backspace after clearing the editable inline in the inlines example removes the empty inline without deleting the preceding character.Related but not fixed:
| Issue | Category | Reason |
|---|---|---|
| #4618 | related/not fixed | Inline-boundary ergonomics, but no public normalizePoint API. |
| #5806 | related/not fixed | Custom inline drag/slide selection, not empty inline Backspace. |
| #5690 | related/not fixed | Double-click before inline then delete crash, requires Windows/Chrome-style repro. |
| #5183/#5391/#4348/#5984 | related/not fixed | Mobile inline/Backspace/input rows need raw-device or dedicated mobile proof. |
| #1654 | improved elsewhere/not fixed here | Split-specific isolating behavior remains separate from empty editable inline deletion. |
Live ledger sync:
docs/slate-issues/gitcrawl-live-open-ledger.md: unchanged; it is generated corpus output and no implementation claim changed.docs/slate-issues/open-issues-ledger.md: #5972 moved to fixes-claimed.docs/slate-v2/ledgers/issue-coverage-matrix.md: #5972 moved from Related to Fixes.docs/slate-v2/ledgers/fork-issue-dossier.md: #5972 refreshed with implementation and proof.docs/slate-v2/references/pr-description.md: synced with the #5972 fixed claim.Cluster coverage:
Selection, Focus, And DOM Bridge and Core Model, Operations, Normalization, And History.| Proof | First Red Test | Green Requirement | Claim Impact |
|---|---|---|---|
| Exact #5972 Chromium browser row | Add empty editable inline Backspace does not delete preceding text in playwright/integration/examples/inlines.test.ts | Preceding text unchanged; empty inline removed or converted to valid adjacent selection; caret remains editable | Enables Fixes #5972 if original steps match |
| Model selection trace | Browser row records model selection before/after Backspace | Selection lands on surviving adjacent point, not removed inline text | Determines core vs React owner |
| DOM selection trace | Browser row checks DOM selection target before Backspace | DOM target maps to expected Slate point before delete | Determines DOM import owner |
Core unit, if root cause is delete-text.ts | Add narrow package test for empty inline before text | deleteBackward({ unit: 'character' }) removes empty inline only | Supports model fix |
| React/input unit, if root cause is import/repair | Add focused slate-react contract | destructive command runs after correct selection import and repairs caret | Supports runtime fix |
| Existing inline rows | Run existing inlines grep | Existing typing/cut/navigation rows stay green | Blocks regression |
First pass:
PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/inlines.test.ts --project=chromium --grep "empty editable inline|types inside an editable inline|keeps the start of following text distinct|keeps caret editable after cutting inline link text"
Second pass when green:
PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/inlines.test.ts --project=chromium --project=firefox --project=webkit --grep "empty editable inline|types inside an editable inline|keeps the start of following text distinct|keeps caret editable after cutting inline link text"
Do not use mobile viewport as raw mobile proof. It can be a smoke row only.
Owner: .tmp/slate-v2/playwright/integration/examples/inlines.test.ts
Work:
/examples/inlines.button inline text.Gate:
Owner: execution agent.
Work:
Internal Runtime Target.Gate:
Use only if the model delete target crosses into preceding text.
Likely files:
.tmp/slate-v2/packages/slate/src/transforms-text/delete-text.ts.tmp/slate-v2/packages/slate/src/core/leaf-lifecycle.ts.tmp/slate-v2/packages/slate/src/core/normalize-node.ts.tmp/slate-v2/packages/slate/test/delete-contract.tsTarget:
Use only if DOM selection import/repair is wrong.
Likely files:
.tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts.tmp/slate-v2/packages/slate-react/src/editable/keyboard-input-strategy.ts.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts.tmp/slate-v2/packages/slate-react/src/editable/selection-controller.ts.tmp/slate-v2/packages/slate-dom/src/**Target:
Use only if runtime is correct and the example markup causes an app-local target issue.
Target:
After green proof:
docs/slate-v2/ledgers/issue-coverage-matrix.md;docs/slate-v2/ledgers/fork-issue-dossier.md;docs/slate-v2/references/pr-description.md;Minimal:
PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/inlines.test.ts --project=chromium --grep "empty editable inline"
If core changed:
bun test packages/slate/test/delete-contract.ts packages/slate/test/leaf-lifecycle-contract.ts --bail 1
bunx turbo typecheck --filter=./packages/slate --force
If React/DOM changed:
bun --filter slate-react test:vitest -- model-input-strategy
bun --filter slate-react test:vitest -- selection-runtime
bunx turbo typecheck --filter=./packages/slate-react --filter=./packages/slate-dom --force
Always after code edits:
bun run lint:fix
Closeout browser gate:
PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/inlines.test.ts --project=chromium --project=firefox --project=webkit --grep "empty editable inline|types inside an editable inline|keeps the start of following text distinct|keeps caret editable after cutting inline link text"
| Skill | Status | Reason |
|---|---|---|
clawsweeper | applied | #5972 selected through gitcrawl and issue ledger proof. |
tdd | applied | Execution must start with a red browser row before patching. |
high-risk-deliberate-pass | applied | Browser destructive editing behavior can corrupt text. |
performance-oracle | skipped | This lane is not a hot-path/perf claim; existing inline rows are enough. |
performance | skipped | No cohort/benchmark behavior is being claimed. |
vercel-react-best-practices | skipped | No React render/subscription API change planned. |
react-useeffect | skipped | No effect lifecycle change planned. |
shadcn | skipped | No UI component system change. |
Trigger: destructive browser editing behavior.
Blast radius:
Scenario 1: Core fix removes required inline spacers.
Scenario 2: React fix imports a stale DOM selection.
Scenario 3: Fix handles the simple case but breaks cut or typing at inline edge.
Rollback/remediation:
Verdict: keep plan.
| Objection | Answer |
|---|---|
| "This is app-specific; raw Slate should not own it." | The issue names the official inlines example and uses editable inline content. Raw Slate owns the default delete contract unless proof shows the example is doing something invalid. |
| "Backspace should delete a character; empty inline deletion is special." | ProseMirror and Lexical both special-case node/boundary deletion. A model-present empty inline is not a character. |
| "Nested inline semantics are ambiguous." | Agreed. The first proof only covers the simple official-example case. Nested cases remain non-claims. |
"A user can handle this in onKeyDown." | That is a workaround, not v2 correctness. The official example should not need a product shortcut to avoid deleting adjacent text. |
| "Do not overfit Chromium." | Start with Chromium for red proof because the issue is browser-visible, then run Firefox/WebKit before closure. |
Fixes #5972 claim.| Dimension | Score | Evidence |
|---|---|---|
| React 19.2 runtime performance | 0.90 | No render subscription change; existing model-owned command path in keyboard-input-strategy.ts and mutation-controller.ts. |
| Slate-close unopinionated DX | 0.96 | Public API target is no new API; raw Slate owns the primitive. |
| Plate and slate-yjs migration backbone | 0.90 | Operation determinism and no product shortcut requirement recorded. |
| Regression-proof testing strategy | 0.96 | Red browser row, owner classification trace, package tests by owner, cross-browser closeout. |
| Research evidence completeness | 0.94 | Live gitcrawl, current v2 source, Lexical, ProseMirror, and Tiptap local source checked. |
| shadcn-style composability and hook/component minimalism | 0.92 | No new component/hook surface; example remains normal Slate React composition. |
Total: 0.93.
| Pass | Status | Evidence Added | Plan Delta | Open Issues | Next Owner |
|---|---|---|---|---|---|
| Current-state read | complete | active goal state, structural delete checkpoint, current source/test files | Selected new lane, not prior slice continuation | none | Ralplan |
| ClawSweeper related issue discovery | complete | gitcrawl #5972 and hybrid search | #5972 selected, neighbors classified non-claim | none | Ralplan |
| Issue-ledger pass | complete | test candidate map, dossier, coverage matrix | #5972 was kept Related until implementation proof landed | none | Ralplan |
| Intent/boundary | complete | explicit scope/non-goals/decision boundary | no user question needed | none | Ralplan |
| Ecosystem synthesis | complete | Lexical/ProseMirror/Tiptap local source | concrete steal/reject decisions added | none | Ralplan |
| High-risk pass | complete | destructive edit pre-mortem | owner classification gate added | none | Ralplan |
| Issue sync accounting | complete | coverage/dossier/PR pointers | generated live corpus unchanged by design | none | Ralplan |
| Closure score | complete | score 0.93 with no dimension below 0.90 | ready for execution | none | Ralph |
| Ralph execution start | complete | .tmp/completion-checks/slate-v2-inline-delete-boundary-repro-ralplan.md moved to pending | red browser row became the active pass | none | execution agent |
| Red browser row | complete | .tmp/slate-v2/playwright/integration/examples/inlines.test.ts | #5972 reproduced: Backspace after clearing the inline deleted the preceding space | none | core delete planner |
| Core fix | complete | .tmp/slate-v2/packages/slate/src/transforms-text/delete-text.ts; .tmp/slate-v2/packages/slate/test/delete-contract.ts | Backspace at the start of an empty editable inline routes to path delete instead of previous-character delete | none | verification |
| Verification and issue sync | complete | package test, browser parity, typecheck, lint, issue ledgers, PR reference | #5972 moved from Related to Fixes | none | done |
/examples/inlines cannot reproduce #5972 even with an equivalent empty editable inline, execution should stop at dossier update and not patch.<input> embedded inside contenteditable can reproduce, this becomes interactive-internal-control policy, not editable inline content.Use [$ralph](/Users/zbeyens/git/plate-2/.agents/skills/ralph/SKILL.md) on this plan.
Start execution in .tmp/slate-v2 with the red Chromium browser row for #5972. Do not patch before the red row classifies owner. After the first red row, choose the narrow owner branch:
Then run the focused package/browser gates listed above and sync issue claims only after green proof.
doneDecision:
Fixes #5972.packages/slate/src/transforms-text/delete-text.ts.Verification:
bun test ./packages/slate/test/delete-contract.ts --test-name-pattern "empty editable inline"
bun test ./packages/slate/test/delete-contract.ts
PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/inlines.test.ts --project=chromium --grep "empty editable inline"
PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/inlines.test.ts --project=chromium --grep "empty editable inline|types inside an editable inline|keeps the start of following text distinct|keeps caret editable after cutting inline link text"
PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/inlines.test.ts --project=chromium --project=firefox --project=webkit --grep "empty editable inline|types inside an editable inline|keeps the start of following text distinct|keeps caret editable after cutting inline link text"
bun typecheck:packages
bun lint:fix