docs/plans/2026-05-14-slate-v2-callback-memoization-dx-ralplan.md
Date: 2026-05-14
Status: executed
Score: 1.00
Owner: Slate Ralplan planning only
Execution owner: ralph in .tmp/slate-v2
Hard cut onDOMBeforeInput as a normal customization/example path.
Keep onDOMBeforeInput only as the raw native escape hatch:
onDOMBeforeInput?: (
event: InputEvent,
context: EditableDOMBeforeInputContext
) => boolean | void
The normal Slate React editing architecture should be:
native DOM event
-> Slate React native listener router
-> input kernel classifies intent/command/selection policy
-> semantic editable command handlers run
-> model-owned default applies or native browser path continues
-> DOM repair / selection export runs
formatBold, formatItalic, historyUndo, delete, paste, and text insertion
must not be taught through ad hoc onDOMBeforeInput examples. That is the old
Slate leak.
onDOMBeforeInput stays because Slate is unopinionated and native-friendly.
But the contract is explicit:
InputEventtrue or calling preventDefault() marks it handledTarget context:
type EditableDOMBeforeInputContext = {
command: EditableInputCommand | null;
data: unknown;
editor: ReactEditor;
inputType: string;
intent: EditableInputIntent | null;
native: boolean;
selection: Range | null;
};
Add a semantic handler for editor behavior that should not depend on DOM event spelling:
onCommand?: (
command: EditableInputCommand,
context: EditableCommandContext
) => boolean | EditableRepairRequest | void
This is the path for:
formatBoldmod+bTarget formatting command shape:
type EditableInputCommand =
| { kind: 'format'; format: 'bold' | 'italic' | 'underline' | 'strikethrough' | string }
| { kind: 'history'; direction: 'redo' | 'undo' }
| { kind: 'delete'; direction: 'backward' | 'forward'; unit?: 'block' | 'line' | 'word' }
| { kind: 'insert-break'; variant: 'open-line' | 'paragraph' | 'soft' }
| { kind: 'insert-text'; inputType?: string; text: string }
| { kind: 'insert-data'; data: DataTransfer }
| ...
Slate can report format commands without deciding whether the app uses
bold, strong, fontWeight, or a custom mark model. That preserves Slate's
unopinionated core.
Current hovering-toolbar should stop doing this:
onDOMBeforeInput={(event) => {
if (event.inputType === 'formatBold') {
event.preventDefault()
return toggleMark(editor, 'bold')
}
}}
Target:
<Editable
onCommand={(command, { editor }) => {
if (command.kind !== "format") {
return;
}
switch (command.format) {
case "bold":
case "italic":
case "underline":
editor.update((tx) => {
tx.marks.toggle(command.format);
});
return true;
}
}}
/>
If the execution pass chooses not to add onCommand yet, then the
fallback is onDOMBeforeInput(event, context), not a closure-only event prop and
never useMemo(() => callback).
Slate should expose the native event when the user asks for native browser input. It should not force every behavior through an opinionated plugin product.
But "unopinionated" does not mean "make users parse browser quirks." Raw DOM events belong at the boundary. Model behavior belongs in the editor runtime.
Users should write:
onCommand={(command, { editor }) => ...}
not:
useMemo(() => (event) => {
switch (event.inputType) {
...
}
}, [editor])
and not:
useCallback((event) => {
...
}, [editor])
unless that callback is registered as a subscription/listener and identity is the actual API contract.
Native listeners should attach once per root element/editor root transition.
Changing an app callback must update the latest callback read by the runtime,
not detach and reattach native beforeinput.
Implementation target:
useEffectEvent if it fits the repo/lint/runtime constraintsslate-reactThis plan follows the existing runtime-owner lesson:
Evidence:
EditorProps.handleDOMEvents is the low-level escape hatch. If it returns
true, the handler is responsible for preventDefault.handleTextInput is the semantic text insertion hook with from, to,
text, and a default transaction.keymap and inputrules are plugins, not raw DOM event props.beforeinput in prosemirror-view is narrow and conservative, mostly
Android/selection plumbing.Steal:
Reject:
Evidence:
beforeinput maps formatBold, formatItalic, formatUnderline,
undo/redo, insert/delete, and paste-like inputs into commands.onDOMBeforeInput.ContentEditable mostly sets the root element.Steal:
Reject:
Evidence:
addCommands, addKeyboardShortcuts, addInputRules, and
paste rules.Steal:
Reject:
Evidence:
beforeinput driven and requires
InputEvent.getTargetRanges.Steal:
beforeinput can be the primary browser-input signalReject:
format* and history is not enough for Slate ReactEvidence:
Steal:
Reject:
Not directly a beforeinput API guide. Their lesson is that layout/measurement
deserves its own deterministic lane, not that input should become layout-aware.
Live source already points in the right direction:
EditableInputRuleContext passes editor, event, inputType, and
selection.EditableKeyDownHandler already uses (event, context).EditableCommand already models delete, history, insert text/data, selection,
set block, and toggle mark.beforeinput intent.runtime-before-input-events.ts already routes native beforeinput through
classification, selection sync, input rules, model-owned operations, and DOM
repair.Current gaps:
onDOMBeforeInput type is still (event: InputEvent) => void.boolean | void but docs/type do not.format* beforeinput is classified as format intent but currently has no
semantic command. That is why examples fell back to raw event.inputType.inputRules as an Editable prop is too close to Tiptap/Plate product API
for raw Slate. Keep only if it is clearly scoped as a low-level input hook;
otherwise move rich rules up to Plate.Cut:
useMemo(() => callback) callback factoriesonDOMBeforeInput for bold/italic/underlineonBeforeInput and onDOMBeforeInput as interchangeableKeep:
onDOMBeforeInput as a native escape hatchonKeyDown for normal React keyboard event escape hatchesRevise:
onDOMBeforeInput type to include boolean | void and contextEditableCommand to include native format commandsonClickralph execution started.ralph execution finished implementation and moved
to ledger/checkpoint closeout.active goal state.active goal state..tmp/slate-v2/packages/slate-react.onCommand, raw onDOMBeforeInput
context, stable native input handler reads, hovering-toolbar cleanup, docs,
changeset, unit/package/browser proof, and bun check landed in
.tmp/slate-v2.onDOMBeforeInput accepts (event, context) => boolean | void.onDOMBeforeInput identity invokes the latest handler
without reattaching native beforeinput.formatBold, formatItalic, and formatUnderline
beforeinput classify into semantic format commands.onCommand handles a format command, Slate prevents
native default and does not continue model/native fallback.bold mark mutation.slate-react.EditableDOMBeforeInputContext into onDOMBeforeInput.onCommand into the input runtime after intent/command
classification and before default model/native application.format editable command for native format input.formatBold -> { kind: 'format', format: 'bold' }formatItalic -> { kind: 'format', format: 'italic' }formatUnderline -> { kind: 'format', format: 'underline' }formatStrikeThrough -> { kind: 'format', format: 'strikethrough' }slate public
exports.hovering-toolbar to use onCommand for native formatting
or normal toolbar onClick for UI buttons.useMemo callback factory import/use.markdown-shortcuts, tables, mentions, and iframe:
Editable event props after runtime proofonDOMBeforeInput docs to advanced native escape hatch guidance.Run from .tmp/slate-v2:
slate-react editing-kernel/runtime testsbun --filter slate-react typecheckbun lint:fixbun checkGrep gates:
rg -n -U "useMemo\\(\\s*\\n\\s*\\(\\)\\s*=>\\s*\\([^)]*\\)\\s*=>" site docs packages -g '!site/out/**'
rg -n "onDOMBeforeInput=.*format|formatBold|formatItalic|formatUnderline" site docs packages -g '!site/out/**'
Cache-first related issue pass only. No fixed issue claims.
Related:
#4681: raw onDOMBeforeInput behavior remains related.#3568 / #3586: beforeinput formatting/mark mutation bugs become better
covered after semantic format-command proof, but exact issue closure still
requires matching repro proof.#5181: stale callback/editor prop pressure is addressed by stable latest
handlers if execution lands.#4317: render callback churn remains adjacent but not closed by this plan.Execution ledger sync:
docs/slate-v2/references/pr-description.md adds the native command boundary
section without changing fixed issue claims.docs/slate-v2/ledgers/issue-coverage-matrix.md refreshes #3568,
#3586, #4681, #5181, and #4317 as related/non-closure rows.docs/slate-v2/ledgers/fork-issue-dossier.md records one cache-first
related issue pass for this touched surface.docs/slate-issues/gitcrawl-v2-sync-ledger.md records current manual sync
state for the same five issues.docs/solutions/developer-experience/2026-05-14-slate-react-native-beforeinput-formatting-needs-semantic-command-handlers.md
captures the reusable boundary pattern.slate-ralplan: applied. Planning only; no Slate v2 source edits.hard-cut: applied. Cut raw onDOMBeforeInput from normal examples, not the
escape hatch itself.repo-research-analyst: applied. Candidate comparison grounded in local
source reads.performance-oracle: applied. Main risk is native listener churn and broad
callback invalidation.react-useeffect: applied. Native listener lifecycle is an effect/subscription
problem; user formatting response is command/event-handler logic.learnings-researcher: applied. Prior runtime-owner and hot-path notes
reinforced static inventories, browser proof, and no public DOM-policy leak.tdd: applied. Tests must lock callback stability, context shape, and format
command classification before cleanup.ce-compound: applied. The reusable semantic-command/native-beforeinput
boundary was captured after verification.steelman-pass: applied. The strongest objection is "this creates another
API"; it wins because the alternative is making users parse InputEvent
quirks forever.high-risk-deliberate-pass: applied. Browser input behavior and public docs
are high-blast-radius surfaces.1.00.
Execution proof closed the remaining 0.05. The final API name is onCommand
because Editable already provides the scope and the exported
EditableCommand* types keep the command family explicit.