docs/plans/2026-05-18-slate-v2-extension-slots-ralplan.md
Date: 2026-05-18
Status: done
Completion id: 019e390b-a7f2-7423-af90-d7dd8e45f8fb
Current pass: verification-sweep-pass
Current pass status: complete
Score: 0.92 ready
The mechanisms are necessary. The current names are not the best Slate-ish DX.
operationMiddlewares: necessary substrate, wrong public name. Rename the author-facing slot to operations.apply.commitListeners: necessary substrate, okay mechanism, registry-ish name. Rename author-facing slot to onCommit.register: necessary lifecycle, but slightly framework-internal. Rename author-facing slot to setup.Keep the internal registry names if useful internally. Do not make extension authors write registry names as their normal API.
operationMiddlewaresCurrent shape:
operationMiddlewares: [
({ operation }, next) => {
next(operation);
},
];
Live source:
.tmp/slate-v2/packages/slate/src/interfaces/editor.ts:1177 defines operation middleware context as { editor, operation }..tmp/slate-v2/packages/slate/src/interfaces/editor.ts:1341 and :1358 expose operationMiddlewares on extension registration output and extension objects..tmp/slate-v2/packages/slate/src/core/public-state.ts:1779 routes every applied operation through registered operation middleware before base apply..tmp/slate-v2/packages/slate/src/core/extension-registry.ts:225 stores/removes operation middleware in the extension registry..tmp/slate-v2/packages/slate-dom/src/plugin/with-dom.ts:143 uses it to keep DOM-side pending text diffs, pending selection, path refs, and key maps coherent as operations apply..tmp/slate-v2/packages/slate/test/transaction-contract.ts:409 proves tx.apply routes through operation middleware.Real use:
editor.applyDX verdict:
operationMiddlewares is honest but ugly. It sounds like Express, not Slate. The closest Slate-ish shape is:
operations: {
apply({ operation, next }) {
next(operation)
},
}
Why this wins:
editor.apply overridetx out of the operation pipelinecommitListenersCurrent shape:
commitListeners: [
(commit, snapshot) => {
// observe post-transaction commit
},
];
Live source:
.tmp/slate-v2/packages/slate/src/interfaces/editor.ts:1347 exposes commitListeners from registration output..tmp/slate-v2/packages/slate/src/interfaces/editor.ts:1364 exposes commitListeners on extension objects..tmp/slate-v2/packages/slate/src/interfaces/editor.ts:1583 defines a listener as (commit, snapshot) => void..tmp/slate-v2/packages/slate/src/core/public-state.ts:2616 notifies commit listeners after a snapshot change and lazily computes snapshot only when needed..tmp/slate-v2/packages/slate-history/src/history-extension.ts:233 uses commit listeners to build undo batches, merge/push/skip history, clear redo, and rebase history on remote/collab commits..tmp/slate-v2/packages/slate/test/collab-adapter-extension-contract.ts:122 uses commit listeners to export local commits for a fake collaboration adapter..tmp/slate-v2/packages/slate/test/generic-extension-contract.ts:40 proves typed commit/snapshot access.Real use:
DX verdict:
commitListeners is a registry name. It is acceptable internally, but public extension DX should be:
onCommit({ commit, snapshot }) {
// post-commit work
}
Why this wins:
onChangeonCommit makes timing obvious: after transaction, not during updateeditor or state later without positional-arg churnregisterCurrent shape:
register({ editor, options, runtimeState, signal }) {
const state = runtimeState(initial)
return {
api: {},
cleanup() {},
commitListeners: [],
state: {},
tx: {},
}
}
Live source:
.tmp/slate-v2/packages/slate/src/interfaces/editor.ts:1328 defines registration context with editor, name, options, runtimeState, and signal..tmp/slate-v2/packages/slate/src/core/editor-extension.ts:351 builds that context..tmp/slate-v2/packages/slate/src/core/editor-extension.ts:545 calls extension.register(context), registers both static slots and returned slots, wires runtime-state cleanup, returned cleanup, and abort signal..tmp/slate-v2/packages/slate-history/src/history-extension.ts:211 uses register to initialize history state, expose editor.api.history, return cleanup, and attach commit listener..tmp/slate-v2/packages/slate-dom/src/plugin/with-dom.ts:273 uses register to install DOM runtime, remove the temporary root editor.dom, and expose editor.api.dom / editor.api.clipboard..tmp/slate-v2/packages/slate-react/src/plugin/with-react.ts:87 uses register to install DOM/React runtime and expose API groups..tmp/slate-v2/packages/slate/test/extension-methods-contract.ts:136 proves options, cleanup signal, and extension-local runtime state..tmp/slate-v2/packages/slate/test/generic-extension-namespace-contract.ts:174 proves registration can return typed state and tx groups..tmp/slate-v2/packages/slate/test/transaction-contract.ts:1265 proves cleanup and abort after unextend.Real use:
DX verdict:
register is not terrible, but setup is closer to the actual job:
setup({ editor, options, runtimeState, signal }) {
const mode = runtimeState('text')
return {
api: {},
onCommit() {},
state: {},
tx: {},
cleanup() {},
}
}
Why setup wins:
withX(editor) better than registerconst history = () =>
defineEditorExtension({
name: "history",
state: {
history(state, editor) {
return {
get: () => getHistory(editor),
redos: () => getHistory(editor).redos,
undos: () => getHistory(editor).undos,
};
},
},
tx: {
history(tx, editor) {
return {
redo() {},
undo() {},
};
},
},
setup({ editor, runtimeState, signal }) {
getHistory(editor);
return {
api: {
history: {
withoutSaving(fn) {},
},
},
onCommit({ commit }) {
// build history batch
},
cleanup() {
// delete WeakMap state
},
};
},
});
Low-level operation hook:
const dom = () =>
defineEditorExtension({
name: "dom",
operations: {
apply({ operation, next }) {
// update DOM-side refs/pending ranges
next(operation);
},
},
setup({ editor }) {
return {
api: {
clipboard,
dom,
},
};
},
});
| Current public slot | Decision | Target | Why |
|---|---|---|---|
operationMiddlewares | rename | operations.apply | middleware is registry-speak; apply is the Slate mental model. |
commitListeners | rename | onCommit | Post-commit extension callback; callback name beats listener array. |
register | rename | setup | Installs runtime state/resources and returns slots; less framework-internal. |
internal registry operationMiddlewares | keep internal | no public docs | Registry can keep literal storage names. |
internal registry commitListeners | keep internal | no public docs | Runtime internals can stay explicit. |
runtimeState context helper | keep | runtimeState | Verbose but precise; avoids confusion with state read view. |
signal | keep | signal | AbortSignal is right for cleanup. |
cleanup | keep | cleanup | Clear, conventional, needed. |
No issue fix claim. This is API review only.
Related surfaces already in durable ledgers:
#1770, #2288, #3741, #3874#1024, #5233, #4569#3222, #4089, #3177Issue-ledger closure:
docs/slate-v2/ledgers/issue-coverage-matrix.md updates #3557 to name
the target slot vocabulary: operations.apply, onCommit, and setup.docs/slate-issues/gitcrawl-v2-sync-ledger.md updates the current #3557
manual sync row with this plan as the latest planning source.docs/slate-v2/ledgers/fork-issue-dossier.md adds the extension slot naming
review and records 0 new fixed/improved claims.docs/slate-v2/references/pr-description.md records the accepted
author-facing naming target.Initial strategy:
operations.apply may look too approachable for app authors; docs must label it advanced/substrate.onCommit can tempt mutation from post-commit listeners; tests/docs should say post-commit observers must start a fresh editor.update if they mutate.setup return shape must not become a dumping ground for product hooks. Plate owns product plugin bundles.Principles:
withX(editor) without mutating
the editor root.Drivers:
apply, commit/onChange
pressure, and withX setup pressure.Options considered:
| Option | Verdict | Reason |
|---|---|---|
Keep operationMiddlewares, commitListeners, register public | reject | Mechanically honest but registry-shaped and not Slate-ish enough. |
Rename to operations.apply, onCommit, setup | accept | Maps to operation apply, post-commit observation, and extension runtime installation without widening scope. |
Collapse these into api or editor helpers | reject | Loses lifecycle timing and encourages app-level helpers for engine-level work. |
| Cut operation/commit/setup slots entirely | reject | Breaks first-party history, DOM, React, and collaboration adapter architecture. |
Consequence:
The plan is a breaking public API rename plan. It needs a later Ralph execution slice with public-surface tests, first-party extension migration, and examples.
Objection: "operations.apply is too inviting; app authors will misuse it."
Answer: keep it documented as advanced and operation-level. The name is still
better than operationMiddlewares because it maps to Slate's historical
apply override instead of middleware infrastructure.
Objection: "onCommit sounds like React and might encourage mutation after
commit."
Answer: the timing is the point. It should be documented as post-transaction
observation. If a listener mutates, it must start a new editor.update.
Objection: "setup is vague."
Answer: register is more vague for authors because it describes an internal
registry action. setup describes what extension authors do: allocate runtime
state, install APIs, and return cleanup.
Objection: "This is churn without behavior."
Answer: the behavior already exists and is necessary. The churn is justified because this is the raw extension authoring spine. Bad names here become permanent copy-paste debt across every first-party and third-party extension.
Pre-mortem:
operations.apply becomes a product command hook.
Mitigation: docs mark it advanced and operation-level; product behavior stays
in transforms.onCommit grows into an event bus.
Mitigation: one author-facing callback per extension; internal registry can
compose returned listeners.setup return shape becomes a dumping ground.
Mitigation: accepted return keys stay the existing extension slot families.Proof matrix for Ralph:
history, dom, and react extensions use the target names.Verdict after live source read: the slot rename itself does not force a site
example edit. Current examples do not author operationMiddlewares,
commitListeners, or register directly.
The broader DX cleanup does affect examples that teach first-party feature
extensions while still passing rendering through Editable render* props. That
is a teaching mismatch: the feature extension owns behavior/schema, but the
visible rendering is still separate at the call site.
| File | Current live shape | Required change | Why |
|---|---|---|---|
.tmp/slate-v2/site/examples/ts/check-lists.tsx | extensions: [checklist()]; transforms.deleteBackward; Editable renderElement | Keep transform shape; move checklist element rendering into extension-owned renderer registration when the renderer API is finalized. | This is the canonical checklist DX example. It should show one feature extension owning behavior plus rendering. |
.tmp/slate-v2/site/examples/ts/tables.tsx | extensions: [table()]; transforms.deleteBackward, deleteForward, insertBreak; Editable renderElement / renderLeaf | Keep transform middleware; move table element rendering into the table feature extension if raw Slate keeps renderer registration, otherwise keep renderElement and document it as per-editor rendering. | Table behavior already proves transform middleware is the right answer instead of keydown interception. |
.tmp/slate-v2/site/examples/ts/markdown-shortcuts.tsx | extensions: [markdownShortcuts()]; transform middleware; Editable renderElement | Keep transform middleware. Renderer move is optional because the extension is mainly behavior, not a schema package. | This is a behavior-extension example; rendering can stay explicit if we want to teach local block rendering separately. |
.tmp/slate-v2/site/examples/ts/inlines.tsx | extensions: [inline()]; clipboard + transform middleware + elements; Editable renderElement / renderText | Keep clipboard/transform/elements; move link/button/badge renderers into the extension only if renderer registration stays in raw Slate. | It is a mixed schema/behavior/rendering feature, so split teaching is noisy. |
.tmp/slate-v2/site/examples/ts/images.tsx | extensions: [image()]; clipboard.insertData; elements; Editable renderElement / renderVoid | Keep clipboard.insertData; move image void rendering into the image extension when renderer registration is accepted. | This is the strongest public example for extension-owned clipboard + void rendering. |
.tmp/slate-v2/site/examples/ts/editable-voids.tsx | extensions: [editableVoid()]; elements; Editable renderElement / renderVoid | Move editable-void rendering into the extension if renderer registration is retained; otherwise keep the explicit render props. | It demonstrates the void/embedded editor model and should not teach two extension paths unless one is clearly an override. |
.tmp/slate-v2/site/examples/ts/embeds.tsx | extensions: [embed()]; elements; Editable renderElement / renderVoid | Same as images/editable-voids: renderer ownership belongs with the feature if raw Slate keeps renderer registration. | Embeds are feature-level schema + rendering, not only per-editor decoration. |
.tmp/slate-v2/site/examples/ts/mentions.tsx | extensions: [mention()]; elements; Editable renderElement / renderLeaf / renderVoid | Keep mention schema extension; decide whether mention renderer belongs in raw Slate extension or stays as an app override. | Mentions mix schema, popup UI, marks, and void rendering, so raw Slate must avoid becoming Plate. |
.tmp/slate-v2/site/examples/ts/forced-layout.tsx | normalizers.editor; Editable renderElement | No slot rename change. Keep normalizer shape; renderer can stay explicit because the example is about document constraints. | The public-surface contract already forbids stale post-commit repair here. |
.tmp/slate-v2/site/examples/ts/richtext.tsx | richText() extension with clipboard + transforms; Editable renderElement / renderLeaf | Do not shove the whole rich-text UI into raw extension slots. Keep as a broader example unless a dedicated renderer-registration API is accepted. | This is closest to a product bundle. Raw Slate should avoid turning it into Plate. |
.tmp/slate-v2/site/examples/ts/paste-html-import.ts / paste-html.tsx | html() extension with clipboard.insertData and elements; rendering remains in the example | Keep clipboard.insertData; no direct slot rename change. | Clipboard ingress shape is already right; output/rendering is separate policy. |
Hard answer: for the three-slot rename, the examples that must change are not site examples; they are first-party packages and contracts:
.tmp/slate-v2/packages/slate-history/src/history-extension.ts: register ->
setup, returned commitListeners -> returned or top-level onCommit..tmp/slate-v2/packages/slate-dom/src/plugin/with-dom.ts: ad-hoc DOM
operation middleware -> operations.apply; dom() lifecycle register ->
setup..tmp/slate-v2/packages/slate-react/src/plugin/with-react.ts: lifecycle
register -> setup..tmp/slate-v2/packages/slate/test/transaction-contract.ts: operation
middleware and lifecycle tests must use operations.apply, onCommit, and
setup..tmp/slate-v2/packages/slate/test/collab-adapter-extension-contract.ts:
fake adapter uses setup + onCommit..tmp/slate-v2/packages/slate/test/extension-methods-contract.ts and generic
extension contracts: public typing must reject old slot names and prove the
new ones.Example policy after this review:
renderElement when the renderer is
not part of the feature being taught.Editable render* props remain valid escape hatches and per-instance
overrides. They should not be the default teaching path for first-party
feature extensions.| Pass | Status | Evidence | Next |
|---|---|---|---|
| Activation reset | complete | active goal state reset from previous done state | none |
| Current-state read | complete | live .tmp/slate-v2 source, tests, first-party history/dom/react/collab uses, compiled Lexical/ProseMirror/Tiptap research | related issue discovery |
| Related issue discovery | complete | related rows checked in coverage matrix, fork dossier, current sync ledger, and frozen open issue ledger | ledger sync |
| Issue ledger sync | complete | updated #3557 in coverage matrix and current sync ledger; added fork dossier section; PR reference synced | decision brief |
| Decision brief | complete | options, drivers, rejected alternatives, and consequence recorded above | maintainer objection |
| Maintainer objection pass | complete | objections for operations.apply, onCommit, setup, and churn recorded above | deliberate pass |
| High-risk deliberate mode | complete | pre-mortem and Ralph proof matrix recorded above | closure gate |
| Closure gate | complete | planning artifacts synced; no Slate v2 source edit made; completion-check passes | none |
| Example impact refresh | complete | live example grep/read over .tmp/slate-v2/site/examples/ts and package contracts; table recorded above | closure refresh |
| Closure refresh | complete | example-impact refresh added no new issue claim, no Slate v2 source edit, and no new ledger sync requirement; completion state closed for current hook id | none |
| Ralph execution activation | complete | active goal state reset to pending; active goal state rewritten for execution | public API TDD slice |
| Public API TDD slice | complete | RED: focused transaction-contract showed operations.apply not wired; GREEN: runtime contracts and package typechecks pass with setup, onCommit, and operations.apply | diff review |
| Diff review pass | complete | Fixed snapshot eagerness in the onCommit wrapper by keeping the internal listener arity 1 and exposing lazy snapshot | verification sweep |
| Verification sweep | complete | bun check passed in .tmp/slate-v2; targeted runtime contracts, package typechecks, slate-react vitest, lint fix, and solution capture completed | none |
The accepted planning verdict is now an implementation lane.
Completed owner:
.tmp/slate-v2.TDD slice:
setup, onCommit, and operations.apply; old public slot names are rejected by type contracts.bun test ./packages/slate/test/transaction-contract.ts --test-name-pattern "tx.apply routes through operations.apply" failed because seenOperations.length stayed 0.Remaining required passes:
tdd-pass: complete.diff-review-pass: complete.verification-sweep-pass: complete.| Dimension | Score | Why |
|---|---|---|
| Slate-close DX | 0.92 | Target names map to Slate mental models: operation apply, commit callback, extension setup. |
| Architecture coherence | 0.93 | Operation, commit, and setup lifecycles stay distinct and match first-party history, DOM, React, and collab use. |
| Regression safety | 0.88 | Rename plan has clear Ralph proof requirements: public type tests, runtime tests, and first-party extension migration. |
| Migration backbone | 0.93 | History, DOM, React, and collab adapter uses prove the hooks are real and must survive the hard cut. |
| Research support | 0.9 | Lexical, ProseMirror, and Tiptap support lifecycle partitioning while Slate keeps product plugin bundles out. |
| Example quality | 0.92 | Example impact table now distinguishes direct slot-rename changes from broader feature-extension teaching cleanup. |
Overall: 0.92 ready.
No further autonomous work remains for this activation.