docs/plans/2026-04-22-slate-v2-editable-browser-kernel-refactor-plan.md
Replace the current packages/slate-react/src/components/editable.tsx browser
event monolith with a lossless browser editing kernel.
The new shape must preserve every current behavior-bearing bug patch while making ownership explicit enough that future browser regressions cannot hide behind model-only tests.
This is not a cosmetic file split. It is a correctness and architecture lane.
editable.tsx is not clean enough for the final architecture.
It currently owns too many unrelated responsibilities:
The caret bug proved the weakness: Slate model text and model selection were correct, but the browser caret was visually wrong. Tests that only asserted model state could not catch it.
The target architecture should make browser state a first-class proof owner:
decorate or child-count chunking.slate core React-first.editable.tsx comments or compatibility patches by accident.
Every comment must either move with its owner or be explicitly classified as
obsolete.Current hard cuts already landed:
Editable has no decorateEditor.* accessorseditor.apply monkeypatchingCurrent remaining weakness:
EditableDOMRoot still concentrates browser event handling and repair logic.Current concrete bug class:
insertText can leave DOM selection on an element wrapper
while the model selection is correctReactEditor.toSlateRange(...) can map that wrapper selection to the right
Slate range, so selection-sync can incorrectly think the DOM caret is already
fixedAll paths below are proposed under
.tmp/slate-v2/packages/slate-react/src/editable/.
selection-reconciler.tsOwns:
Does not own:
Key API:
type SelectionReconciler = {
captureDOMSelection(): DOMSelectionSnapshot | null;
syncDOMSelectionFromModel(options?: { force?: boolean }): DOMRange | null;
syncModelSelectionFromDOM(options?: { exactMatch?: boolean }): Range | null;
repairCaretAfterModelOperation(options: RepairCaretOptions): void;
isCanonicalDOMCaretForModelSelection(): boolean;
};
input-router.tsOwns:
beforeinputinputonBeforeInputkeydowncompositionstartcompositionupdatecompositionendpastedropcopycutDoes not own:
Key API:
type InputRouter = {
onDOMBeforeInput(event: InputEvent): void;
onDOMInput(event: InputEvent): void;
onKeyDown(event: React.KeyboardEvent<HTMLDivElement>): void;
onPaste(event: React.ClipboardEvent<HTMLDivElement>): void;
onDrop(event: React.DragEvent<HTMLDivElement>): void;
};
native-input-strategy.tsOwns when native browser input is allowed.
Rules currently embedded in editable.tsx that must move here:
insertText only for safe simple characterswhite-space: pre plus tab contentKey API:
type NativeInputStrategy = {
canUseNativeInput(context: NativeInputContext): NativeInputDecision;
};
model-input-strategy.tsOwns Slate-owned operations for browser edit intents.
Operation classes:
insert_textremove_textKey API:
type ModelInputStrategy = {
applyInputIntent(intent: InputIntent): void;
};
dom-repair-queue.tsOwns post-commit DOM repairs for model-owned operations.
This exists because some model-owned operations intentionally prevent native DOM mutation but still need the browser view/caret repaired after React commits.
Owns:
Key API:
type DOMRepairQueue = {
enqueue(operationClass: DOMRepairClass, options: DOMRepairOptions): void;
flushAfterCommit(): void;
};
browser-handle.tsOwns test/proof-only semantic control surface.
This is not app API.
Owns:
createRangeRefunrefRangeRefgetSelectiongetTextselectRangeinsertTextdeleteFragmentdeleteBackwarddeleteForwardinsertBreakinsertDataundoredoRules:
editable-dom-root.tsxOwns only the DOM root composition.
Responsibilities left in the component:
RestoreDOMAnything else belongs in one of the kernel modules above.
Every current editable.tsx behavior comment must move with its owner.
Move to editable-dom-root.tsx:
Rerender editor when composition status changedUpdate internal state on each render.The autoFocus TextareaHTMLAttribute doesn't do anything on a div...Update element-related weak maps with the DOM element ref.Allow positioning relative to the editable element.Preserve adjacent whitespace and new lines.Allow words to break if they are too long.Allow for passed-in styles to override anything.this magic zIndex="-1" will fix itImportant note:
Move to selection-reconciler.ts:
Listen on the native selectionchange event...React's onSelect is leaky and non-standard...Deselect the editor if the dom selection is not selectable in readonly modeMake sure the DOM selection state is in sync.If the DOM selection is properly unset, we're done.Get anchorNode and focusNodeCOMPAT: In firefox the normal selection way does not workverify that the dom selection is in the editorIf the DOM selection is in the editor and the editor selection is already correct, we're done.domSelection is not necessarily a valid Slate rangewhen <Editable/> is being controlled through external value...Otherwise the DOM selection is out of sync, so update it.Ignore, dom and state might be out of syncIn firefox if there is more then 1 range...Android IMEs try to force their selection...we force it again here... visible flickerGBoards spellchecker state...Leave browser selection unchanged if the DOM bridge is between commits.Move to input-router.ts:
Listen on the native beforeinput event to get real "Level 2" events...React's beforeinput is fake...Translate the DOM Range into a Slate RangeBeforeInput events aren't cancelable on android...#5038insertFromComposition order commentonBeforeInput commentsinput fallback commentsMove to native-input-strategy.ts:
Only use native character insertion for single characters a-z or space...Skip native if there are marks...Find the last text node inside the anchor.white-space: pre commentOnly insertText operations use the native functionality, for now.Potentially expand to single character deletes, as well.Move to model-input-strategy.ts:
repairDOMInput model-vs-DOM text mismatch logicRestore the actual user selection if nothing manually set it.Ensure we insert text with the marks the user was actually seeingMove to dom-repair-queue.ts:
insertTextFlush native operations... compare DOM text values... autocorrect and spellcheckSince beforeinput doesn't fully preventDefault... browser undo stackrepair DOM after model undo behavior from the history hotkey rowMove paste/copy/drop comments to input-router.ts plus strategy modules:
Move to browser-handle.ts:
__slateBrowserHandle setupNo horizontal rewrite.
Every phase follows:
Each user-path row must assert:
| Operation class | Required rows | Projects |
|---|---|---|
| Insert text before text | rich text before trailing punctuation; plain paragraph start | Chromium, WebKit; semantic handle on mobile/Firefox where transport is not the target |
| Insert text inside text | rich text middle leaf; plain text middle leaf | Chromium, WebKit |
| Insert text after text | end of first rich text block; end of plain block | Chromium, WebKit |
| Delete backward | before text, after text, around inline boundary, after DOM-owned sync | Chromium, WebKit, mobile semantic |
| Delete forward | before punctuation, before inline/void boundary, after DOM-owned sync | Chromium, WebKit, mobile semantic |
| Expanded range delete | same text node, cross leaf, cross block, inline/void included | Chromium, Firefox, WebKit, mobile semantic |
| Select text + add mark | select range, apply bold, assert model marks, DOM markup, caret after operation | Chromium, WebKit |
| Insert with active mark | set mark, type text, assert mark and caret | Chromium, WebKit, mobile semantic |
| Split block / Enter | plain paragraph, rich leaf boundary, after inline/void, shell-backed island | Chromium, Firefox, WebKit, mobile where transport is honest |
| Paste plain | collapsed and expanded selection | Chromium, Firefox, WebKit, mobile semantic |
| Paste rich HTML | collapsed and expanded selection | Chromium, Firefox, WebKit, mobile semantic where clipboard transport is limited |
| Paste Slate fragment | full document, partial shell-backed selection, inline/void fragment | Chromium, Firefox, WebKit, mobile semantic |
| IME composition | empty text, non-empty text, inline edge, void edge, DOM-owned opt-out | Chromium proxy/direct, Firefox direct where honest, WebKit proxy, mobile semantic/proxy |
| Undo/redo after native edit | native insert then undo/redo, DOM text and caret repaired | Chromium, WebKit |
| Undo/redo after model-owned edit | handle/model insert/delete/paste then undo/redo | All projects |
| Focus/blur selection restore | focus editor, focus void child/input, blur Safari, nested editable Firefox | Chromium, Firefox, WebKit |
| Shadow DOM editing | insert, delete, Enter, follow-up typing, selection rect | Chromium, Firefox, WebKit, mobile if honest |
Purpose:
Commands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
cd packages/slate-react && bun test:vitest
Acceptance:
Why first:
EditableDOMRootWork:
editable/browser-handle.ts__slateBrowserHandle type and setupTests:
Acceptance:
Why:
Tracer bullets:
Work:
editable/selection-reconciler.tstoSlateCollapsedRangeFromDOMSelectiontoSlateRangeFromDOMSelectionsyncDOMSelectionToEditorAcceptance:
Why:
insertTextTracer bullets:
Work:
editable/dom-repair-queue.tstext-inserttext-deleterange-deletesplit-nodepastehistoryselectionrequestAnimationFrame
scattered in input codeAcceptance:
onDOMBeforeInputWhy:
Tracer bullets:
Work:
editable/native-input-strategy.tsAcceptance:
Why:
Tracer bullets:
Work:
editable/model-input-strategy.tsAcceptance:
input-router maps event to intentmodel-input-strategy applies intentselection-reconciler repairs selectionEditableDOMRootWhy after strategies:
Work:
editable/input-router.tsbeforeinputinputonBeforeInputAcceptance:
Why separate:
Work:
input-router, selection-reconciler, and
useAndroidInputManagerAcceptance:
Target:
EditableDOMRoot should be mostly composition:
RestoreDOMAcceptance:
editable.tsx no longer contains browser input policyeditable.tsx no longer contains operation-specific logiceditable.tsx no longer contains canonical caret repair logicRequired gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts
bunx playwright test ./playwright/integration/examples/plaintext.test.ts
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts
bunx playwright test ./playwright/integration/examples/shadow-dom.test.ts
bunx playwright test ./playwright/integration/examples/mentions.test.ts
bunx playwright test ./playwright/integration/examples/markdown-shortcuts.test.ts
bunx playwright test ./playwright/integration/examples/paste-html.test.ts
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
cd packages/slate-react && bun test:vitest
bun run bench:react:rerender-breadth:local
bun run bench:react:huge-document-overlays:local
REACT_HUGE_COMPARE_BLOCKS=5000 REACT_HUGE_COMPARE_ITERATIONS=5 REACT_HUGE_COMPARE_TYPE_OPS=10 bun run bench:react:huge-document:legacy-compare:local
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
bun run lint
Final gate:
bun test:integration-local
Create shared Playwright helpers for caret proof:
assertModelTextassertModelSelectionassertVisibleTextassertDOMSelectionassertDOMCaretTextNodeassertVisualCaretRectNearassertOperationProofDo not hide assertions inside a single opaque helper. Helpers should return readable failure messages for:
slate-react before Playwright rows that consume package dist.next build.This plan is complete only when:
EditableDOMRoot is mostly a root shellStart Phase 1 with the browser handle extraction.
Do not start by moving the whole beforeinput switch. That is how this becomes
a risky rewrite instead of a controlled kernel extraction.
Status: in progress.
Current owner:
Next action:
__slateBrowserHandle
setup/cleanup into .tmp/slate-v2/packages/slate-react/src/editable/browser-handle.tsStatus: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/browser-handle.ts__slateBrowserHandle setup/cleanup, semantic handle methods, browser
handle range-ref bookkeeping, and cleanup into attachSlateBrowserHandleinsertText still skips force-render for direct DOM-synced text pathsselectRangeEditableDOMRoot to call attachSlateBrowserHandle(...)Changed files:
.tmp/slate-v2/packages/slate-react/src/editable/browser-handle.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxMoved comments:
Commands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|undoes inserted text|undo restores deleted selected text|visual caret"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
6 passed1 pass, 15 pass, 6 passslate-dom and slate-react: passedDecision:
Verdict: keep course.
Harsh take: the handle extraction is clean but low-risk. The real architecture work starts with selection reconciliation.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret"bunx playwright test ./playwright/integration/examples/shadow-dom.test.ts --project=chromiumbun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
editable/selection-reconciler.ts; do not move the layout effect yetDo-not-do list:
beforeinput switchStatus: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.tstoSlateCollapsedRangeFromDOMSelectiontoSlateRangeFromDOMSelectionEditableDOMRoot to import toSlateRangeFromDOMSelectionChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxMoved comments:
editable.tsx until the
layout effect/focus-sync owner movesCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret"
bunx playwright test ./playwright/integration/examples/shadow-dom.test.ts --project=chromium
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
3 passed3 passed1 passslate-dom and slate-react: passednext build lock before tests;
rerunning the command alone passedDecision:
Verdict: keep course.
Harsh take: selection conversion is extracted, but editable.tsx still owns
the dangerous selection layout effect. That is the next real cut.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret"bunx playwright test ./playwright/integration/examples/shadow-dom.test.ts --project=chromiumbunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=mobile --grep "DOM-owned text sync|directly synced"Next move:
editable/selection-reconciler.ts while preserving every selection comment
in the migration mapDo-not-do list:
beforeinput switchStatus: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.tssyncDOMSelectionToEditor into useEditableSelectionReconcilerEditableDOMRoot now calls useEditableSelectionReconciler(...) and
consumes only syncDOMSelectionToEditorbeforeinput, focus/blur handlers, and Android input manager internals
untouchedChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxMoved comments:
Update element-related weak maps with the DOM element ref.Make sure the DOM selection state is in sync.If the DOM selection is properly unset, we're done.Get anchorNode and focusNodeverify that the dom selection is in the editorIf the DOM selection is in the editor and the editor selection is already correct, we're done.Otherwise the DOM selection is out of sync, so update it.Leave browser selection unchanged if the DOM bridge is between commits.Commands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret"
bunx playwright test ./playwright/integration/examples/shadow-dom.test.ts --project=chromium
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=mobile --grep "DOM-owned text sync|directly synced"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
3 passed3 passed5 passed1 passslate-dom and slate-react: passednext build lock before tests;
sequential reruns passedRange import as type-only; fixed to a value
import and reran typecheck greenDecision:
Verdict: keep course.
Harsh take: selection reconciliation now has a real module. The next bug-prone
cluster is post-commit DOM repair, where the caret bug was patched ad hoc with
requestAnimationFrame.
Why:
beforeinput
switch, which is the next wrong ownerRisks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret"bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "undoes inserted text|repairs DOM after Mac keyboard undo"bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=mobile --grep "DOM-owned text sync|directly synced"Next move:
editable/dom-repair-queue.ts and move only the model-owned
insertText post-commit caret repair into itDo-not-do list:
beforeinput switchStatus: closed for model-owned insertText caret repair.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/dom-repair-queue.tsinsertText requestAnimationFrame caret
repair behind createDOMRepairQueue(...)insertText queues this repair in this sliceChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/dom-repair-queue.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxMoved comments:
Commands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|undoes inserted text|repairs DOM after Mac keyboard undo"
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=mobile --grep "DOM-owned text sync|directly synced"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
5 passed5 passed1 passslate-dom and slate-react: passednext build lock before tests;
sequential reruns passedDecision:
Verdict: keep course.
Harsh take: repair now has a home, but native-vs-model ownership is still
buried in the beforeinput switch. That is the next clean cut.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|inserts text through browser input"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=mobile --grep "DOM-owned text sync|directly synced"Next move:
editable/native-input-strategy.ts and move only the native
single-character insertText eligibility checks into itDo-not-do list:
beforeinput switchStatus: closed for native single-character insertText eligibility.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/native-input-strategy.tsinsertText eligibility cluster out of
EditableDOMRoota-z / space native inputwhite-space: pre / tab issueChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/native-input-strategy.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|inserts text through browser input"
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=mobile --grep "DOM-owned text sync|directly synced"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
4 passed5 passed1 passslate-dom and slate-react: passednext build lock before tests;
sequential rerun passedDecision:
Verdict: keep course.
Harsh take: native policy now has a module. The remaining beforeinput switch
still owns model operation application, so the next clean cut is model-owned
insertText intent application.
Why:
Risks:
insertText is coupled to DOM repair queue and
preferModelSelectionForInputRefEarliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|inserts text through browser input|undoes inserted text"bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=mobile --grep "DOM-owned text sync|directly synced"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
editable/model-input-strategy.ts and move only model-owned
insertText application into it, calling the DOM repair queue for the
post-commit caret repairDo-not-do list:
Status: closed for model-owned insertText.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.tsinsertText application into
applyModelOwnedTextInput(...)Editor.insertText(editor, data)domRepairQueue.repairCaretAfterModelTextInsert()preferModelSelectionForInputRef.current = true for insertTextEditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxMoved comments:
Commands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|inserts text through browser input|undoes inserted text"
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=mobile --grep "DOM-owned text sync|directly synced"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
5 passed5 passed1 passslate-dom and slate-react: passednext build lock before tests;
sequential rerun passedDecision:
Verdict: keep course.
Harsh take: model-owned insert is clean. The next input-operation cluster is delete backward/forward, because it is still in the monolithic switch and has caret/selection semantics distinct from insert.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "undo restores deleted selected text|undoes inserted text"bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=mobile --grep "deletes backward|deletes forward"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
model-input-strategy.ts with delete backward/forward intent
application onlyDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts
with applyModelOwnedDeleteIntent(...)EditableDOMRoot for a dedicated sliceChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "undo restores deleted selected text|undoes inserted text"
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=mobile --grep "deletes backward|deletes forward"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
2 passed2 passed1 passslate-dom and slate-react: passednext build lock before tests;
sequential rerun passedDecision:
deleteFragment application.Verdict: keep course.
Harsh take: collapsed delete is clean. Expanded range delete is the next dangerous branch because it overrides apparent backward/forward intent when a selection is expanded.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "undo restores deleted selected text"bunx playwright test ./playwright/integration/examples/highlighted-text.test.ts --project=chromium --grep "semantic selection|copies decorated text"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
Editor.deleteFragment application into
model-input-strategy.ts onlyDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts
with applyModelOwnedExpandedDelete(...)deleteContentBackward => backwardforwardChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "undo restores deleted selected text"
bunx playwright test ./playwright/integration/examples/highlighted-text.test.ts --project=chromium --grep "semantic selection|copies decorated text"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
1 passed2 passed1 passslate-dom and slate-react: passednext build lock before tests;
sequential rerun passedDecision:
Verdict: keep course.
Harsh take: delete is now cleaner. The next small model-owned branch is
insertLineBreak / insertParagraph, which owns split/Enter behavior.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/shadow-dom.test.ts --project=chromium --grep "new line"bunx playwright test ./playwright/integration/examples/markdown-shortcuts.test.ts --project=chromium --grep "can add a h1|can add list"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
insertLineBreak and insertParagraph application into
model-input-strategy.tsDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts
with applyModelOwnedLineBreak(...)insertLineBreak => Editor.insertSoftBreak(editor)insertParagraph => Editor.insertBreak(editor)Changed files:
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/shadow-dom.test.ts --project=chromium --grep "new line"
bunx playwright test ./playwright/integration/examples/markdown-shortcuts.test.ts --project=chromium --grep "can add a h1|can add list"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
1 passed2 passed1 passslate-dom and slate-react: passednext build lock before tests;
sequential rerun passedDecision:
Verdict: keep course.
Harsh take: the basic model operation classes are moving cleanly. Paste is the next high-risk owner because it mixes DataTransfer, Slate fragments, rich HTML, plain text, shell-backed selections, and browser transport limitations.
Why:
model-input-strategy.tsRisks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/paste-html.test.ts --project=chromiumbunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "paste"bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=mobile --grep "paste"Next move:
DataTransfer paste/drop text insertion application into
model-input-strategy.ts without changing transport fallback behaviorDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts
with applyModelOwnedDataTransferInput(...)DataTransfer application branch to the model input strategyEditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/paste-html.test.ts --project=chromium
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "paste"
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=mobile --grep "paste"
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
2 passed3 passed3 passedslate-dom and slate-react: passedDecision:
DataTransfer application is strategy-owned.Verdict: keep course.
Harsh take: model input strategy now owns most ordinary operation application. Composition is the next high-risk branch because browser event order differs by engine and comments already encode Safari/Chrome pitfalls.
Why:
Risks:
insertFromComposition ordering is fragileEarliest gates:
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "IME composition"bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
insertFromComposition state-reset behavior into
model-input-strategy.ts or a composition-specific helper, preserving the
Safari comment exactlyDo-not-do list:
Status: closed for insertFromComposition state reset.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/composition-state.tsinsertFromComposition composing-state reset into
commitInsertFromComposition(...)Changed files:
.tmp/slate-v2/packages/slate-react/src/editable/composition-state.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "IME composition"
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
1 passed1 passed1 passslate-dom and slate-react: passednext build lock before tests;
sequential rerun passedDecision:
insertFromComposition composing-state reset is isolated.Verdict: keep course.
Harsh take: composition state reset now has a home. The remaining composition logic is still split, and the Chrome compositionend fallback is the next cohesive branch.
Why:
EditableDOMRootRisks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "IME composition"bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
composition-state.ts, preserving marks behavior and commentsDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/composition-state.ts
with commitChromeCompositionEndFallback(...)EditableDOMRootinsertFromComposition beforeinput shape needed
by the main pathcompositionendChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/composition-state.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "IME composition"
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
1 passed1 passed1 passslate-dom and slate-react: passedDecision:
Verdict: keep course.
Harsh take: model, paste, and composition application are mostly out of the root switch. History is the last obvious model-owned branch before router-level extraction starts.
Why:
handleNativeHistoryEvents and hotkey history fallback still own model
history application in EditableDOMRootRisks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "undoes inserted text|repairs DOM after Mac keyboard undo"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
model-input-strategy.ts or a
dedicated history helper, preserving DOM repair behaviorDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts
with applyModelOwnedHistoryIntent(...)EditableDOMRootEditableDOMRootforceRender() after successful keyboard undo/redoChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "undoes inserted text|repairs DOM after Mac keyboard undo"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
2 passed1 passslate-dom and slate-react: passedDecision:
Verdict: pivot.
Harsh take: model input application now has a real module. The next remaining monolith problem is event routing itself.
Why:
Risks:
beforeinput and input; that is the safest
first router sliceEarliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|inserts text through browser input"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --forceNext move:
editable/input-router.ts and move only root native listener
attach/detach handling from callbackRef, not the beforeinput logic itselfDo-not-do list:
beforeinput callbackStatus: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.tsattachEditableNativeInputListeners(...)onBeforeInput is a leaky polyfillbeforeinput is attached directlyinput listener is attached next to itonDOMBeforeInput and onDOMInput callback bodies in
EditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|inserts text through browser input"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
4 passed1 passslate-dom and slate-react: passedDecision:
input callback routing.Verdict: keep course.
Harsh take: native listener plumbing is out. The next safe router slice is
onDOMInput, because it is smaller than beforeinput and already delegates
actual repair to repairDOMInput.
Why:
onDOMInput is a small callback that can become the first true router-owned
native event handlerRisks:
input is tied to DOM repair after browser mutationrepairDOMInput in the same slice unless required by typesEarliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|inserts text through browser input"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
onDOMInput callback creation into input-router.tsDo-not-do list:
onDOMBeforeInputrepairDOMInput unless the input router helper requires itStatus: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts
with useEditableDOMInputHandler(...)input callback creation out of EditableDOMRootrepairDOMInput(...) in EditableDOMRootbeforeinput callback body in EditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|inserts text through browser input"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
4 passed1 passslate-dom and slate-react: passedDecision:
input callback creation is router-owned.onBeforeInput wrapper creation.Verdict: keep course.
Harsh take: native input routing is now half-extracted. The remaining easy
router work is wrapper creation for React fallback handlers; the native
beforeinput body is still too large to move in one piece.
Why:
input-router.tsonBeforeInput is a small wrapper around onDOMBeforeInputRisks:
beforeinput; keep its
behavior identicalEarliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
onBeforeInput fallback wrapper creation into
input-router.tsDo-not-do list:
onDOMBeforeInputStatus: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts
with useEditableReactBeforeInputHandler(...)onBeforeInput wrapper creation out of
EditableDOMRootbeforeinputinsertTextonDOMBeforeInput callback body in EditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
1 passed1 passslate-dom and slate-react: passedDecision:
onBeforeInput fallback wrapper is router-owned.Verdict: keep course.
Harsh take: input-router now owns simple native/input wrappers. Paste handler creation is next, but paste semantics must stay where they are until a focused paste-strategy slice moves them.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/paste-html.test.ts --project=chromiumbunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "paste"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
input-router.ts, keeping paste
semantics and comments intact if possibleDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts
with useEditablePasteHandler(...)EditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/paste-html.test.ts --project=chromium
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "paste"
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=mobile --grep "paste"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
2 passed3 passed3 passed1 passslate-dom and slate-react: passedDecision:
EditableDOMRoot until a focused paste-strategy
slice moves them.Verdict: keep course.
Harsh take: paste routing is now shaped, but the core paste behavior is still too loaded to move casually. The right next bite is copy/cut wrapper extraction, not rewriting clipboard semantics.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/paste-html.test.ts --project=chromiumbunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "paste"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
input-router.ts, keeping fragment
serialization semantics in EditableDOMRootDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts
with useEditableClipboardHandler(...)EditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/highlighted-text.test.ts --project=chromium
bun test ./packages/slate-dom/test/clipboard-boundary.ts --bail 1
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
3 passed6 pass1 passslate-dom and slate-react: passedDecision:
EditableDOMRoot until a focused clipboard
strategy slice moves them.Verdict: keep course.
Harsh take: clipboard event wrappers are out, but drag/drop is still sitting in the root. Extract wrapper creation only; do not pretend that makes drop semantics clean.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/voids.test.ts --project=chromiumbunx playwright test ./playwright/integration/examples/paste-html.test.ts --project=chromiumbun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
input-router.ts, keeping drag/drop
semantics in EditableDOMRootDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts
with useEditableDragHandler(...)EditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/editable-voids.test.ts --project=chromium
bunx playwright test ./playwright/integration/examples/paste-html.test.ts --project=chromium
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
4 passed2 passed1 passslate-dom and slate-react: passedDecision:
EditableDOMRoot until a focused drag/drop
strategy slice moves them.Verdict: keep course.
Harsh take: wrapper extraction is working because it is intentionally boring. Keep it boring. The next dangerous inline cluster is composition; move wrapper creation only and keep IME semantics exactly where they are.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "IME"bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
input-router.ts, keeping
composition semantics in EditableDOMRootDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts
with useEditableCompositionHandler(...)EditableDOMRootIS_COMPOSING updates, expanded
selection deletion, and Chrome composition-end fallback behaviorChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "IME"
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
14 passed4 passed1 passslate-dom and slate-react: passedDecision:
EditableDOMRoot until a focused composition
strategy slice moves them.onInput wrapper creation.Verdict: keep course.
Harsh take: composition routing is safer, but the root still has a chunky
React onInput handler mixing app callback, Android manager, deferred native
op flushing, and selector repair. Extract wrapper creation only.
Why:
Risks:
onInput is tied to Android input manager and deferred operation flushEarliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
onInput handler creation into input-router.ts, keeping
input semantics in EditableDOMRootDo-not-do list:
onInput semanticsStatus: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts
with useEditableInputHandler(...)onInput handler callback creation through the input routeronInput semantics in EditableDOMRootonInput handling, Android input manager dispatch, deferred
native operation flushing, model insertion repair, handledDOMBeforeInputRef
reset, and native-history repair behaviorChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
4 passed1 passslate-dom and slate-react: passedDecision:
onInput event wrapper creation is router-owned.onInput semantics stay in EditableDOMRoot until a focused input
strategy slice moves them.onInputCapture wrapper creation.Verdict: keep course.
Harsh take: onInput is shaped, but onInputCapture still manually schedules
DOM repair in the JSX. Extract the wrapper, not the repair queue.
Why:
Risks:
onInputCapture is directly tied to post-native DOM repair timingEarliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
onInputCapture handler creation into input-router.ts, keeping
repair scheduling semantics in EditableDOMRootDo-not-do list:
repairDOMInputStatus: closed.
Actions:
useEditableInputHandler(...) for onInputCaptureonInputCapture handler callback creation through the input routerEditableDOMRootsetTimeout timing and { data, inputType } payload shaperepairDOMInputChanged files:
.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
4 passed1 passslate-dom and slate-react: passedDecision:
onInputCapture event wrapper creation is router-owned.EditableDOMRoot until the DOM repair
queue owner moves them.Verdict: keep course.
Harsh take: root input wrappers are mostly out. Focus/blur is next, but it has real browser compatibility landmines, so move handler creation only and preserve every comment with the behavior.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/editable-voids.test.ts --project=chromiumbunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
input-router.ts, keeping
focus/blur semantics and compatibility comments in EditableDOMRootDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts
with useEditableFocusHandler(...)EditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/editable-voids.test.ts --project=chromium
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
4 passed4 passed1 passslate-dom and slate-react: passedDecision:
EditableDOMRoot until a focused focus policy
slice moves them.Verdict: keep course.
Harsh take: focus/blur moved safely because only the hook identity moved. Click and mouse-down still belong to routing shape, but click contains selection policy and must not be rewritten in this slice.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|types at the browser-selected end"bunx playwright test ./playwright/integration/examples/editable-voids.test.ts --project=chromiumbun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
input-router.ts, keeping
click/mouse-down semantics in EditableDOMRootDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts
with useEditableMouseHandler(...)EditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|types at the browser-selected end"
bunx playwright test ./playwright/integration/examples/editable-voids.test.ts --project=chromium
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
4 passed4 passed1 passslate-dom and slate-react: passedDecision:
EditableDOMRoot until a focused mouse
selection policy slice moves them.Verdict: keep course.
Harsh take: almost every small wrapper is gone. Keydown is the monster. Move only handler identity; do not rewrite hotkey, history, deletion, or selection policy in this slice.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "undo|types at the browser-selected end|visual caret"bunx playwright test ./playwright/integration/examples/markdown-shortcuts.test.ts --project=chromium --grep "can add a h1|can add list"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
input-router.ts, keeping keydown
semantics in EditableDOMRootDo-not-do list:
beforeinput in the same sliceStatus: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts
with useEditableKeyboardHandler(...)EditableDOMRootbeforeinput or selection reconciliationChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "undo|types at the browser-selected end|visual caret"
bunx playwright test ./playwright/integration/examples/markdown-shortcuts.test.ts --project=chromium --grep "can add a h1|can add list"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
7 passed2 passed1 passslate-dom and slate-react: passedDecision:
EditableDOMRoot until focused keyboard strategy
slices move them.beforeinput wrapper creation.Verdict: keep course.
Harsh take: keydown is no longer inline JSX, but the root still owns native
beforeinput callback creation. Rename the semantic callback and wrap it;
do not move the actual native input policy.
Why:
Risks:
beforeinput is the hottest browser-policy ownerEarliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|undo|visual caret"bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "directly synced|IME|delete"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
beforeinput handler creation into input-router.ts,
keeping native input semantics in EditableDOMRootDo-not-do list:
beforeinput semanticsStatus: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts
with useEditableDOMBeforeInputHandler(...)beforeinput callback to
handleDOMBeforeInputbeforeinput handler callback creation through the input routerEditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|undo|visual caret"
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "directly synced|IME|delete"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
7 passed14 passed1 passslate-dom and slate-react: passedDecision:
beforeinput event wrapper creation is router-owned.EditableDOMRoot until focused native/model
strategy slices move it.Verdict: pivot.
Harsh take: the wrapper phase has hit diminishing returns. The remaining root size is no longer because JSX has inline handlers; it is because durable owners still live inside the root. Next owner is selection reconciliation.
Why:
useCallback event handlers remainbeforeinput, keydown, input, clipboard, drag/drop, composition,
focus, blur, click, and mouse-down wrapper creation is routedRisks:
onSelect compatibility comment
must move with behaviorEarliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|types at the browser-selected end"bunx playwright test ./playwright/integration/examples/shadow-dom.test.ts --project=chromiumbun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
selection-reconciler.ts, keeping semantics and comments intactDo-not-do list:
Status: closed.
Actions:
attachEditableSelectionChangeListener(...) to
.tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.tsselectionchange listener attachment and Chrome
input/textarea filtering out of EditableDOMRootonSelect compatibility comment with the listener ownerEditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|types at the browser-selected end"
bunx playwright test ./playwright/integration/examples/shadow-dom.test.ts --project=chromium
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
4 passed3 passed1 passslate-dom and slate-react: passedDecision:
Verdict: keep course.
Harsh take: selectionchange is in the right module. The root still owns the global dragend/drop lifecycle listener only because it was adjacent. Move that small lifecycle owner next.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/editable-voids.test.ts --project=chromiumbunx playwright test ./playwright/integration/examples/paste-html.test.ts --project=chromiumbun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
input-router.ts, preserving Firefox compatibility commentsDo-not-do list:
Status: closed.
Actions:
attachEditableGlobalDragLifecycleListeners(...) to
.tmp/slate-v2/packages/slate-react/src/editable/input-router.tsdragend/drop lifecycle listener attachment out of
EditableDOMRootEditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/editable-voids.test.ts --project=chromium
bunx playwright test ./playwright/integration/examples/paste-html.test.ts --project=chromium
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
4 passed2 passed1 passslate-dom and slate-react: passedDecision:
EditableDOMRoot until focused
drag/drop strategy slices move them.Verdict: replan.
Harsh take: the root is cleaner, but this is still not the final architecture. We moved event shells; the real remaining work is semantic owner migration and operation coverage.
Why:
Risks:
Earliest gates:
rg -n "const handle[A-Z].*= useCallback|useIsomorphicLayoutEffect|useEffect\\(" packages/slate-react/src/components/editable.tsxbunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|undo|types at the browser-selected end"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
EditableDOMRoot owner clusters and choose the next
semantic extraction slice with proof gates before editingDo-not-do list:
Status: closed.
Actions:
EditableDOMRoot callback/effect owners after wrapper
extractioncomponents/editable.tsx: 2090 lineseditable/input-router.ts: 260 lineseditable/selection-reconciler.ts: 404 lineseditable/model-input-strategy.ts: 98 lineseditable/native-input-strategy.ts: 90 lineseditable/dom-repair-queue.ts: 15 linesbeforeinput policyCommand:
rg -n "const handle[A-Z].*= useCallback|const on[A-Z].*= useEditable|useIsomorphicLayoutEffect|useEffect\\(|const callbackRef|const repairDOMInput|const syncDOMSelectionToEditor|const onDOMSelectionChange|const scheduleOnDOMSelectionChange|const domRepairQueue|const \\{ marks \\}" packages/slate-react/src/components/editable.tsx
wc -l packages/slate-react/src/components/editable.tsx packages/slate-react/src/editable/input-router.ts packages/slate-react/src/editable/selection-reconciler.ts packages/slate-react/src/editable/model-input-strategy.ts packages/slate-react/src/editable/native-input-strategy.ts packages/slate-react/src/editable/dom-repair-queue.ts
Decision:
beforeinput policy.repairDOMInput(...) is isolated, already protected by visual-caret and
DOM-sync gates, and belongs in dom-repair-queue.ts.beforeinput policy is larger and should wait until DOM repair has a
clearer module boundary.Verdict: pivot.
Harsh take: moving native beforeinput policy next would be macho refactoring,
not engineering. DOM repair is the cleaner next owner and directly attacks the
caret class that started this lane.
Why:
repairDOMInput(...) is self-containeddom-repair-queue.ts is currently too thin for its intended ownershipRisks:
onInputCapture must stay unchangedEarliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
repairDOMInput(...) from EditableDOMRoot into
dom-repair-queue.ts, keeping call timing and behavior unchangedDo-not-do list:
beforeinput policyStatus: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/dom-repair-queue.ts
so DOMRepairQueue owns repairDOMInput(...)EditableDOMRootinput and React onInputCapture repair paths through
domRepairQueue.repairDOMInput(...)onInputCapture timeout timing and payload shapebeforeinput policy or selection reconciliationChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/dom-repair-queue.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
4 passed1 passslate-dom and slate-react: passedDecision:
beforeinput semantic split, but it
should start with a focused sub-slice, not a bulk move.Verdict: replan.
Harsh take: the easy root shrink is done. The next block is the real beast:
native beforeinput policy. Moving it whole would be dumb. Split it by
sub-owner and prove each cut.
Why:
handleDOMBeforeInputRisks:
beforeinput extraction will regress typing, delete, paste,
composition, and visual caret at onceEarliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|undo|visual caret"bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "directly synced|IME|delete|paste"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
beforeinput by owner; first candidate is extracting
target-range/model-selection synchronization into selection-reconciler.ts
only if the extracted helper has the same inputs/outputs and no policy driftDo-not-do list:
beforeinput at onceStatus: closed.
Actions:
syncSelectionForBeforeInput(...) to
.tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.tsbeforeinput target-range selection synchronization out of
EditableDOMRootEditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|undo|visual caret"
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "directly synced|IME|delete|paste"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
7 passed14 passed1 passslate-dom and slate-react: passedDecision:
beforeinput selection synchronization is
selection-reconciler-owned.beforeinput owner is model-input operation dispatch.Verdict: keep course.
Harsh take: this was the right first semantic cut. The remaining
beforeinput block is smaller, and the switch-based operation dispatch belongs
in model-input-strategy.ts.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|undo|visual caret"bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "directly synced|IME|delete|paste"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
beforeinput operation dispatch switch into
model-input-strategy.ts with explicit inputs for data, native,
inputType, selection, domRepairQueue, and composition stateDo-not-do list:
Status: closed.
Actions:
applyModelOwnedBeforeInputOperation(...) to
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.tsbeforeinput operation dispatch out of EditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|undo|visual caret"
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "directly synced|IME|delete|paste"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
7 passed14 passed1 passslate-dom and slate-react: passedDecision:
beforeinput operation dispatch is model-input-strategy-owned.native-input-strategy.ts.Verdict: keep course.
Harsh take: handleDOMBeforeInput is finally mostly orchestration. The next
honest cut is native eligibility/composition gating, not clipboard or keydown.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "directly synced|IME"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
beforeinput eligibility and composition-change decision into
native-input-strategy.ts, preserving current input policyDo-not-do list:
Status: closed.
Actions:
getNativeBeforeInputDecision(...) to
.tmp/slate-v2/packages/slate-react/src/editable/native-input-strategy.tsbeforeinput input type/data extraction, composition-change
detection, composition abort decision, and native insert eligibility out of
EditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/native-input-strategy.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "directly synced|IME"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
4 passed14 passed1 passslate-dom and slate-react: passedDecision:
beforeinput eligibility is native-input-strategy-owned.components/editable.tsx size after this cut: 1810 lines.Verdict: keep course.
Harsh take: handleDOMBeforeInput is much closer to orchestration, but it
still directly restores EDITOR_TO_USER_SELECTION. That is selection
reconciler work.
Why:
selection-reconciler.tsRisks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|types at the browser-selected end|undo"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
selection-reconciler.tsDo-not-do list:
Status: closed.
Actions:
restoreUserSelectionAfterBeforeInput(...) to
.tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.tsEditableDOMRootEDITOR_TO_USER_SELECTION unref/delete behavior and
live-selection comparison before Transforms.select(...)beforeinput policy in this sliceChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|types at the browser-selected end|undo"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
7 passed1 passslate-dom and slate-react: passedDecision:
Status: closed.
Actions:
applyModelOwnedNativeHistoryEvent(...) to
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.tshistoryUndo/historyRedo native input handling out of
EditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "undo|visual caret"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
6 passed1 passslate-dom and slate-react: passedDecision:
Verdict: keep course.
Harsh take: the root now has one obvious preflight wart left before the normal beforeinput flow: the WebKit Shadow DOM processing branch. That is selection bridge behavior, not root behavior.
Why:
handleDOMBeforeInput still owns a self-contained WebKit ShadowRoot
target-range-to-Slate-range repair branchRisks:
stopImmediatePropagation; changing return semantics would
break Shadow DOM input behaviorEarliest gates:
bunx playwright test ./playwright/integration/examples/shadow-dom.test.ts --project=chromiumbunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
selection-reconciler.tsDo-not-do list:
preventDefault / stopImmediatePropagation behaviorStatus: closed.
Actions:
handleWebKitShadowDOMBeforeInput(...) to
.tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.tsbeforeinput target-range repair branch out of
EditableDOMRootpreventDefault() and stopImmediatePropagation() behaviorbeforeinput orchestrationChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/shadow-dom.test.ts --project=chromium
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
3 passed4 passed1 passWindow.Range typing in
selection-reconciler.ts; fixed by typing the window input as
Window & typeof globalThisslate-dom and slate-reactDecision:
Verdict: keep course.
Harsh take: the Shadow DOM preflight is out. The next leftover root effect is
pending insertion marks; it is explicitly composition-state behavior and should
not live in EditableDOMRoot.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "IME"bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
composition-state.tsDo-not-do list:
Status: closed.
Actions:
usePendingInsertionMarksEffect(...) to
.tmp/slate-v2/packages/slate-react/src/editable/composition-state.tsEditableDOMRootsetTimeout timing, loose Text.equals(...) mark comparison,
and EDITOR_TO_PENDING_INSERTION_MARKS set/delete behaviorChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/composition-state.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "IME"
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
14 passed4 passed1 passslate-dom and slate-react: passedDecision:
EditableDOMRoot.Verdict: keep course.
Harsh take: the root is materially smaller and less filthy, but still not the final architecture. Clipboard/paste/drop semantics remain in the root and are the next meaningful owner.
Why:
EditableDOMRootRisks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/paste-html.test.ts --project=chromiumbunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "paste"bunx playwright test ./playwright/integration/examples/highlighted-text.test.ts --project=chromiumbun test ./packages/slate-dom/test/clipboard-boundary.ts --bail 1bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
Do-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/clipboard-input-strategy.tsEditableDOMRoot into
applyEditablePaste(...)ReactEditor.insertData(...)onPaste fallback policy for unsupported beforeinput,
paste-without-formatting, and Safari missing Slate fragment itemsEditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/clipboard-input-strategy.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/paste-html.test.ts --project=chromium
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "paste"
bunx playwright test ./playwright/integration/examples/highlighted-text.test.ts --project=chromium
bun test ./packages/slate-dom/test/clipboard-boundary.ts --bail 1
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
2 passed3 passed3 passed6 pass1 passslate-dom and slate-react: passedDecision:
Verdict: keep course.
Harsh take: paste is finally out of the root. Copy/cut is the obvious next slice: same owner, narrower behavior, good decorated-copy proof.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/highlighted-text.test.ts --project=chromiumbun test ./packages/slate-dom/test/clipboard-boundary.ts --bail 1bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
clipboard-input-strategy.ts, preserving
fragment serialization and cut deletion behaviorDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/clipboard-input-strategy.ts
with applyEditableCopy(...) and applyEditableCut(...)EditableDOMRootReactEditor.setFragmentData(...)Changed files:
.tmp/slate-v2/packages/slate-react/src/editable/clipboard-input-strategy.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/highlighted-text.test.ts --project=chromium
bun test ./packages/slate-dom/test/clipboard-boundary.ts --bail 1
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
3 passed6 pass1 passslate-dom and slate-react: passedKnown gap:
Decision:
Verdict: keep course.
Harsh take: copy/cut is out, but drag/drop still sits in the root. Move it next with void/editor proof; do not pretend that covers all native DnD weirdness.
Why:
EditableDOMRootRisks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/editable-voids.test.ts --project=chromiumbunx playwright test ./playwright/integration/examples/paste-html.test.ts --project=chromiumbun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
clipboard-input-strategy.ts, preserving void
drag selection, internal drag deletion, DataTransfer insertion, and focus
repairDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/clipboard-input-strategy.ts
with drag/drop strategy functions:
applyEditableDragEnd(...)applyEditableDragOver(...)applyEditableDragStart(...)applyEditableDrop(...)EditableDOMRootinput-router.tsChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/clipboard-input-strategy.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/editable-voids.test.ts --project=chromium
bunx playwright test ./playwright/integration/examples/paste-html.test.ts --project=chromium
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
4 passed2 passed1 passslate-dom and slate-react: passedDecision:
Verdict: pivot.
Harsh take: we can keep shrinking the root, but the sharper next move is coverage. Copy/cut moved with unit/adjacent proof, and cut deserves a browser row before more churn.
Why:
Risks:
onCut path, fragment serialization, and model deletionEarliest gates:
bunx playwright test ./playwright/integration/examples/highlighted-text.test.ts --project=chromiumbun test ./packages/slate-dom/test/clipboard-boundary.ts --bail 1bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
Do-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/playwright/integration/examples/highlighted-text.test.tsControlOrMeta+X, instead of a
synthetic ClipboardEventlpha betaa.tmp/slate-v2/packages/slate-react/src/editable/clipboard-input-strategy.ts
so expanded-selection cut preserves a point ref, deletes the fragment,
restores the collapsed model selection, and syncs DOM focus/selectionclipboard.assert.types(...) assertion because it
performs another copy after cut and perturbs the collapsed selectionChanged files:
.tmp/slate-v2/playwright/integration/examples/highlighted-text.test.ts.tmp/slate-v2/packages/slate-react/src/editable/clipboard-input-strategy.tsCommands:
bunx playwright test ./playwright/integration/examples/highlighted-text.test.ts --project=chromium --grep "cuts decorated"
bunx playwright test ./playwright/integration/examples/highlighted-text.test.ts --project=chromium
bunx playwright test ./playwright/integration/examples/editable-voids.test.ts ./playwright/integration/examples/paste-html.test.ts --project=chromium
bun test ./packages/slate-dom/test/clipboard-boundary.ts --bail 1
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
null; this exposed a real selection/caret safety gapReactEditor.focus(...)
after cut fixed the real shortcut path4 passed6 passed6 pass1 passslate-dom and slate-react: passedDecision:
Verdict: keep course.
Harsh take: the cut row caught exactly the kind of regression the old model-only
proof missed. Clipboard strategy is now in a much better place. Next, move
composition event semantics out of EditableDOMRoot.
Why:
EditableDOMRoot is down to 1636 linesRisks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "IME"bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
composition-state.ts
without changing Android, Chrome fallback, or expanded-selection behaviorDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/composition-state.ts
with composition event strategy functions:
applyEditableCompositionEnd(...)applyEditableCompositionStart(...)applyEditableCompositionUpdate(...)EditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/composition-state.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "IME"
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
14 passed4 passed1 passslate-dom and slate-react: passedDecision:
EditableDOMRoot is now 1606 lines.Verdict: keep course.
Harsh take: composition is out. Do not jump into keydown yet; focus/click selection policy is smaller, browser-sensitive, and belongs with selection reconciliation.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/editable-voids.test.ts --project=chromiumbunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|types at the browser-selected end"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
selection-reconciler.tsDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.ts
with focus/click selection policy functions:
applyEditableBlur(...)applyEditableFocus(...)applyEditableClick(...)applyEditableMouseDown(...)EditableDOMRootChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/editable-voids.test.ts --project=chromium
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "visual caret|types at the browser-selected end"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
4 passed4 passed1 passslate-dom and slate-react: passedDecision:
EditableDOMRoot is now 1496 lines.Verdict: keep course.
Harsh take: this leaves keydown as the obvious large browser-policy block. Move it next, but as keyboard policy, not random helper soup.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "undo|types at the browser-selected end|visual caret"bunx playwright test ./playwright/integration/examples/markdown-shortcuts.test.ts --project=chromium --grep "can add a h1|can add list"bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "activates shells by keyboard|directly synced"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
model-input-strategy.ts, preserving all hotkey behaviorDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/keyboard-input-strategy.tsEditableDOMRootnativeEvent.isComposing === falseonKeyDown handlingbeforeinput is unavailableChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/keyboard-input-strategy.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "undo|types at the browser-selected end|visual caret"
bunx playwright test ./playwright/integration/examples/markdown-shortcuts.test.ts --project=chromium --grep "can add a h1|can add list"
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "activates shells by keyboard|directly synced"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
7 passed2 passed5 passed1 passslate-dom and slate-react: passedDecision:
EditableDOMRoot is now 1162 lines.Verdict: keep course.
Harsh take: root has dropped below 1200 lines. The next cut should not be more editing policy; it should be root ref/native listener lifecycle, which is plumbing still sitting in the root.
Why:
Risks:
beforeinput/input listeners or
break weak-map registrationEarliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --forcebunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --forceNext move:
input-router.tsDo-not-do list:
Status: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts
with useEditableRootRef(...)EditableDOMRootbeforeinput/input listener attach/detachforwardedRef may be undefinedChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/input-router.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
4 passed1 passforwardedRef can be undefined; widened the
helper typeslate-dom and slate-reactDecision:
EditableDOMRoot is now 1133 lines.onInput strategy extraction.Verdict: keep course.
Harsh take: root lifecycle is out. The remaining root still owns React
onInput semantics, including Android input dispatch, deferred native op
flush, DOM text repair, and native history repair. That belongs in an input
strategy owner.
Why:
handleInput(...) is self-contained and has direct caret/browser
gatesbeforeinput
orchestrationRisks:
onInput repairs visible DOM/model drift, so browser-visible proof is
mandatoryEarliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret|undo"bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
onInput strategy into model-input-strategy.ts or a dedicated
input strategy helper, preserving Android/deferred/native-history behaviorDo-not-do list:
onInput orderingbeforeinput orchestration in the same sliceStatus: closed.
Actions:
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts
with applyEditableInput(...)onInput semantics out of EditableDOMRootonInput handling firsthandledDOMBeforeInputRef resetbeforeinput orchestrationChanged files:
.tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsxCommands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|visual caret|undo"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Evidence:
7 passed1 passslate-dom and slate-react: passedDecision:
onInput semantics are model-input-strategy-owned.EditableDOMRoot is now 1066 lines.beforeinput orchestration extraction or a final
closure-gate decision if the remaining root is accepted as orchestration.Verdict: replan.
Harsh take: most real event policy is extracted. The remaining root is mostly
orchestration wrappers, but native beforeinput still has a meaningful
orchestration block. Decide whether to extract that final block or classify it
as the root coordinator.
Why:
EditableDOMRoot is now substantially smaller and mostly delegates behaviorhandleDOMBeforeInput(...), plus event wrapper
callbacks that now call strategy functionsRisks:
beforeinput orchestration will pass a lot of dependencies and may
make the boundary uglier than the current coordinator shapeEarliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "inserts text through browser input|undo|visual caret"bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "directly synced|IME|delete|paste"bunx playwright test ./playwright/integration/examples/highlighted-text.test.ts --project=chromiumbun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1Next move:
handleDOMBeforeInput(...) and decide whether to extract it into a
native input coordinator helper or explicitly keep it as root orchestration
before running final gatesDo-not-do list:
beforeinput blindly if the helper shape is worse than the root
coordinatorStatus: closed.
Decision:
handleDOMBeforeInput(...) in EditableDOMRoot as the root
coordinator.Why:
Current root size:
.tmp/slate-v2/packages/slate-react/src/components/editable.tsx: 1066
linesNext move:
Do-not-do list:
Verdict: keep course.
Harsh take: the architecture refactor is at the point where more blind extraction is negative value. Run the final gates. If they pass, close the lane. If they expose a regression, fix the owner.
Why:
Risks:
Earliest gates:
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/highlighted-text.test.ts ./playwright/integration/examples/editable-voids.test.ts ./playwright/integration/examples/paste-html.test.ts ./playwright/integration/examples/large-document-runtime.test.ts ./playwright/integration/examples/shadow-dom.test.ts ./playwright/integration/examples/markdown-shortcuts.test.ts --project=chromiumbun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1bun run lintbunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --forcebunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --forceNext move:
Do-not-do list:
Status: closed.
Actions:
beforeinput in EditableDOMRoot as the root coordinator rather
than extracting a worse mega-helperFinal root size:
.tmp/slate-v2/packages/slate-react/src/components/editable.tsx: 1066
linesFinal gate commands:
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/highlighted-text.test.ts ./playwright/integration/examples/editable-voids.test.ts ./playwright/integration/examples/paste-html.test.ts ./playwright/integration/examples/large-document-runtime.test.ts ./playwright/integration/examples/shadow-dom.test.ts ./playwright/integration/examples/markdown-shortcuts.test.ts --project=chromium
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
Final evidence:
39 passed1 pass15 pass6 passslate-dom and slate-react: passedDecision:
Verdict: stop.
Harsh take: this lane is done. Keeping the loop alive now would be fake motion.
Why:
Risks:
Earliest gates:
Next move:
Do-not-do list: