docs/research/decisions/slate-v2-state-tx-public-api-and-extension-namespaces.md
Slate v2 should make the public lifecycle:
editor.read((state) => {
state.selection.get();
});
editor.update((tx) => {
tx.value.replace({
children,
marks: null,
selection: null,
});
tx.selection.get();
tx.nodes.set(props, { at: target });
});
Use state for read-only work and tx for update work.
Do not expose api / tf as the core Slate naming. api is too vague, and
tf is too Plate-shaped and cryptic for raw Slate.
tx Also Has Read MethodsInside an update, reads must observe the transaction-in-progress. If reads stay
on a separate object that points at the last committed snapshot, users will
ask whether api.selection.get() sees mutations already made in the same
callback.
tx should therefore be a writable transaction view:
state and txtxtx.selection.get() returns the fresh transaction selectiontx.nodes.get(target) reads the transaction document, not stale editor
stateExtensions should add named groups to state and tx, not flat methods to the
editor object:
defineEditorExtension({
key: "table",
state: {
table(state) {
return {
currentCell() {},
};
},
},
tx: {
table(tx) {
return {
insertRow() {},
};
},
},
});
Usage:
editor.read((state) => {
state.table.currentCell();
});
editor.update((tx) => {
tx.table.insertRow();
});
This keeps raw Slate unopinionated while giving Plate and plugins a clean, discoverable extension namespace.
Live .tmp/slate-v2 now has the state/tx substrate:
editor.read((state) => ...) and editor.update((tx) => ...) are implemented
and tested.state and tx expose grouped read APIs.tx exposes grouped write APIs.state.<plugin> and tx.<plugin>.But author-facing docs and examples still teach primitive editor writes inside
editor.update(() => ...) as the normal method API.
Live source evidence:
packages/slate/test/state-tx-public-api-contract.ts proves grouped
state reads, grouped tx writes, and tx-local read coherence.packages/slate/src/create-editor.ts still wires primitive transform
methods onto BaseEditor.packages/slate/test/write-boundary-contract.ts rejects primitive writes
outside editor.update, but still proves primitive writes are routed inside
update.docs/concepts/04-transforms.md and docs/concepts/07-editor.md still
present primitive editor.* methods as the author-facing method API.Current research verdict:
tx.* remains the accepted normal public write DX.editor.* transform methods should not be presented as the normal
author-facing path if the goal is the clean architecture/DX target.tx.*.applyOperations as the explicit operation replay writer for
collaboration/backbone proof.This is a plan/execution gap, not a research contradiction.
The public Editor value is still a live source mismatch with this decision.
Live .tmp/slate-v2 still exports EditorInterface and export const Editor,
and that namespace mixes editor-state reads, writes, extension registration,
replacement helpers, and setup helpers.
The hard-cut target is:
Editor as a type onlyNode, Path, Point, Range,
Element, and TextisEditor(value) if a public predicate is neededEditor value and public EditorInterfaceeditor.read((state) => ...) for committed readseditor.update((tx) => ...) for writes and tx-local readstx.value.replace(input) for public whole-document replacementLive source does not expose tx.value.replace yet. Current replacement is
implemented by replaceSnapshot, and BaseEditor still exposes
replace / reset. That means tx.value.replace is an implementation
requirement for the namespace hard cut, not a current capability.
Fixture seeding should use non-public helpers. Keeping public
Editor.replace, public editor.replace, or public editor.reset just to make
tests shorter would preserve the wrong app-author API.
Schema predicates should not stay as top-level editor clutter:
editor.isInline(element);
editor.isVoid(element);
editor.markableVoid(element);
editor.isSelectable(element);
The final public shape should be:
editor.schema.isInline(element);
editor.read((state) => {
state.schema.isVoid(element);
});
editor.update((tx) => {
tx.schema.isSelectable(element);
});
Most authors should configure these through element specs instead of manual predicate overrides:
defineElement({
type: "mention",
inline: true,
void: "markable-inline",
selectable: true,
});
read prevents mutation and update
is the only safe mutation place. Its active-context checks also show that
helpers should only run inside read/update callbacks.editor.commands and chain-first product style should remain a
product-DX inspiration, not the raw Slate core shape.editor.update(({ api, tf }) => {})Rejected. It forces users to reason about read freshness inside a write
callback, and the tf abbreviation is too framework-specific for core Slate.
editor.api and editor.tfRejected. It permits illegal writes outside editor.update unless every method
does runtime checks, and it makes autocomplete heavier on the editor object.
editor.commandsRejected as core Slate API. It is good product ergonomics for Tiptap and Plate-style command catalogs, but raw Slate should stay primitive and unopinionated.
editor.* method growthRejected. It preserves Slate familiarity, but it scales into method clutter and keeps extension collisions on the editor object.
The best final shape is:
small editor object
editor.read((state) => ...)
editor.update((tx) => ...)
namespaced read groups on state and tx
namespaced write groups only on tx
extension groups attached to state/tx namespaces
optional product command sugar above the primitive lifecycle