docs/plans/2026-04-29-slate-v2-base-editor-state-tx-hard-cut-plan.md
Date: 2026-04-29
Status: done
Implementation status: complete
Code repo: /Users/zbeyens/git/slate-v2
Plan repo: /Users/zbeyens/git/plate-2
Hard cut the public BaseEditor instance query/read surface.
The current public shape still has two competing APIs:
editor.read((state) => ...) and editor.update((tx) => ...)editor.getSnapshot(),
editor.getSelection(), editor.getChildren(), editor.string(...),
editor.above(...), editor.before(...), editor.pathRef(...), and
editor.schema.define(...)That is not a clean rewrite. It is a transaction-first API with a legacy instance API still attached.
The target public editor is small:
export interface Editor<V extends Value = Value> {
read<T>(fn: (state: EditorStateView<V>) => T): T;
update(
fn: (tx: EditorUpdateTransaction<V>) => void,
options?: EditorUpdateOptions,
): void;
subscribe(listener: SnapshotListener<V>): () => void;
extend(extension: EditorExtensionInput<V>): () => void;
}
subscribe is an advanced adapter/runtime bridge. Normal app code uses
React hooks or read / update.
Live source:
.tmp/slate-v2/packages/slate/src/interfaces/editor.ts
BaseEditor exposes direct reads: getChildren, getFragment,
getLastCommit, getOperations, getSelection, getSnapshot,
getRuntimeId, and getPathByRuntimeId.BaseEditor exposes direct queries: above, after, before,
edges, first, fragment, hasBlocks, hasInlines, hasPath,
isBlock, isEmpty, levels, nodes-style traversal through
levels / next / previous, refs, string, unhangRange, and
void.OmitFirstArg<typeof Editor.*>, keeping
the internal static Editor table embedded in the public editor type..tmp/slate-v2/packages/slate/src/create-editor.ts
createEditor() still attaches every direct query/read alias onto the
editor object..tmp/slate-v2/packages/slate/src/core/public-state.ts
state / tx groups already exist, but they are incomplete; they call
back through direct editor aliases for several reads.slate-history, slate-dom, site
examples, and tests using direct instance reads.Intent:
read / update the only normal public editor-state API.Editor implementation.In scope:
packages/slate/src/interfaces/editor.tspackages/slate/src/create-editor.tspackages/slate/src/core/public-state.tsslate-dom, slate-history, slate-react, slate-hyperscript call sitesOut of scope:
editor.refs as a public object proposalNode, Path, Point, Range, Element,
Text, OperationDecision boundary:
slate/internal or a package-local helper.Keep:
editor.read((state) => ...)
editor.update((tx) => ...)
editor.subscribe(listener)
editor.extend(extension)
Cut from public BaseEditor:
getChildren, getFragment, getSelection,
getSnapshot, getOperations, getLastCommitgetRuntimeId, getPathByRuntimeIdgetDirtyPaths, getOperationDirtinessschemanormalizeNode,
shouldNormalizeabove, after, before, edges, first,
fragment, hasBlocks, hasInlines, hasPath, hasTexts, isBlock,
isEdge, isEmpty, isEnd, isNormalizing, isStart, last, leaf,
levels, next, parent, path, point, positions, previous,
projectRange, range, string, unhangRange, void,
shouldMergeNodesRemovePrevNodepathRef, pathRefs, pointRef, pointRefs, rangeRef,
rangeRefsOmitFirstArg<typeof Editor.*> reference in public BaseEditorExpand EditorStateView until every surviving read has one obvious home:
editor.read((state) => {
state.value.get();
state.value.snapshot();
state.value.operations({ since: 0 });
state.value.lastCommit();
state.selection.get();
state.marks.get();
state.nodes.children([]);
state.nodes.get([0]);
state.nodes.parent([0, 0]);
state.nodes.above({ at, match });
state.nodes.first(at);
state.nodes.last(at);
state.nodes.leaf(at);
state.nodes.levels({ at });
state.nodes.next({ at });
state.nodes.previous({ at });
state.nodes.match({ at });
state.nodes.hasPath(path);
state.nodes.isBlock(element);
state.nodes.isEmpty(element);
state.nodes.hasBlocks(element);
state.nodes.hasInlines(element);
state.nodes.hasTexts(element);
state.nodes.void({ at });
state.points.before(at, options);
state.points.after(at, options);
state.points.start(at);
state.points.end(at);
state.points.get(at, options);
state.points.isEdge(point, at);
state.points.isStart(point, at);
state.points.isEnd(point, at);
state.ranges.get(at);
state.ranges.edges(at);
state.ranges.unhang(range, options);
state.ranges.project(range);
state.text.string(at, options);
state.schema.getElementSpec(type);
state.schema.isInline(element);
state.schema.isBlock(element);
state.schema.isVoid(element);
state.schema.isElementReadOnly(element);
state.schema.isSelectable(element);
state.schema.markableVoid(element);
state.runtime.idAt(path);
state.runtime.pathOf(runtimeId);
});
No direct editor instance query is the normal read path.
tx inherits read groups and adds writes:
editor.update((tx) => {
tx.value.replace(value);
tx.operations.replay(ops);
tx.nodes.set(props, options);
tx.nodes.insert(node, options);
tx.nodes.remove(options);
tx.nodes.move(options);
tx.nodes.wrap(element, options);
tx.nodes.unwrap(options);
tx.nodes.split(options);
tx.nodes.merge(options);
tx.selection.set(target);
tx.selection.clear();
tx.selection.move(options);
tx.selection.collapse(options);
tx.text.insert(text, options);
tx.text.delete(options);
tx.marks.add(key, value);
tx.marks.remove(key);
tx.marks.toggle(key, options);
tx.normalize(options);
tx.withoutNormalizing(fn);
});
If refs remain public, they live in state/tx groups, not on editor:
editor.update((tx) => {
const ref = tx.refs.path(path, options);
});
If refs are only runtime infrastructure, they move to slate/internal.
Public schema mutation should not be editor.schema.define(...).
Target:
const cleanup = editor.extend(
defineEditorExtension({
elements: [
{
type: "image",
void: "block",
selectable: true,
},
],
}),
);
Read schema facts through state.schema / tx.schema.
Internal packages can use a runtime schema registry, but the public editor object should not expose a mutable schema object.
Introduce an internal runtime object that owns the removed direct methods:
type EditorRuntime<V extends Value = Value> = {
state: InternalEditorStateApi<V>;
queries: InternalEditorQueryApi<V>;
transforms: EditorTransformRegistry<V>;
refs: InternalEditorRefApi;
schema: InternalEditorSchemaApi;
normalize: InternalEditorNormalizeApi;
};
Rules:
createEditor() returns the small public editor object.slate/internal exports internal helpers for first-party packages only.EditorStaticApi, InternalEditor, or
OmitFirstArg<typeof Editor.*>.This keeps extension override points, but they are explicit extension/runtime registration points, not monkeypatched methods on the public editor object.
Plate migration backbone:
editor.extend(...).editor.update((tx) => ...).slate-yjs migration backbone:
state.value.snapshot() and
state.value.lastCommit().tx.operations.replay(...).state.runtime.editor.subscribe(...) as an
advanced adapter bridge.No current-version adapter compatibility is required.
Add tests before implementation:
BaseEditor public keys are exactly:
readupdatesubscribeextendeditor.getSnapshot, editor.getSelection, editor.getChildren,
editor.string, editor.above, editor.pathRef, and editor.schema are
type errors and absent at runtime.BaseEditor source has no OmitFirstArg<typeof Editor.slate still exports type Editor, not runtime Editor.Node, Path, Point, Range, Element,
Text, Operation.Focused command:
bun test ./packages/slate/test/public-surface-contract.ts ./packages/slate/test/state-tx-public-api-contract.ts
Expected before implementation: red.
Add missing state groups before deleting aliases:
state.value.snapshot()state.value.operations(options?)state.value.lastCommit()state.runtime.idAt(path)state.runtime.pathOf(runtimeId)state.nodes.above, first, last, leaf, levels, next,
previous, void, hasBlocks, hasInlines, hasTexts, isBlock,
isEmptystate.points.get, isEdge, isStart, isEndstate.ranges.unhang, projectstate.schema.isBlockAdd transaction equivalents only where mutation or tx-local read semantics are needed.
Move direct query/read implementations behind internal helpers:
getEditorRuntime(editor)getEditorQueryRuntime(editor)getEditorSchemaRuntime(editor)getEditorRefRuntime(editor)getEditorNormalizeRuntime(editor)Then update public-state.ts so state / tx call internal helpers directly
instead of calling editor.above(...), editor.string(...), etc.
BaseEditorEdit packages/slate/src/interfaces/editor.ts:
BaseEditorschemaOmitFirstArg<typeof Editor.*> from public editor surfaceEdit packages/slate/src/create-editor.ts:
read, update, subscribe, extendMigrate packages by ownership:
slate-history
Editor.getSnapshot(e) -> e.read((state) => state.value.snapshot())Editor.subscribe(e, ...) can remain e.subscribe(...) as advanced
adapter bridgee.update((tx) => ...)slate-dom
editor.read(...)slate/internalslate-react
slate-hyperscript
Migrate test intent:
editor.read / editor.update.slate/internal explicitly.No preload rewrites. No compatibility bridges.
Docs must describe current truth only:
editor.read((state) => ...)editor.update((tx) => ...)editor.extend(...)editor.getSnapshot()editor.getChildren()editor.string(...)editor.schema.define(...)Editor.* state/query helpersExamples migrate the same way.
Add grep-backed guards:
rg -n "OmitFirstArg<typeof Editor" packages/slate/src/interfaces/editor.ts
rg -n "\beditor\.(getChildren|getSelection|getSnapshot|getOperations|getLastCommit|getRuntimeId|getPathByRuntimeId|string|above|after|before|pathRef|pointRef|rangeRef|schema)\b" packages site docs --glob '!**/dist/**' --glob '!site/out/**' --glob '!site/.next/**'
rg -n "import \{[^}]*Editor[^}]*\} from ['\"]slate['\"]" packages site docs --glob '!**/dist/**'
Allowed hits must be explicit internal tests or historical changelog only.
Required before marking implementation done:
bun test ./packages/slate/test/public-surface-contract.ts ./packages/slate/test/state-tx-public-api-contract.ts
bun test ./packages/slate/test/query-contract.ts ./packages/slate/test/snapshot-contract.ts
bun test ./packages/slate-history/test/history-contract.ts ./packages/slate-history/test/integrity-contract.ts
bun check
If docs/examples change user-facing browser behavior, add focused
slate-browser or Playwright proof. Do not put bun test:integration-local
in the normal iteration loop.
| Risk | Why It Matters | Plan Response |
|---|---|---|
| State/tx groups miss a current query | Removal becomes a DX regression | Phase 2 fills parity before the cut |
| Internal packages start importing public aliases again | The hard cut rots | Phase 8 grep guards |
| Extension authors lose override points | Plugins need behavior hooks | Move overrides to explicit extension/runtime registration |
| Collaboration loses snapshot/commit access | slate-yjs-style substrate needs deterministic commits | state.value.snapshot(), state.value.lastCommit(), tx.operations.replay(...), editor.subscribe(...) |
| Ref API gets fuzzy | refs are neither pure reads nor normal writes | Decide state/tx/internal in Phase 2 before deleting aliases |
| Docs keep stale snippets | Users learn the wrong API | Phase 7 plus grep guards |
BaseEditor public surface is small and does not expose direct state/query
aliases.createEditor() returns an object with only read, update, subscribe,
and extend as public methods.state and tx cover all intended public reads/writes.typeof Editor.*.bun check passes in .tmp/slate-v2.No runnable in-scope owner remains for this plan.
active goal state to pending.active goal state for the active execution lane..tmp/slate-v2/packages/slate.extend, read, subscribe,
and updateBaseEditor typing through OmitFirstArg<typeof Editor.*>state.value.snapshot()state.value.operations()state.value.lastCommit()state.runtime.idAt(path)state.runtime.pathOf(runtimeId)state.nodes, state.points,
state.ranges, and state.schemabun test ./packages/slate/test/public-surface-contract.ts ./packages/slate/test/state-tx-public-api-contract.ts163 pass, 5 fail..tmp/slate-v2:
BaseEditor public methods are read, update, subscribe, and
extend.createEditor() no longer attaches public direct read/query/schema/ref
aliases.slate/internal.editor.* aliases.OmitFirstArg<typeof Editor.*> coupling from
packages/slate/src/interfaces/editor.ts.OmitFirstArg utility exports from
packages/slate/src/utils/types.ts.editor.schema.define(...) -> editor.extend(...)Editor.getSnapshot(...) / Editor.subscribe(...) ->
editor.read(...) / editor.subscribe(...)tx.schema / state.* groups for queries.bun test:vitest test/surface-contract.test.tsx in
.tmp/slate-v2/packages/slate-react: 1 passed, 9 tests passed.bun check in .tmp/slate-v2: lint, package/site/root typecheck,
Bun tests, and Vitest all passed.rg -n "OmitFirstArg" packages/slate/src -S only found the now-removed
utility before the final cleanup.packages/slate/test/support/with-test.js still
attaches legacy fixture-only methods such as getChildren,
getSelection, schema, string, and transform aliases..tmp/slate-v2/packages/slate/test/support/with-test.js.extend(...), and runtime registration needed by old internal query
callbacks. It no longer exposes getChildren, getSelection,
getOperations, schema.define, or direct transform aliases.tx.value.operations() for operation assertionstx.operations.replay(...) for replaytx.marks.add(...) for mark writestx.nodes.*, tx.selection.*, tx.withoutNormalizing(...), and
tx.normalize(...) for transform intenteditor.extend(...) for fixture schema setup.tmp/slate-v2:
bun test packages/slate/test/index.spec.ts: 964 pass, 94 skip.bun test packages/slate/test/index.spec.ts packages/slate-hyperscript/test/index.spec.ts: 993 pass, 94 skip.bun test ./packages/slate/test/public-surface-contract.ts ./packages/slate/test/state-tx-public-api-contract.ts: 168 pass.bun lint:fix: passed, fixed formatting in touched files.bun typecheck:packages: passed, 6 successful.bun check: passed lint, package/site/root typecheck, Bun tests, and
slate-react Vitest.bun test ./packages/slate/test/query-contract.ts ./packages/slate/test/snapshot-contract.ts ./packages/slate-history/test/history-contract.ts ./packages/slate-history/test/integrity-contract.ts: 286 pass.bun test ./packages/slate/test/read-update-contract.ts ./packages/slate/test/schema-contract.ts ./packages/slate/test/transforms-contract.ts ./packages/slate/test/surface-contract.ts.pending; the plan is not honestly complete
while those stale contract files remain..tmp/slate-v2/packages/slate/test/surface-contract.ts.tmp/slate-v2/packages/slate/test/extension-contract.tseditor.read(...) or explicit slate/internal helperseditor.update((tx) => ...)editor.extend(...).tmp/slate-v2/packages/slate/test/support/schema.ts.tmp/slate-v2:
bun test ./packages/slate/test/*contract.ts ./packages/slate-hyperscript/test/smoke-contract.ts: 591 pass.bun test packages/slate/test/index.spec.ts packages/slate-hyperscript/test/index.spec.ts: 993 pass, 94 skip.packages/slate/test and
packages/slate-hyperscript/test: no hits for editor.schema,
editor.get*, or direct transform aliases.bun lint:fix: passed.bun typecheck:packages: passed, 6 successful.bun check: passed lint, package/site/root typecheck, Bun tests, and
slate-react Vitest.done.