docs/solutions/developer-experience/2026-05-17-slate-v2-extension-composition-hard-cuts-need-creation-time-inference-and-browser-proof.md
Slate v2 had picked the right public direction: extensions: [...] at editor
creation, replayable APIs on state / tx, and installed runtime APIs on
editor.api. The implementation was not done while first-party examples,
docs, and React runtime paths still taught or depended on wrapper-era roots.
withEditor, local withX(editor) wrappers, or
wrapper-shaped casts.editor.undo() / editor.redo() and
root editor.dom.clipboard.dom.clipboard.insertData after the API
decision moved clipboard to sibling editor.api.clipboard.pending while runnable
browser and broad verification remained.done to silence the hook. That would have made
the execution state lie while proof remained runnable.Make the creation-time extension list the only public composition story:
const editor = useSlateEditor({
initialValue,
extensions: [editableVoid(), checklist()],
})
React editor creation installs react() and history() by default. Raw
createEditor() stays unopinionated:
const headlessEditor = createEditor({
initialValue,
extensions: [history(), checklist()],
})
Make extension resolution deterministic:
const editor = createReactEditor({
initialValue,
extensions: [history({ enabled: false })],
})
// @ts-expect-error disabled history contributes no installed state group
editor.read((state) => state.history.undos())
The resolver should walk tuples right-to-left, keep the latest extension for a
name, and treat enabled: false as a tombstone. Empty extension slots must
contribute never, not unknown, or one extension with no state/tx group will
erase the useful union members.
Move installed APIs to their final owners:
editor.read((state) => state.history.undos())
editor.update((tx) => {
tx.history.undo()
})
editor.api.history.withoutSaving(() => {
editor.update((tx) => {
tx.nodes.set({ type: 'paragraph' })
})
})
editor.api.clipboard.insertData(data)
For React runtime history, stop probing root methods:
const hasHistory = editor.read((state) => {
const history = (state as { history?: unknown }).history as
| { redos?: unknown; undos?: unknown }
| undefined
return (
typeof history?.redos === 'function' &&
typeof history?.undos === 'function'
)
})
if (hasHistory) {
editor.update((tx) => {
tx.history.undo()
})
}
For clipboard customization, align the handler key with the public API:
const html = () =>
defineEditorExtension({
name: 'paste-html',
capabilities: {
'clipboard.insertData': insertData,
},
})
Then close the lane with the proof stack that matches the blast radius:
bun --filter slate-dom typecheck
bun --filter slate-react typecheck
bun x tsc --project packages/slate/test/tsconfig.generic-types.json --noEmit
bun x tsc --project packages/slate-history/test/tsconfig.generic-types.json --noEmit
bun x tsc --project packages/slate-react/test/tsconfig.generic-types.json --noEmit
bun typecheck:site
bun lint:fix
PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun x playwright test \
playwright/integration/examples/check-lists.test.ts \
playwright/integration/examples/editable-voids.test.ts \
playwright/integration/examples/markdown-shortcuts.test.ts \
playwright/integration/examples/inlines.test.ts \
--project=chromium
bun check
node tooling/scripts/completion-check.mjs
Creation-time extensions gives TypeScript a single place to infer installed
state, tx, and editor.api handles. It also removes the wrapper
intersection trap where examples recover type power through T & HistoryEditor
or as CustomEditor.
React defaults need one extra rule: the context value should require only React
capabilities, not history. history({ enabled: false }) is valid, so
EditorContext cannot be typed as a default-history editor.
The browser rows matter because hard-cutting public roots can still leave stale internal calls. Typecheck did not catch the stale undo path; the editable-voids row did.
Keeping active goal state pending until the final gate is
not bureaucracy. It prevents the stop hook from becoming a fake green light.
editor.api, run browser rows
that exercise the moved behavior, not only package typechecks.done while the continuation file names a
runnable next action.editor.api.clipboard, examples should not teach dom.clipboard.never for empty groups. unknown is not a neutral element inside unions.enabled: false both at runtime and in negative type contracts.