docs/slate-v2-draft/references/architecture-contract.md
Reference doc. Not a live queue owner. For current queue and roadmap truth, see ../master-roadmap.md.
This is the canonical technical north star for slate-v2.
It losslessly consolidates the four adjacent design docs that were previously competing for the same role:
engine.mdcore-foundation-spec.mddom-runtime-boundary-spec.mdreact-runtime-spec.mdUse this file when you want the full technical contract in one place.
For the live replacement verdict, family truth, and blocker list, use:
Current invariantNear-term requiredFuture directionPart I is the greenfield architecture rationale.Part II is the core slate contract.Part III is the slate-dom boundary contract.Part IV is the slate-react runtime contract.The old standalone docs are retired. Use the part anchors below instead.
Truth class:
Future directionThe current Slate rewrite is the best retrofit for the existing ecosystem:
editor.apply(op) stays the one plugin seamEditor.withBatch(editor, fn) is the explicit batch boundaryTransforms.applyBatch(editor, ops) is public sugar over the same engineThat path is correct for Slate today. It preserves plugin expectations and keeps the public API boring.
It is still a retrofit. The engine underneath is carrying real complexity:
editor.childreneditor.applyIf Slate were starting from zero, this is not the engine I would choose.
Sketch the best greenfield Slate engine, not a migration plan for the current rewrite.
Reference-only north-star doc.
Use this for architecture rationale and greenfield direction, not for the live replacement verdict or the current execution queue.
For current truth, start with:
The target is a native transaction engine where batching is the default execution model instead of a compatibility layer welded onto per-op mutation.
Just as important: this should not be a fake-neutral core designed around every framework at once.
That means:
slate-react the reference runtime, not an adapter afterthoughtThe target is not "React in the core." The target is a better core that stops fighting React.
This brainstorm is intentionally not "framework-neutral architecture."
It is shaped by two constraints:
19.2+ runtime features and constraints:
That is why the proposal keeps pushing on:
19.2+ as the target runtime, not React 18 compromise modeThose are not here because "React won, deal with it."
They are here because they improve the engine on its own merits and also happen to make a React runtime dramatically cleaner.
One more correction after broader package comparison work:
One more current-read correction:
Edix is a useful reference for headless DOM binding and explicit clipboard boundaries, but not a reason to collapse slate, slate-dom, slate-react, and slate-history back into one bucket.
This is the principle order that makes the most sense for a Slate v2:
That is the key correction from the first draft.
The first draft drifted too close to "React-first core." That is the wrong framing. The better framing is:
slate-react becomes brutally well-optimized on topSlate v2 would have four layers.
Public calls express intent. They do not mutate the committed tree directly.
Examples:
Transforms.insertNodes(...)Transforms.setNodes(...)Transforms.moveNodes(...)editor.apply(op)Those all append normalized intent into the active transaction.
A transaction object owns all mutable working state for one edit session:
That transaction is the actual runtime unit. Not the individual operation.
Plugins do not monkey-patch editor.apply. They hook named phases.
The engine should expose explicit middleware phases like:
rewriteOperationvalidateOperationbeforeTransformRefstransformRefsmutateDraftderiveDirtyPathsnormalizeDraftbeforeCommitafterCommitThat is cleaner than “save old apply, wrap it, hope you understood the timing.”
At commit, the engine publishes one new immutable snapshot:
editor.childreneditor.selectioneditor.marksNo previously published node object is ever mutated in place. Ever.
This is the part I would push harder than the first sketch.
Slate v2 should be built so slate-react fits React's model cleanly:
That does not mean the core becomes React-defined.
It means the core exposes runtime semantics that let React work the way it should have been able to all along.
The target runtime should be React 19.2+.
That changes the bar:
useEffectEvent is available and should be standard<Activity> is available and should shape hidden and background UI policyEvery commit should publish a versioned immutable editor snapshot.
Something like:
type EditorSnapshot = {
version: number;
children: Descendant[];
selection: Range | null;
marks: EditorMarks | null;
};
slate-react should subscribe to snapshots, not to a mutable editor object that happens to change under its feet.
This should follow the external-store shape React actually likes:
The important consequence is that the store contract belongs in core, not as a wrapper slate-react improvises later.
slate-react should expose slice-based subscriptions as the default rendering model.
The important unit is not "rerender because the editor changed." It is:
That means stable selectors over snapshots, ideally keyed by persistent node identity.
This should be the default mental model for slate-react:
Broad editor-wide rerenders should be treated as failure, not the baseline.
React optimization does not mean "put everything in a transition and pray."
Urgent editor work stays synchronous:
Deferred work can move out of the urgent lane:
That split should be explicit in the engine and in slate-react.
Important constraint: transitions should not be the mechanism that makes core editor mutation safe. The editor commit path should already be small, synchronous, and correct before any deferred React work starts.
This matters enough to name directly.
useEffectEventslate-react should use useEffectEvent for effect-owned event reactions like:
But Effect Events are not general stable callback identities. They stay local to effects.
<Activity><Activity> should be a first-class tool for hidden and background editor-adjacent UI:
It is not a fix for active editable-surface correctness.
If hidden UI resumes cleanly, that proves the core published a coherent snapshot model. If it resumes from stale mutable guts, the core contract is fake.
Do not let these distort the engine:
cacheSignal() is RSC-only and not part of the client editor runtime contractuse(store) external-store work is still research, not a v2 dependencyPath-only identity is one of Slate's oldest pain points.
React wants stable identity. Slate v2 should give the runtime stable identity:
That reduces remount churn, makes memoization less cursed, and gives the renderer a real handle on "what actually changed."
It also avoids one of the oldest React-hostile habits in Slate: treating array position as identity and then acting surprised when reordering hurts.
Important boundary: this does not require polluting the serialized JSON document model with React-facing ids.
The document format can stay simple while the runtime still maintains stable identity.
slate-react should have a dedicated selection bridge between the DOM and committed snapshots.
That bridge should be a real subsystem, not timing-sensitive glue spread across event handlers, render, and normalization side effects.
Package seam:
slate-dom owns browser semantics, translation, and selection rulesslate-react owns React lifecycle wiring around that boundaryslate owns the committed state those packages consumeIf Slate v2 is serious about React, these should be treated as hard rules:
useSyncExternalStore first: store subscriptions should use the standard external-store primitive instead of effect-plus-useState wrappersOne more hard rule:
The public surface could stay pretty small:
Editor.withTransaction(editor, (tx) => {
Transforms.insertNodes(editor, node);
Transforms.setNodes(editor, { color: "orange" }, { at: [0] });
Transforms.moveNodes(editor, { at: [3], to: [1] });
});
Possible rules:
editor.apply(op) remains as a low-level convenienceTransforms.* always target the active transactionTransforms.applyBatch(...) is optional and may not even need to exist in v2That means the native model is transactional even when the user writes single-op code.
The important runtime detail is that this public API should sit on top of a snapshot engine that can publish one coherent commit to React, not a chain of observable partial mutations.
This is the real win.
This is end-state direction, not a Phase 1 requirement.
Plugins should override one middleware surface, not one mutable method.
There is one useful external constraint here: explicit hook points are good, but they should stay package-local and boundary-local.
Good:
slateslate-domslate-reactslate-historyBad:
Instead of this:
const { apply } = editor;
editor.apply = (op) => {
if (op.type === "set_node" && op.newProperties?.color === "blue") {
op = {
...op,
newProperties: {
...op.newProperties,
color: "orange",
},
};
}
apply(op);
};
You would do this:
editor.rewriteOperation = (op, next) => {
if (op.type === "set_node" && op.newProperties?.color === "blue") {
op = {
...op,
newProperties: {
...op.newProperties,
color: "orange",
},
};
}
return next(op);
};
That is vastly easier to reason about:
The committed editor state should be immutable and boring:
editor.children is the last committed snapshotIf a plugin needs to inspect current in-transaction state, it should do so through the transaction object:
Editor.withTransaction(editor, (tx) => {
Transforms.insertNodes(editor, node);
const currentChildren = tx.children;
const currentSelection = tx.selection;
});
That is cleaner than accessor tricks on editor.children.
For React, this matters even more:
That is the foundation for correct external-store integration.
Normalization should also be transaction-scoped.
Bad model:
Better model:
This lets the engine optimize normalize at the right granularity instead of replaying the same work because the public API shape backed it into a corner.
These should be first-class transaction concerns, not bolt-ons.
Each committed transaction becomes one history entry by default.
If you want merge behavior, say so explicitly:
Editor.withTransaction(editor, (tx) => {
tx.history.merge = true;
// operations...
});
Selection should live in the transaction as mutable working state, then publish once.
That removes the weirdness where selection ops are half-real, half-history metadata, and half special-case garbage.
It also makes React integration cleaner because selection becomes part of the same published snapshot boundary instead of a side channel with weird timing.
Refs should update incrementally against the draft transaction, not by pretending every op is the only op that matters.
This greenfield model wins on clarity:
And the React-optimized runtime wins on product fit:
slate-react stops compensating for engine timing quirks and starts consuming snapshots the way React actually wantsIf Slate had started here, the current rewrite would be dramatically simpler.
It would also probably be a much better editor runtime for React than current Slate, not just a cleaner engine.
A transaction-first engine with a React-optimized runtime could remove some long-standing pain instead of just optimizing around it:
editor.apply override timingThis is the real case for a v2. Not novelty. Leverage.
Because it is basically a new editor engine.
The current Slate ecosystem assumes:
editor.applyapply has immediate effectsPivoting now would mean:
slate-react runtime modelThat is not a refactor. That is a major-version rewrite.
So the blunt take is:
The active path in slate-batch-engine.md is still the right one for Slate now.
Slate v2 should not be React-first in ontology.
It should be:
That means:
slate-react is allowed to be the best runtime, not a second-class adapterTwo bad extremes should both be rejected:
The winning position is narrower and better:
slate-react be excellentIf Slate ever wanted this for real, do it as an explicit major-version engine transition.
The sane rollout would be:
Anything softer becomes a half-v1, half-v2 mutant. That sounds clever right until it ruins both.
Operation remain the core primitive, or should the engine promote higher-level intents first and lower to ops later?slate-react versus core snapshot helpers?Use this as a reference architecture, not the current implementation target.
The current rewrite should keep doing the practical thing:
editor.apply(op) as the public low-level seamEditor.withBatch(...) / Transforms.applyBatch(...)Slate v2 should be transaction-first, data-model-first, and React-optimized.
Slate now should finish the retrofit and ship.
Within the current v2 proof program, Phase 5 cashout is no longer the open question.
The completed release-shaped anchor surface is:
SlateEditableBlockswithHistory(createEditor())slate-v2-rich-inlineThat lane now cashes out the proved semantics into:
The next endgame move after that cashout is package shaping on top of the stabilized surface:
New geometry proof is still allowed.
Use it only when a later lane fails for a real model reason.
Truth class:
Near-term requiredThis is the first real implementation-spec artifact for Slate v2.
It only covers Phase 0 and Phase 1 from roadmap-from-issues.md:
slate core foundationThis is not the full v2 plan.
This is the minimal foundation that has to exist before slate-dom and slate-react can be anything other than cleanup crews again.
It is also no longer allowed to freehand those runtime packages later.
This spec is constrained by:
.tmp/slate-v2/packages/*These are not up for bikeshedding in Phase 1.
slate.children, selection, marks, and ref-aligned lookup state publish atomically.Phase 1 does not try to solve:
If we try to solve those here, the core will bloat before it even exists.
That does not mean Phase 1 can ignore runtime pressure.
It means the pressure is carried as core invariants instead of DOM or React code.
Lock:
slate-dom runtime-boundary contractslate-react runtime contractslateBuild:
packages/slateThe package should mirror current Slate where that helps comprehension, but not cargo-cult the current internals.
Initial shape:
packages/slate/
src/
index.ts
create-editor.ts
editor/
interfaces/
types/
transforms-node/
transforms-selection/
transforms-text/
core/
transaction/
snapshot/
identity/
normalize/
refs/
apply/
operations/
Strong take:
core/ sprawlPhase 1 should expose the smallest honest surface:
createEditor()editor.apply(op)Transforms.*Recommended provisional seam:
Editor.withTransaction(editor, fn);
Why:
What not to add in Phase 1:
Phase 1 does not need to freeze the final public store API, but it does need the runtime contract.
That means slate must own equivalents of:
getSnapshot(editor)subscribe(editor, listener)Strong take:
slate-react must not invent its own store by watching mutable editor state from the outsideThese are the primitives that need to exist immediately.
RuntimeIdRuntime-only stable identity for nodes.
Requirements:
Recommended shape:
type RuntimeId = string;
Keep it boring. A fancy branded type can come later.
EditorSnapshotThe immutable committed editor state.
Recommended shape:
type EditorSnapshot = {
version: number;
children: Descendant[];
selection: Range | null;
marks: EditorMarks | null;
index: SnapshotIndex;
};
Where SnapshotIndex is the runtime-only sidecar for identity and lookup, not a serialized data structure.
EditorSnapshot is not just “immutable data.”
It is the runtime contract:
children, selection, or marksSnapshotIndexThe sidecar index that makes stable identity real.
Minimum responsibilities:
id -> pathpath -> idDo not make this a kitchen sink. Phase 1 only needs identity and lookup.
TransactionThe only mutable editing unit.
Recommended shape:
type Transaction = {
id: number;
baseVersion: number;
operations: Operation[];
draft: DraftRoot;
children: Descendant[];
selection: Range | null;
marks: EditorMarks | null;
normalizeDebt: NormalizeDebt;
refs: TransactionRefs;
isImplicit: boolean;
};
Phase 1 does not need rich metadata beyond that.
NormalizeDebtThe thing current Slate mostly spreads everywhere.
Minimum responsibility:
Strong take:
TransactionRefsRefs move with the transaction, not as an afterthought after every op.
Minimum responsibilities:
DraftRootPrivate mutable working tree owned by the transaction.
This can be implemented with structural sharing internally later. Phase 1 only needs the abstraction boundary:
These are the rules Phase 1 has to enforce.
editor.children always means the last committed snapshot.children.Phase 1 flow:
editor.apply(op) or Transforms.*Important:
These are the correctness lanes to freeze in Phase 0.
#5977 custom operations should not break editor detection
editor.operations#5874 duplicate node insertion by object identity
#5811 custom normalize wrap/unwrap loop
#5972 empty inline deleteBackward semantics
#5771 high-QPS remote insert_text versus local selection
Strong take:
#5771These are the benchmark lanes to freeze in Phase 0.
#6038 transaction execution and mixed structural updates
slate#5945 large plaintext paste
slate and slate-dom#5131 selection-driven rerender breadth
slate-react#3656 many-leaf rerender breadth inside one block
slate-react#3430 one paragraph with many inlines
slate-reactWhy freeze later lanes now:
packages/slate with mirrored top-level export shapeEditorSnapshot, SnapshotIndex, Transaction, RuntimeIdeditor.apply(op)Editor.withTransaction(editor, fn) seam#6038 benchmark lane against the new packagePhase 0 + 1 are done when:
slate exists as its own package#6038 benchmark lane existsDo not solve these in this spec:
Those are real tasks. They are just not Phase 1.
These are the only Phase-1-adjacent questions still worth answering:
RuntimeId be generated lazily per snapshot build or eagerly at draft mutation time?SnapshotIndex store only id/path mappings in Phase 1, or also node-object references?withTransaction share exactly the same commit pipeline, or is there a tiny fast path worth keeping?Strong take:
Start with one prototype package:
packages/slateDo not scaffold slate-dom or slate-react yet.
If the core foundation is wrong, the rest will just be expensive lipstick.
Truth class:
Near-term requiredThis is the package-level contract for slate-dom.
It exists to stop DOM ownership from smearing back into slate and slate-react.
This is the browser-facing runtime boundary:
beforeinputslate-dom owns DOM translation and browser-boundary semantics.slate-dom does not own React subscription policy or hook design.slate-dom does not own core transform or normalization semantics.slate-dom consumes committed snapshots and runtime identity. It does not peek into draft state.slate-dom exposes browser-boundary primitives that slate-react may wire through React lifecycle, without re-owning browser semantics there.This package does not try to own:
slateslate-dom forces these guarantees onto the core:
id -> pathpath -> idchildrenselectionmarksslate-dom needs a stable relationship between DOM nodes and committed editor identity.
That means:
This package owns the browser selection bridge.
Responsibilities:
This bridge must explicitly handle:
beforeinputThis package owns the browser event boundary for text entry.
Responsibilities:
beforeinput interpretationImportant rule:
This package owns DOM clipboard formats and import/export seams.
Responsibilities:
Important:
slate-dom should expose the browser-boundary part of clipboard handling without making slate-react or slate guess at fragment format detailsslate-dom must define explicit ownership for:
Phase 2 does not need a giant API.
It needs a small honest surface:
That means this package should stay an adapter layer:
Not:
Do not start with:
slate-react assumptionsThis package must be able to absorb these pressure classes honestly:
#5947#5938#5749#4789#4839#4881#6034#5826That means at minimum:
slate-dom is real enough to unblock slate-react when:
beforeinput have one clear boundary owner.slate-react can consume this bridge without re-owning low-level DOM translation.Truth class:
Near-term required for snapshot/store/selector/runtime-seam sectionsFuture direction for the more speculative runtime posture sections named
belowThis is the package-level contract for slate-react.
The target is:
19.2+Current repo baseline:
/Users/zbeyens/git/slate-v2 already runs this package surface on
React 19.2 and Next 16.2.2<Activity> docs: https://react.dev/reference/react/ActivityuseEffectEvent docs: https://react.dev/reference/react/useEffectEventslate-react targets React 19.2+ only.useSyncExternalStore are the default rendering model.useEffectEvent is the default tool for effect-owned event reactions.<Activity> is a first-class tool for hidden and background UI, not a fix for active editable-surface correctness.startTransition and useDeferredValue are for derived non-urgent UI only.slate-react must not depend on reading half-mutated editor state.This package does not try to:
use(store) APIscacheSignal() as a client runtime primitiveslate-domslateslate-react forces these guarantees onto the core:
children, selection, marks, and ref-aligned lookup state publish atomically.Truth class:
Near-term requiredThe editor runtime must expose a real external-store contract.
That means:
getSnapshot() semanticsuseSyncExternalStore as the baseline subscription primitiveuseState” wrappersStrong take:
useSlate() rerenders are failureTruth class:
Near-term requiredThe package should prefer narrow selectors like:
Selector hooks should behave like real React reads:
useMemo only for genuinely expensive derivationuseState plus useEffectThe real target is not “rerender only the selected node.”
The real target is:
Anything broader than that is runtime debt.
Truth class:
Near-term requiredslate-react should own exactly one editor-scoped headless overlay kernel.
That kernel is the canonical runtime for:
Hard rules:
slate-reactderive(snapshot) => decorations callback shapedecoration exportsThe winning split is:
slate
slate-react
slate-dom
Strong take:
useSlateAnnotations(...), useSlateWidgets(...), or any future
hook become the public source of truthTruth class:
Near-term requiredThe runtime should freeze three different overlay lanes:
Decoration
Annotation
Widget
Those lanes may share projection plumbing. They may not share ownership semantics.
Important correction:
widget is the public nounTruth class:
Near-term requiredLogical anchoring is not enough. Floating UI still needs viewport geometry.
So widget architecture must split:
WidgetAnchor
WidgetPlacement
Hard rule:
WidgetPlacement API into slate-react just because
the runtime needs internal placement dataTruth class:
Near-term requiredThe old decorate contract died because refresh timing was ambiguous.
The new runtime should require:
allpathsruntimeIdsselectionsyncdeferredComposition rule:
Truth class:
Near-term requiredIf a write happens because the user clicked, typed, pasted, dragged, or submitted something, it belongs in an event handler or command path.
That means:
Hard rule:
useEffectEventTruth class:
Near-term requireduseEffectEvent should be the standard pattern for:
Hard rule:
Effects in this package should exist only when synchronizing with something outside React:
If the problem is only “props changed” or “editor snapshot changed”, that is almost certainly not an effect problem.
Important seam:
slate-react wires lifecycle and listener ownership through slate-domslate-react does not reinterpret DOM points, selection semantics, or composition rules on its ownTruth class:
Near-term requiredThe runtime must not implement controlled mode by copying props into editor state with an effect.
Bad shape:
useEffect(() => editor.setValue(value), [value])Good shape:
Strong take:
<Activity>Truth class:
Future direction<Activity> should be a real part of the runtime story for:
It should not be used to justify sloppy active-editor semantics.
Important constraint:
Truth class:
Near-term requiredUrgent work:
Deferred work:
That means:
startTransition and useDeferredValue belong to derived UITruth class:
Future directionLarge-document behavior is not a special mode.
It is the default design target.
That means slate-react should assume:
The default posture should be:
content-visibility: autocontain-intrinsic-sizeTruth class:
Future directionThe runtime should distinguish between:
For active editing geometry:
For inactive island planning:
This is where Pretext is relevant.
Pretext is not a general rendering engine for slate-react.
It is a candidate planning primitive for:
Strong rule:
PretextPretext only where deterministic offscreen planning wins more than live DOM measurementStrong take:
slate-reactVirtualization is a later escalation layer.
It is not the baseline runtime contract.
Truth class:
Near-term requiredThis package should assume:
eslint-plugin-react-hooksuseSyncExternalStore is the default store-connection primitiveuseEffectEvent is available and standard<Activity> is available and standardThis package should not carry:
useEffectEvent isn’t there”key boundary or explicit source-of-truth contract would doTruth class:
Near-term requiredPhase 3 should expose a small but hard-edged runtime surface:
Do not start with:
This package should feel like a real React runtime, not a thin imperative wrapper around DOM binding.
That means:
slate-dom owns browser translationslate owns committed stateIf those boundaries blur, the package is drifting.
Truth class:
Near-term requiredThis package must be able to absorb these pressure classes honestly:
#5709#5697#5568#5488#5131#4612That means at minimum:
Truth class:
Near-term requiredslate-react is real enough when:
useSyncExternalStore-backed and selector-first.useEffectEvent instead of dependency-array hacks.<Activity> without state corruption or stale-editor weirdness.