docs/plans/2026-04-23-slate-v2-selection-fresh-editor-methods-architecture-plan.md
The active master execution plan is:
This file remains historical execution evidence for the target-fresh and read/update transition slices.
This section supersedes the earlier target-fresh editor-method framing below.
The execution ledger remains valid historical evidence, but the final API
direction is editor.read(...) and editor.update(...), not semantic-method
growth.
Do not pivot to a different editor architecture.
Pivot harder into:
Slate model + operations
Lexical-style read/update lifecycle
ProseMirror-style transaction and DOM-selection discipline
Tiptap-style extension ergonomics
React 19.2 optimized rendering/runtime APIs
The core stays data-model-first and operation/collaboration-friendly. React does not own the model. React receives better runtime facts: live reads, commit dirtiness, dirty runtime ids, semantic islands, and direct DOM text sync as an explicit capability.
The public runtime contract becomes:
editor.read(() => {
const selection = editor.getSelection();
const active = editor.hasNodes({ match: isHeading });
});
editor.update(() => {
editor.unwrapNodes({ match: isList });
editor.setNodes({ type: "list-item" });
editor.wrapNodes({ type: "bulleted-list", children: [] });
});
The internal runtime contract remains:
editor.update
-> transaction
-> resolve implicit target once
-> primitive editor methods use the transaction target when `at` is omitted
-> operations
-> EditorCommit
-> history / collaboration / React runtime / DOM repair
tx.resolveTarget() is correct, but it is an internal engine-room API. Plugin
authors should not learn DOM freshness policy.
The current target-fresh method direction fixed real stale-selection bugs, but it risks semantic API bloat:
toggleMarktoggleBlocktoggleListtoggleAlignmenttoggleTodotoggleCallouttoggleWhateverCustomNodeThat is not the final DX. Slate's durable advantage is flexible primitive transforms over arbitrary JSON-like document models. The perfect v2 should make those primitives safe under one lifecycle, not replace flexibility with an endless semantic-method catalog.
The right public headline is:
All writes happen in `editor.update`.
All coherent reads happen in `editor.read`.
Primitive editor methods are safe inside updates.
Operations remain collaboration truth.
Commits are local runtime truth.
Steal:
editor.update(fn) as the public write lifecycle.editor.read(fn) as the public coherent read lifecycle.Do not steal:
$function naming.Slate v2 adaptation:
read/update lifecycleSteal:
selectionFromDOM and selectionToDOM as explicit bridge directionsDo not steal:
Slate v2 adaptation:
slate-react owns DOM import/export/repair via one runtime ownerSteal:
Do not steal:
editor.chain().focus().toggleX().run() ceremonySlate v2 adaptation:
editor.update eliminates focus() ceremony for target freshnesseditor.chain() can exist later as sugar over editor.updatePrimary coherent read boundary:
editor.read(() => {
editor.getSelection();
editor.getChildren();
editor.getMarks();
editor.getOperations();
editor.getLastCommit();
});
Rules:
editor.read is synchronous.editor.read never imports DOM selection.editor.read sees a coherent model/runtime state.editor.read may flush pending updates if the implementation needs it.editor.read may exist only for stable live APIs that are
explicitly documented as safe.Primary write boundary:
editor.update(() => {
editor.setNodes({ type: "heading-one" });
});
Rules:
editor.update creates or reuses one transaction.at never import DOM selection.at use the transaction target.EditorCommit.Keep flexible primitives as the main power-user/plugin API:
editor.setNodes(props, options?)editor.unsetNodes(key, options?)editor.wrapNodes(element, options?)editor.unwrapNodes(options?)editor.insertNodes(nodes, options?)editor.removeNodes(options?)editor.mergeNodes(options?)editor.splitNodes(options?)editor.moveNodes(options?)editor.insertText(text, options?)editor.delete(options?)editor.insertFragment(fragment, options?)editor.select(selection)These methods must be safe inside editor.update.
Convenience methods are allowed, but they are not the architecture:
editor.toggleMark('bold')editor.toggleBlock('heading-one')Do not grow core convenience methods for every app-specific node family.
Custom node families should be implemented from primitives inside
editor.update.
Optional later sugar:
editor
.chain()
.unwrapNodes({ match: isList })
.setNodes({ type: "list-item" })
.wrapNodes({ type: "bulleted-list", children: [] })
.run();
Rules:
chain().run() is sugar over editor.update.focus() ceremony for ordinary toolbar commands.Public extension shape:
editor.extend({
name: "todo",
methods: {
toggleTodo() {
this.update(() => {
this.setNodes({ type: "todo", checked: true });
});
},
},
});
Named package shape:
const TodoExtension = defineEditorExtension({
name: "todo",
methods: {
toggleTodo() {
this.update(() => {
this.setNodes({ type: "todo", checked: true });
});
},
},
normalizers: [],
commands: [],
});
Rules:
editor.updateTarget freshness is internal:
editor.update
-> active transaction
-> tx.resolveTarget()
-> target runtime asks slate-react only when an implicit current-selection
mutation needs browser target freshness
Rules:
ateditor.readeditor.getSelectionslate-react runtime, not coreReact receives runtime facts, not responsibility for editor truth:
React must not rely on Editor.getSnapshot() for urgent render paths.
Direct DOM sync rules:
Cut from primary public API/docs/examples:
Transforms.*editor.selectioneditor.childreneditor.markseditor.operationseditor.applyeditor.onChangeReactEditor.runCommanddecorate as primary overlay APIAllowed internally:
tx.resolveTargetFiles:
.tmp/slate-v2/packages/slate/src/interfaces/editor.ts.tmp/slate-v2/packages/slate/src/create-editor.ts.tmp/slate-v2/packages/slate/src/core/public-state.tsImplement:
editor.read(fn)editor.update(fn, options?)Editor.read(editor, fn)Editor.update(editor, fn, options?)history-pushhistory-mergepastecollabskip-dom-selectionskip-scrollEditorCommitTests:
.tmp/slate-v2/packages/slate/test/read-update-contract.ts.tmp/slate-v2/packages/slate/test/transaction-contract.tsFiles:
.tmp/slate-v2/packages/slate/src/transforms-node/**.tmp/slate-v2/packages/slate/src/transforms-text/**.tmp/slate-v2/packages/slate/src/transforms-selection/**.tmp/slate-v2/packages/slate/src/editor/**Implement:
editor.update or reuses active
updateat is
omittedat bypasses DOM freshnessTests:
.tmp/slate-v2/packages/slate/test/primitive-method-runtime-contract.ts.tmp/slate-v2/packages/slate/test/editor-methods-contract.ts.tmp/slate-v2/packages/slate/test/transaction-target-runtime-contract.tsFiles:
.tmp/slate-v2/packages/slate/src/core/extension-registry.ts.tmp/slate-v2/packages/slate/src/interfaces/editor.ts.tmp/slate-v2/packages/slate/test/extension-methods-contract.tsImplement:
editor.extend({ name, methods, normalizers, commands })defineEditorExtension(...)editor.updateFiles:
.tmp/slate-v2/packages/slate-react/src/components/slate.tsx.tmp/slate-v2/packages/slate-react/src/components/editable.tsx.tmp/slate-v2/packages/slate-react/src/editable/**.tmp/slate-v2/packages/slate-react/src/hooks/**Implement:
slate-react installs target runtime for implicit write targetsslate-react owns DOM import/export/repairEditor.getSnapshot()Tests:
.tmp/slate-v2/packages/slate-react/test/target-runtime-contract.ts.tmp/slate-v2/packages/slate-react/test/dom-text-sync-contract.ts.tmp/slate-v2/packages/slate-react/test/large-doc-and-scroll.tsx.tmp/slate-v2/packages/slate-react/test/projections-and-selection-contract.tsxFiles:
.tmp/slate-v2/packages/slate/src/interfaces/editor.ts.tmp/slate-v2/packages/slate/src/index.ts.tmp/slate-v2/site/examples/ts/**docs/slate-v2/**Cut:
Transforms.*apply/onChange extension pointsTests:
.tmp/slate-v2/packages/slate/test/public-field-hard-cut-contract.ts.tmp/slate-v2/packages/slate/test/write-boundary-contract.tsFiles:
.tmp/slate-v2/packages/slate-browser/src/playwright/**.tmp/slate-v2/playwright/integration/examples/**Generate scenario families:
Every user-facing row asserts:
Required gates:
bun run bench:react:rerender-breadth:local
REACT_HUGE_COMPARE_BLOCKS=5000 REACT_HUGE_COMPARE_ITERATIONS=5 REACT_HUGE_COMPARE_TYPE_OPS=10 bun run bench:react:huge-document:legacy-compare:local
bun run bench:core:observation:compare:local
bun run bench:core:huge-document:compare:local
Perf target:
editor.read and editor.update are the public lifecycle contracteditor.updateTransforms.* is removed from primary docs/examplesslate-react consumes live reads/dirty commits, not full snapshots, for urgent
pathsfocus().chain().run() ceremonyslate/compatPivot to a strict Editor Method Runtime Contract: method-first editor APIs backed by transaction-owned lazy target resolution.
Do not expose command policy objects, command kinds, dispatchCommand, or
ReactEditor.runCommand as the normal user/plugin API.
The public DX should be:
editor.toggleBlock("heading-one");
editor.toggleMark("bold");
editor.setNodes({ type: "heading-one" });
editor.insertText("x");
editor.deleteBackward();
The internal architecture should be:
all mutating editor methods
-> Editor.withTransaction
-> tx.resolveTarget(options)
-> if explicit at: use explicit target
-> if implicit target: lazily resolve target through runtime selection authority
-> operations
-> commit
-> history/render/DOM repair
Batch 6 fixed DOM bridge/caret truth. It did not fix app command authority.
The next regression class is worse for DX: toolbar and plugin commands can still read stale model selection and mutate the wrong block. The concrete tracer:
That means Slate v2 still lets app/plugin mutation bypass the runtime selection target freshness invariant.
The real owner is not "add editor methods." Slate v2 already has many editor
methods and some Transforms.* delegation. The owner is guaranteeing that
every selection-dependent editor method and every temporary transform wrapper
resolves implicit targets through the same transaction target runtime.
Target freshness belongs inside transaction target resolution.
Not:
ReactEditor.runCommand(editor, () => {
Transforms.setNodes(editor, { type: "heading-one" });
});
Not:
editor.dispatchCommand({
kind: "set-block",
policy: "import-dom-before-command",
});
Not:
editor.registerCommand("toggleTodo", {
kind: "selection-transform",
run() {},
});
The final API must not require normal app or plugin authors to know DOM import policy.
If an editor method implicitly targets current selection, the transaction must resolve a fresh target before mutation.
Generic model selection reads stay model-only. DOM import belongs to target resolution, not to every selection read.
That is an editor runtime invariant.
Plugin authors should write:
editor.toggleTodo = (options) => {
editor.setBlock({ type: "todo", checked: options.checked });
};
The safety lives in editor.setBlock(...) resolving its implicit target through the transaction runtime, not in the plugin author choosing a policy.
Primary API is editor methods:
editor.getSelection()editor.select(range)editor.setNodes(props, options?)editor.setBlock(propsOrType, options?)editor.toggleBlock(type, options?)editor.toggleMark(mark, options?)editor.insertText(text, options?)editor.deleteBackward(options?)editor.deleteForward(options?)editor.deleteFragment(options?)editor.insertFragment(fragment, options?)editor.insertBreak(options?)Transforms.* is not part of the final primary API.
There is no slate/compat package for this rewrite.
If a temporary in-package wrapper exists during execution, it is only a staging mechanism while tests are migrated in the same plan. It is not documented, exported as a migration story, or treated as a long-term API.
Docs and examples teach editor methods only.
All mutating editor methods run through Editor.withTransaction.
Nested method calls share the active transaction.
withTransaction owns:
withTransaction does not eagerly import DOM selection on every mutation.
It imports only when a transaction resolves an implicit current-selection target.
Freshness is triggered by:
tx.resolveTarget(options) when no explicit at is providedFreshness is not triggered by:
editor.getSelection() / tx.getModelSelection() readsatExamples:
editor.setNodes({ type: "heading-one" });
// implicit target; tx.resolveTarget imports current DOM selection if DOM owns selection
editor.setNodes({ type: "heading-one" }, { at: [1] });
// explicit target; no DOM import required
editor.insertText("x");
// input pipeline already model-owned; target freshness no-ops
Core stays React-free.
Core owns a framework-neutral target runtime hook:
editor.targetRuntime = {
resolveImplicitTarget(editor, request) {
return Editor.getLiveSelection(editor);
},
};
slate-react installs the browser implementation:
resolveImplicitTarget(editor, request) {
if (domSelectionBelongsToEditor(editor) && domSelectionIsAuthoritativeForTarget(editor)) {
return importDOMSelectionAsTarget(editor)
}
return Editor.getLiveSelection(editor)
}
The request may include internal diagnostics such as:
But normal plugin authors never pass this manually.
Authority remains internal runtime state:
These states guide targetRuntime.resolveImplicitTarget.
They are not public command policy knobs.
Plugin authors extend editor methods through a deterministic extension API, not free transforms and not arbitrary instance monkeypatching.
Good:
editor.extend({
methods: {
toggleTodo(options) {
this.setBlock({ type: "todo", checked: options.checked });
},
},
});
Also good for package authors that want a named extension unit:
const TodoExtension = defineEditorExtension({
methods: {
toggleTodo(options) {
this.setBlock({ type: "todo", checked: options.checked });
},
},
});
Bad:
editor.toggleTodo = (options) => {
this.setBlock({ type: "todo", checked: options.checked });
};
Lower-level extensions can still use transactions inside registered methods:
editor.extend({
methods: {
myTransform(options) {
return Editor.withTransaction(this, (tx) => {
const selection = tx.getSelection();
// selection is fresh if needed
});
},
},
});
Bad:
editor.myTransform = (options) =>
Editor.withTransaction(editor, (tx) => {
const selection = tx.getSelection();
});
Bad:
Transforms.setNodes(editor, props);
Bad:
editor.selection;
Bad:
editor.registerCommand("x", { policy: "formatting" });
The command registry may exist internally, but the public plugin surface is method-first.
Transactions are the only mutation boundary.
All document, selection, mark, operation, history, and runtime notification changes must flow through:
editor method
-> Editor.withTransaction
-> tx.apply / tx helpers
No public API may apply operations or mutate runtime state outside a transaction.
Collaboration/history/import code may apply operations, but it must enter through a named editor method or transaction API that creates a commit.
Bad:
editor.apply(op);
editor.operations.push(op);
editor.selection = range;
Good:
editor.applyOperation(op);
editor.withTransaction((tx) => tx.apply(op));
editor.select(range);
editor.applyOperation(...) is allowed only if it creates/uses a transaction
and emits the same commit metadata as other writes.
<Slate> / Editable installs:
It should not override every editor method.
It should provide the runtime hook that transactions call when needed.
Hard cut from public primary API/docs:
Transforms.*editor.selection reads/writeseditor.children writeseditor.marks writeseditor.operations queue accesseditor.onChange as extension pointeditor.apply as monkeypatch pointNo slate/compat.
The editor may keep private/internal storage for:
But public access goes through methods:
editor.getSelection();
editor.getChildren();
editor.getMarks();
editor.getOperations();
Public writes go through editor methods:
editor.select(range)
editor.setNodes(props)
editor.insertText(text)
editor.withTransaction(...)
editor.selection is stale-by-default and must not appear in app/plugin UI
command code.
Policy-based APIs leak runtime internals.
If a plugin author must choose between:
formattingselection-transformui-onlyimport-dom-before-commandthe architecture failed.
The method already knows whether it needs implicit selection.
The transaction already knows whether implicit target resolution has been requested.
The React runtime already knows whether DOM selection is authoritative.
So the safest API is:
editor.setNodes(props);
editor.setNodes(props, { at });
No extra policy.
Lexical hides this inside updates and command dispatch. Selection is part of editor state, and DOM selection update is handled in update reconciliation.
Slate v2 should not copy Lexical's data model. It should copy the discipline: mutations run inside a runtime-owned update/transaction.
ProseMirror commands receive state, dispatch, and view; transactions own
selection and document changes.
Slate v2 should not copy ProseMirror's schema model. It should copy the discipline: commands do not randomly mutate stale editor fields.
Edix methods operate on current selection with aggressive operation/selection tests.
Slate v2 should copy the test discipline: command + selection behavior must be proved through generated browser scenarios, not one-off examples.
Before new code, inventory the actual current state.
Do:
.tmp/slate-v2/packages/slate/src/interfaces/editor.tsEditor.withTransactionTransforms.* wrappers that already delegate to editor methods and
wrappers that still bypass the method runtime.tmp/slate-v2/packages/slate/**.tmp/slate-v2/packages/slate-react/**.tmp/slate-v2/site/examples/ts/**Transforms.* use in examples and React-facing testsOutput:
atDo not:
Status: complete.
Commands:
rg -n "editor\\.(selection|children|marks|operations|onChange|apply)\\b|Transforms\\." packages/slate packages/slate-react site/examples/ts -g "*.ts" -g "*.tsx"
High-signal findings:
| Area | Current state | Contract status | Owner |
|---|---|---|---|
| Existing editor methods | createEditor() already wires many methods including setNodes, insertText, insertFragment, deleteBackward, select, withTransaction, getChildren, getLiveSelection, getOperations | Do not invent duplicate methods | Audit-only |
NodeTransforms | insertNodes, liftNodes, mergeNodes, moveNodes, removeNodes, setNodes, splitNodes, unsetNodes, unwrapNodes, wrapNodes delegate to editor methods | Mostly good as temporary wrappers | Keep until primary API migration, then remove from docs/examples |
SelectionTransforms | collapse, deselect, move, select, setPoint, setSelection delegate to editor methods | Mostly good as temporary wrappers | Keep until primary API migration, then remove from docs/examples |
TextTransforms.insertText | Has direct logic and calls Transforms.delete, Transforms.setSelection, Transforms.deselect, applyOperation | Bypasses final method runtime shape | Core method runtime owner |
TextTransforms.removeText | Reads Editor.getSnapshot(editor).selection?.anchor | Stale-by-default; wrong hot/read source | Core method runtime owner |
Editor.addMark/removeMark | Use getCurrentSelection, then call Transforms.setNodes/unsetNodes for expanded selections | Needs lazy target freshness for implicit target | Core method runtime owner |
Editor.deleteBackward/deleteForward/deleteFragment | Read editor.selection directly and call Transforms.delete | P0 stale selection risk | First core owner after browser tracer |
Editor.insertText | Reads getCurrentSelection, delegates back through Transforms.insertNodes/insertText | Needs method runtime contract | Core method runtime owner |
core/public-state.ts | Initializes and publishes from editor.selection, editor.marks, editor.operations; uses editor.apply fallback internally | Required storage exists, but public fields are still source pressure | Public field hard-cut owner |
transforms-node/* | Many internal transform helpers call Transforms.*; lift-nodes and delete-text can fall back to editor.apply | Some are internal composition, but final write boundary must be transaction-only | Core write-boundary owner |
slate-react runtime | Uses Transforms.* in caret, selection, clipboard, model input, DOM repair, Android manager | Runtime internal usage can remain temporarily, but must flow through editor methods before closure | React migration owner |
| React hooks | use-selected, use-slate-selection read editor.selection | Stale/public-field pressure | React public-field owner |
| Examples | richtext, inlines, markdown-shortcuts, images, check-lists, mentions, etc. still call Transforms.* | Public DX is not final | Example migration owner |
| UI command tracer | richtext.tsx toolbar toggleBlock still calls Transforms.unwrapNodes, Transforms.setNodes, Transforms.wrapNodes | Direct source of paragraph-2 heading bug | Phase 1 red test owner |
First owner:
Do not implement before red:
Add a red browser test for the reported bug.
Scenario:
Primary file:
.tmp/slate-v2/playwright/integration/examples/richtext.test.tsExpected first owner:
Status: red-green-partial, then replan required.
Actions:
richtext.test.ts:
slate-react DOM selection runtime from Editable.setNodes implicit target resolution from snapshot selection to
fresh target.Red evidence:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "toolbar heading" --workers=1 --retries=0
Initial failure:
Partial green:
New red:
0 instead of advancing to offset 1Rejected implementation tactic:
getCurrentSelection
read is too broadOwner classification:
Decision:
getCurrentSelection freshness tactic unless it is
constrained to transaction target resolutionNext move:
getCurrentSelection into an explicit
transaction target resolver used by selection-dependent editor methodstx.resolveTarget({}) instead of changing every
current-selection readStatus: partial success; continue with formal transaction API and broader contracts.
Actions:
getCurrentSelection.EditorTargetRuntimeTargetFreshnessRequestresolveImplicitTarget(editor, fallback)setTargetRuntime(editor, runtime)setNodes implicit target resolution to call
resolveImplicitTarget(...).slate-react target runtime from Editable that imports DOM
selection only for implicit target resolution.Evidence:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "generated navigation and typing|toolbar heading" --workers=1 --retries=0
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "toolbar heading|generated|selection|caret|paste over selected" --workers=1 --retries=0
bun test ./packages/slate/test/transaction-contract.ts --bail 1
bun test ./packages/slate/test/snapshot-contract.ts --bail 1
bun test ./packages/slate-react/test/selection-controller-contract.ts --bail 1
bun test ./packages/slate-react/test/editing-kernel-contract.ts --bail 1
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/dom-repair-policy-contract.ts --bail 1
bun run bench:react:rerender-breadth:local
bunx turbo typecheck --filter=./packages/slate --filter=./packages/slate-react --filter=./packages/slate-browser --force
Results:
2 passed18 passed23 passed190 passedslate-dom/dist/index.d.ts aliasing
issue:
Cannot find name 'BaseEditor'. Did you mean 'BaseEditor$1'?Cannot find name 'Editor'. Did you mean 'Editor$1'?Owner classification:
setNodes rather than tx.resolveTargetRejected tactics:
getCurrentSelectionCheckpoint:
setNodes uses target freshness so fartx.resolveTarget is not formalizedtransaction-target-runtime-contract.tstx.resolveTarget(...) / tx.getModelSelection() as formal transaction
API, move setNodes to use it, and add core contract testsgetCurrentSelectionslate-dom d.ts aliasing blocker as this lane's failureStatus: complete for the first target-runtime slice.
Actions:
getCurrentSelection.TargetFreshnessRequestEditorTargetRuntimesetTargetRuntime(editor, runtime)resolveImplicitTarget(editor, fallback)tx.getModelSelection()tx.resolveTarget({ at? })setNodes implicit target resolution through tx.resolveTarget.slate-react target runtime in Editable and scoped DOM import
to implicit target resolution only.editor.getSelection() and tx.getModelSelection() model-only.Evidence:
bun test ./packages/slate/test/transaction-target-runtime-contract.ts --bail 1
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "generated navigation and typing|toolbar heading" --workers=1 --retries=0
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "toolbar heading|generated|selection|caret|paste over selected" --workers=1 --retries=0
bun test ./packages/slate/test/transaction-contract.ts --bail 1
bun test ./packages/slate/test/snapshot-contract.ts --bail 1
bun test ./packages/slate-react/test/selection-controller-contract.ts --bail 1
bun test ./packages/slate-react/test/editing-kernel-contract.ts --bail 1
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/dom-repair-policy-contract.ts --bail 1
bun run bench:react:rerender-breadth:local
bunx turbo typecheck --filter=./packages/slate --filter=./packages/slate-react --filter=./packages/slate-browser --force
Results:
3 passed2 passed18 passed23 passed190 passedslate-dom/dist/index.d.ts aliasing:
Cannot find name 'BaseEditor'. Did you mean 'BaseEditor$1'?Cannot find name 'Editor'. Did you mean 'Editor$1'?Cannot find name 'Ancestor'. Did you mean 'Ancestor$1'?Owner classification:
Rejected tactics:
getCurrentSelection freshnessCheckpoint:
Transforms.*setNodes uses tx.resolveTarget so farTransforms.*slate-dom d.ts aliasing blockerpublic-field-hard-cut-contract.tseditor.selection
reads in React-facing code with method/runtime readsslate/compatAdd the framework-neutral target runtime hook.
Primary files:
.tmp/slate-v2/packages/slate/src/core/public-state.ts.tmp/slate-v2/packages/slate/src/core/transaction.ts.tmp/slate-v2/packages/slate/src/core/apply.ts.tmp/slate-v2/packages/slate/src/interfaces/editor.tsRequired behavior:
Editor.withTransaction creates or reuses the active transaction.tx.getModelSelection() returns model selection without DOM import.tx.resolveTarget({ at }) returns explicit at without DOM import.tx.resolveTarget({}) lazily requests a fresh implicit target.slate.Tests:
.tmp/slate-v2/packages/slate/test/transaction-target-runtime-contract.tsScenarios:
at does not request freshnessPromote editor methods as the only primary write API.
Primary files:
.tmp/slate-v2/packages/slate/src/create-editor.ts.tmp/slate-v2/packages/slate/src/interfaces/editor.ts.tmp/slate-v2/packages/slate/src/transforms-node/**.tmp/slate-v2/packages/slate/src/transforms-selection/**.tmp/slate-v2/packages/slate/src/transforms-text/**Required methods:
editor.selecteditor.setNodeseditor.setBlockeditor.toggleBlockeditor.toggleMarkeditor.insertTexteditor.insertFragmenteditor.insertBreakeditor.deleteBackwardeditor.deleteForwardeditor.deleteFragmenteditor.getSelectioneditor.getChildreneditor.getMarkseditor.getOperationsRules:
Editor.withTransactionat avoid implicit target freshnessat use lazy target freshnessTransforms.* imports are removed from docs/exampleseditor.selection reads in new tests/docs/examplesTests:
.tmp/slate-v2/packages/slate/test/editor-methods-contract.ts.tmp/slate-v2/packages/slate/test/public-field-hard-cut-contract.tsHard cut stalable fields from the public type/documented surface.
Primary files:
.tmp/slate-v2/packages/slate/src/interfaces/editor.ts.tmp/slate-v2/packages/slate/src/create-editor.ts.tmp/slate-v2/packages/slate/src/core/public-state.ts.tmp/slate-v2/packages/slate/src/index.tsCut from public primary surface:
editor.selectioneditor.children writeseditor.markseditor.operationseditor.onChangeeditor.apply monkeypatchingReplace with method API:
editor.getSelection()editor.getChildren()editor.getMarks()editor.getOperations()editor.subscribe(listener)editor.applyOperation(op) or equivalent final methodRules:
slate/compatTests:
.tmp/slate-v2/packages/slate/test/public-field-hard-cut-contract.tsInstall browser implicit-target resolution in slate-react.
Primary files:
.tmp/slate-v2/packages/slate-react/src/components/slate.tsx.tmp/slate-v2/packages/slate-react/src/components/editable.tsx.tmp/slate-v2/packages/slate-react/src/editable/selection-controller.ts.tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts.tmp/slate-v2/packages/slate-react/src/editable/editing-kernel.tsRequired behavior:
Tests:
.tmp/slate-v2/packages/slate-react/test/target-runtime-contract.ts.tmp/slate-v2/packages/slate-react/test/editing-kernel-contract.ts.tmp/slate-v2/packages/slate-react/test/selection-controller-contract.tsMigrate examples from Transforms.* and stale public fields to editor methods.
Primary files:
.tmp/slate-v2/site/examples/ts/richtext.tsx.tmp/slate-v2/site/examples/ts/hovering-toolbar.tsx.tmp/slate-v2/site/examples/ts/markdown-shortcuts.tsx only after Android
scheduling is accounted forTransforms.* useRules:
editor.selection reads in toolbar command logicTransforms.* imports in examples after the migration sliceeditor.extend({ methods: ... }), not assignment-based
monkeypatchingTests:
Add generated scenario coverage for app/plugin commands.
Required scenario families:
Every row asserts:
Primary files:
.tmp/slate-v2/packages/slate-browser/src/playwright/index.ts.tmp/slate-v2/playwright/integration/examples/richtext.test.ts.tmp/slate-v2/playwright/integration/examples/highlighted-text.test.ts.tmp/slate-v2/playwright/integration/examples/inlines.test.tsAfter method API, hard cuts, and React runtime are green:
Transforms.* as public APIDo not create a compatibility namespace.
If a temporary wrapper remains inside the package while tests migrate, it must be explicitly listed as temporary execution debt and removed before closure.
Core:
bun test ./packages/slate/test/public-field-hard-cut-contract.ts --bail 1
bun test ./packages/slate/test/write-boundary-contract.ts --bail 1
bun test ./packages/slate/test/extension-methods-contract.ts --bail 1
bun test ./packages/slate/test/transaction-target-runtime-contract.ts --bail 1
bun test ./packages/slate/test/editor-methods-contract.ts --bail 1
bun test ./packages/slate/test/transaction-contract.ts --bail 1
bun test ./packages/slate/test/snapshot-contract.ts --bail 1
React:
bun test ./packages/slate-react/test/target-runtime-contract.ts --bail 1
bun test ./packages/slate-react/test/editing-kernel-contract.ts --bail 1
bun test ./packages/slate-react/test/selection-controller-contract.ts --bail 1
Browser:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "heading|toolbar|app command|selection" --workers=1 --retries=0
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/inlines.test.ts ./playwright/integration/examples/highlighted-text.test.ts --project=chromium --project=firefox --project=webkit --project=mobile --workers=4 --retries=0
Perf:
bun run bench:react:rerender-breadth:local
REACT_HUGE_COMPARE_BLOCKS=5000 REACT_HUGE_COMPARE_ITERATIONS=5 REACT_HUGE_COMPARE_TYPE_OPS=10 bun run bench:react:huge-document:legacy-compare:local
Build/type/lint:
bunx turbo build --filter=./packages/slate --filter=./packages/slate-dom --filter=./packages/slate-react --filter=./packages/slate-browser --force
bunx turbo typecheck --filter=./packages/slate --filter=./packages/slate-dom --filter=./packages/slate-react --filter=./packages/slate-browser --force
bun run lint:fix
bun run lint
Editor.withTransactionat methods do not import DOM selectionslate/compatslate-react installs selection runtime once, not per methodeditor.extend({ methods }) and
editor methods without policiesEditor.withTransactionTransforms.* is removed from docs/examples as primary API; no final compat
namespace existsTransforms.* for UI commandsReactEditor.runCommand public ceremonykind taxonomy exposed to plugin authorsslate/compatThe absolute-best direction is:
method-first public API
transaction-owned lazy target resolution
React-installed selection runtime
hard-cut public mutable fields
deterministic extension methods
transaction-only write boundary
generated app-command browser gauntlets
If plugin authors need to understand DOM selection import policy, the architecture failed.
Status: complete for the first target-runtime and richtext method slice; plan remains open.
Actions:
getCurrentSelection freshness tactic.TargetFreshnessRequestEditorTargetRuntimesetTargetRuntime(editor, runtime)resolveImplicitTarget(editor, fallback)tx.getModelSelection()tx.resolveTarget({ at? })setNodes implicit target resolution through tx.resolveTarget.slate-react target runtime in Editable; it imports DOM selection
only for implicit target resolution.transaction-target-runtime-contract.ts.site/examples/ts/richtext.tsx block toolbar commands from
Transforms.* to editor methods:
editor.unwrapNodeseditor.setNodeseditor.wrapNodeseditor.selection in useSelected and
useSlateSelection.Evidence:
bun test ./packages/slate/test/transaction-target-runtime-contract.ts --bail 1
bun test ./packages/slate/test/transaction-contract.ts --bail 1
bun test ./packages/slate/test/snapshot-contract.ts --bail 1
bun test ./packages/slate-react/test/selection-controller-contract.ts --bail 1
bun test ./packages/slate-react/test/editing-kernel-contract.ts --bail 1
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/dom-repair-policy-contract.ts --bail 1
bunx vitest run --config ./vitest.config.mjs test/use-selected.test.tsx
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "generated navigation and typing|toolbar heading" --workers=1 --retries=0
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "toolbar heading|generated|selection|caret|paste over selected" --workers=1 --retries=0
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "toolbar heading|renders rich text|generated mark typing" --workers=1 --retries=0
bun run bench:react:rerender-breadth:local
bun run lint:fix
bun run lint
bunx turbo typecheck --filter=./packages/slate --filter=./packages/slate-react --filter=./packages/slate-browser --force
Results:
3 passed23 passed190 passeduseSelected test: 4 passed2 passed18 passed3 passedslate-dom/dist/index.d.ts
aliasing issue, not this slice:
Cannot find name 'BaseEditor'. Did you mean 'BaseEditor$1'?Cannot find name 'Editor'. Did you mean 'Editor$1'?Cannot find name 'Ancestor'. Did you mean 'Ancestor$1'?Owner classification:
Transforms.*Rejected tactics:
ReactEditor.runCommandNext move:
public-field-hard-cut-contract.ts and
write-boundary-contract.ts incrementally, starting with type/runtime
contracts that prevent new React-facing editor.selection reads and public
operation writes.Status: complete for target-fresh mark methods and the discovered direct-DOM composition fallback gap; plan remains open.
Actions:
editor.toggleMark(key, value?) as the method-first public mark toggle.Editor.addMark and Editor.removeMark selection decisions inside
Editor.withTransaction and tx.resolveTarget().editor.toggleMark(...).editor.selection read in block active
state; it uses editor.getSelection().mark-button-* test id for app-command browser proof.addMark targeting the transaction-resolved implicit targetremoveMark targeting the transaction-resolved implicit targettoggleMark deciding active state from the transaction-resolved targetsyncTextOperationsToDOM reports text-op sync countsSlate forces React selector updates only when text operations were not
directly DOM-syncedcollapseToEnd() unsafelylarge-doc-and-scroll React event fixtures to use
withReact(createEditor()) instead of a bare core editor.slate public method.docs/solutions/ui-bugs/2026-04-23-slate-react-unsynced-dom-text-ops-must-force-react-fallback.md.Commands:
bun test ./packages/slate/test/transaction-target-runtime-contract.ts --bail 1
bun test ./packages/slate/test/editor-methods-contract.ts --bail 1
bun test ./packages/slate/test/transaction-contract.ts --bail 1
bun test ./packages/slate/test/snapshot-contract.ts --bail 1
bun test ./packages/slate-react/test/selection-controller-contract.ts --bail 1
bun test ./packages/slate-react/test/editing-kernel-contract.ts --bail 1
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/dom-repair-policy-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "toolbar bold|toolbar heading|generated mark typing" --workers=1 --retries=0
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate --filter=./packages/slate-react --filter=./packages/slate-browser --force
bunx turbo typecheck --filter=./packages/slate --filter=./packages/slate-react --filter=./packages/slate-browser --force
bun run bench:react:rerender-breadth:local
REACT_HUGE_COMPARE_BLOCKS=5000 REACT_HUGE_COMPARE_ITERATIONS=5 REACT_HUGE_COMPARE_TYPE_OPS=10 bun run bench:react:huge-document:legacy-compare:local
Evidence:
4 passed3 passed23 passed190 passed2 passed3 passed1 passed2 passed15 passed6 passed3 passed
slate, slate-react, and slate-browser passedslate-dom/dist/index.d.ts
aliasing, not this slice:
Cannot find name 'BaseEditor'. Did you mean 'BaseEditor$1'?Cannot find name 'Editor'. Did you mean 'Editor$1'?Cannot find name 'Ancestor'. Did you mean 'Ancestor$1'?0; v2 remains far faster than
legacy chunk-off and faster than chunk-on for ready, start typing,
promoted-middle typing, and full-document replacement/fragment. Current run
still shows the accepted first-activation class:
v2 middleBlockTypeMs mean 53.66ms vs legacy chunk-on 33.27msv2 middleBlockSelectThenTypeMs mean 48.53ms vs legacy chunk-on 32.88msv2 middleBlockPromoteThenTypeMs mean 13.94ms vs legacy chunk-on 31.28msv2 readyMs mean 12.05ms vs legacy chunk-on 291.63msArtifacts:
/Users/zbeyens/git/slate-v2/packages/slate/test/editor-methods-contract.ts/Users/zbeyens/git/slate-v2/packages/slate/test/transaction-target-runtime-contract.ts/Users/zbeyens/git/slate-v2/playwright/integration/examples/richtext.test.ts/Users/zbeyens/git/slate-v2/packages/slate-react/test/large-doc-and-scroll.tsx/Users/zbeyens/git/slate-v2/.changeset/editor-method-target-fresh-marks.mdHypothesis:
Decision:
editor.toggleMark(...), not manually read
marks and choose addMark/removeMark.Owner classification:
slate-dom declaration alias ownerChanged files:
.tmp/slate-v2/packages/slate/src/core/public-state.ts.tmp/slate-v2/packages/slate/src/create-editor.ts.tmp/slate-v2/packages/slate/src/editor/add-mark.ts.tmp/slate-v2/packages/slate/src/editor/remove-mark.ts.tmp/slate-v2/packages/slate/src/editor/toggle-mark.ts.tmp/slate-v2/packages/slate/src/editor/index.ts.tmp/slate-v2/packages/slate/src/editor/is-editor.ts.tmp/slate-v2/packages/slate/src/interfaces/editor.ts.tmp/slate-v2/packages/slate/test/editor-methods-contract.ts.tmp/slate-v2/packages/slate/test/transaction-target-runtime-contract.ts.tmp/slate-v2/packages/slate-react/src/components/slate.tsx.tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.ts.tmp/slate-v2/packages/slate-react/src/hooks/use-slate-node-ref.tsx.tmp/slate-v2/packages/slate-react/test/large-doc-and-scroll.tsx.tmp/slate-v2/site/examples/ts/richtext.tsx.tmp/slate-v2/playwright/integration/examples/richtext.test.ts.tmp/slate-v2/.changeset/editor-method-target-fresh-marks.mddocs/solutions/ui-bugs/2026-04-23-slate-react-unsynced-dom-text-ops-must-force-react-fallback.mdRejected tactics:
ReactEditor.runCommandgetCurrentSelection freshnessCheckpoint:
setNodestoggleBlock/setBlock are still example-owned logic, not core methodsTransforms.*slate-dom declarationssetBlock/toggleBlock editor-method contract with stale
active-state target proofeditor.setBlock / editor.toggleBlock or a narrower
block-format method API that removes stale active-state logic from
richtext block toolbar without exposing command policiesTransforms.* examples remainStatus: complete for basic block-format method ownership; plan remains open.
Actions:
editor.setBlock(props, options?)editor.toggleBlock(type, options?)Editor.withTransaction and
tx.resolveTarget({ at }).toggleBlock decides active state from the
transaction-resolved implicit target, not stale model selection.editor.toggleBlock(...).heading-one but
browser target is paragraph 2; clicking heading makes paragraph 2 a heading
instead of toggling it back to paragraph.setBlock and toggleBlock.Commands:
bun test ./packages/slate/test/editor-methods-contract.ts --bail 1
bun test ./packages/slate/test/transaction-target-runtime-contract.ts --bail 1
bun test ./packages/slate/test/transaction-contract.ts --bail 1
bun test ./packages/slate/test/snapshot-contract.ts --bail 1
bun test ./packages/slate/test/surface-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "toolbar bold|toolbar heading|generated mark typing" --workers=1 --retries=0
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate --filter=./packages/slate-react --filter=./packages/slate-browser --force
bunx turbo typecheck --filter=./packages/slate --filter=./packages/slate-react --filter=./packages/slate-browser --force
bun run bench:core:observation:compare:local
bun run bench:core:huge-document:compare:local
Evidence:
4 passed4 passed23 passed190 passed10 passed15 passed6 passed4 passedslate, slate-react, and slate-browser passedslate and slate-browser passed; slate-react remains blocked
by the known generated slate-dom/dist/index.d.ts aliasing issue:
Cannot find name 'BaseEditor'. Did you mean 'BaseEditor$1'?Cannot find name 'Editor'. Did you mean 'Editor$1'?Cannot find name 'Ancestor'. Did you mean 'Ancestor$1'?0; current faster than legacy on
all measured means:
0.69ms vs 1.09ms6.51ms vs 8.57ms0.88ms vs 1.71ms0; current faster or effectively
tied:
0.65ms vs 0.66ms0.39ms vs 0.51ms3.51ms vs 8.43ms4.01ms vs 8.46ms0.02ms vs 0.01msArtifacts:
/Users/zbeyens/git/slate-v2/packages/slate/src/editor/block-format.ts/Users/zbeyens/git/slate-v2/packages/slate/test/editor-methods-contract.ts/Users/zbeyens/git/slate-v2/site/examples/ts/richtext.tsx/Users/zbeyens/git/slate-v2/playwright/integration/examples/richtext.test.ts/Users/zbeyens/git/slate-v2/.changeset/editor-method-target-fresh-marks.mdHypothesis:
isBlockActive(...)
lived in example code and read stale model selection before the target runtime
resolved the browser target.Decision:
toggleBlock(type) API solves list wrapping or alignment semantics.Owner classification:
Transforms.*: migration ownerRejected tactics:
toggleBlockCheckpoint:
Transforms.*slate-react typecheck remains blocked by generated slate-dom d.tsTransforms.*
examples; choose one command-family owner instead of broad mechanical
migration