docs/plans/2026-04-27-slate-v2-internal-runtime-projection-firewall-plan.md
In progress. Phase 2 decoration impact facts, Phase 3 projection subscription
facts, real source scoping, selector hook APIs, descendant/bound text selector
adoption, source-key projection refresh, and selection export policy extraction
landed. The first hard-cut shell cleanup slice landed for runtime-owned
VoidElement spacer children, and the second landed for runtime-owned
InlineVoidElement hidden anchor children. The runtime-owned void/atom shell
owner is complete; shell, event, repair, composition, and generated
slate-browser replay contracts remain the next broader lane.
Do not change the public node API first.
The current regressions are not caused by renderElement syntax being ugly.
They are caused by weak runtime ownership: void shells leak layout, selection
movement leaks into React rendering, decorations can steal focus, and examples
can accidentally own browser-critical DOM.
A shiny defineElement API on top of that would only make the bugs easier to
ship. The first serious move is an internal runtime/projection firewall behind
the current API.
Make Slate v2 browser-correct and React 19.2-fast before public API redesign.
The editor runtime owns:
React owns:
React must not be the editing engine.
defineElement / defineMark rollout in the first slice.Research decisions already accepted:
docs/research/decisions/editor-node-dx-should-use-runtime-owned-shells-and-spec-first-renderers.mddocs/research/systems/editor-node-text-mark-dx-landscape.mdCurrent Slate v2 code assets to reuse:
/Users/zbeyens/git/slate-v2/packages/slate/src/core/*/Users/zbeyens/git/slate-v2/packages/slate/src/editor/*/Users/zbeyens/git/slate-v2/packages/slate-react/src/editable/*/Users/zbeyens/git/slate-v2/packages/slate-react/src/stores/*/Users/zbeyens/git/slate-v2/packages/slate-react/src/hooks/*/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/*/Users/zbeyens/git/slate-v2/packages/slate-browser/src/*/Users/zbeyens/git/slate-v2/playwright/stress/*Do not repeat the old hard-cut slogan as if all direct mutable editor fields still exist.
Already cut as normal core API:
editor.childreneditor.selectioneditor.markseditor.operationsThe live BaseEditor uses accessor and transaction surfaces instead:
Editor.getChildren(editor) / editor.getChildren()Editor.getSelection(editor) / editor.getSelection()Editor.getSnapshot(editor) / editor.getSnapshot()Editor.getOperations(editor) / editor.getOperations()editor.withTransaction((tx) => ...)Remaining cleanup is not "remove mutable fields" again. The remaining cleanup is to make the blessed model unambiguous:
editor.apply(op), reset-style setChildren, and broad live accessors<Slate onChange> as a React adapter callback, not a core editor fieldThis distinction matters. The architecture plan should cut the real remaining footguns, not spend another tranche fighting ghosts.
Current browser regressions to use as canaries:
/examples/hovering-toolbar: mouse selection does not show the toolbar./examples/mentions: inline void navigation from both sides is wrong./examples/search-highlighting: typing in the search input decorates but
loses focus./examples/tables: ArrowRight from first cell lands in second cell at
offset 1 instead of 0./examples/images: keyboard navigation around images is broken./examples/images: void image shell can create visible empty layout above
the image./examples/embeds: editable void spacer/layout differs from legacy.packages/slateOwn the model, operations, transaction lifecycle, normalization, path/runtime id mapping, and dirty commit facts.
Required output per commit:
packages/slate-browserOwn browser proof and browser contracts.
This package should be the proof spine for:
packages/slate-domOwn DOM lookup primitives and low-level editor DOM helpers.
This package should not know React. It should provide stable lookup and mapping utilities that the React runtime can call without owning browser behavior.
packages/slate-reactOwn projection only.
React components render stable islands and subscribe to explicit runtime facts. They do not derive browser state by reading broad editor snapshots during render.
Create an internal runtime object before public API work:
interface EditorRuntime {
commit: CommitRuntime;
composition: CompositionRuntime;
decorations: DecorationRuntime;
dom: DomRuntime;
events: EventRuntime;
mutation: MutationRuntime;
projection: ProjectionRuntime;
selection: SelectionRuntime;
shells: ShellRuntime;
}
This is internal first. The name can change. The contract matters.
Turns model updates into cheap facts.
interface CommitFacts {
id: number;
dirtyNodeIds: ReadonlySet<string>;
dirtyTextIds: ReadonlySet<string>;
dirtyShellIds: ReadonlySet<string>;
selectionAfter: Range | null;
selectionBefore: Range | null;
selectionImpactIds: ReadonlySet<string>;
decorationImpactIds: ReadonlySet<string>;
normalizationImpactIds: ReadonlySet<string>;
}
No React component should need to scan the whole editor to know if it changed.
Owns subscription keys by runtime id and source.
Projection facts should be small:
selectedfocusedreadOnlycomposingattrsHashchildrenIdentitydecorationHashannotationHashshellStateReact hooks subscribe to derived values, not giant editor objects.
Owns all browser-critical DOM structure:
App renderers receive visible props only. For containers, they receive a branded content slot. For void/atom elements, they do not receive raw hidden children.
Use React 19.2 where it helps. Do not make it responsible for correctness.
useSyncExternalStore-style subscriptions for runtime facts.useEffectEvent for DOM listener bridges so handlers stay fresh without
resubscribing global listeners.startTransition for non-urgent projection work such as search
decorations, expensive overlays, and analytics.useDeferredValue only where visual lag is acceptable, never for core
text input or selection authority.Activity for hidden secondary surfaces, inactive floating UI, or
secondary editor panels. Do not wrap the active editor body in Activity.Rules from Vercel guidance that directly apply:
rerender-defer-readsrerender-derived-statererender-derived-state-no-effectrerender-split-combined-hooksrerender-use-ref-transient-valuesrerender-transitionsrerender-use-deferred-valuerendering-hoist-jsxrendering-activityclient-event-listenersbundle-barrel-importsThese budgets are the bar for the internal runtime.
| Scenario | Budget |
|---|---|
| Type one character in plain text | touched text/leaf projection only; editor body does not rerender |
| Delete selected text | affected text/leaf projection only; toolbar can update separately |
| Arrow across block void | old selected shell and new selected shell update; no full tree rerender |
| Arrow around inline void | adjacent text and inline atom projection update only |
| Type in search input | input keeps DOM focus; decoration projection updates separately |
| IME composition | composition refs update without broad React invalidation |
| Mouse selection for toolbar | toolbar anchor updates without remounting editor content |
| Table cell ArrowRight | target cell offset is model-correct and DOM selection agrees |
| Image selection | hidden anchor never creates visible layout |
Render counts must be measured, not guessed.
Default iteration stays fast.
Fast lane:
bun check when a slice touches core package behaviorOpt-in stress lane:
bun test:stressSTRESS_ROUTES=... STRESS_FAMILIES=... bun test:stressSTRESS_REPLAY=... bun test:stress:replayClosure lane:
bun check:fullDo not put the full generated browser matrix into default bun check.
editable.tsxeditable-element.tsxslate-element.tsxslate-text.tsxslate-leaf.tsxvoid-element.tsxprojection-store.tswidget-store.tsannotation-store.tsEditorRuntime entrypoint or equivalent module.| Responsibility | Current owner | Current problem | Target owner |
|---|---|---|---|
| DOM selection import/export | packages/slate-react/src/components/editable.tsx, editable/input-controller.ts, editable/selection-reconciler.ts | EditableDOMRoot wires native listeners, model preference, import/export, shell-backed state, and trace recording in one component. | Internal SelectionRuntime called by EditableDOMRoot. |
| Mutation and DOM repair | editable.tsx, editable/dom-repair-queue.ts, input strategies | Repair queue is created in React render scope and request plumbing is scattered across beforeinput/input/keyboard/paste/drop handlers. | Internal MutationRuntime plus DomRepairRuntime. |
| Composition and IME state | editable.tsx, editable/composition-state.ts, Android input manager hooks | Composition state still forces React state through setIsComposing; high-frequency facts should mostly stay in refs/runtime. | Internal CompositionRuntime; React exposes stable projection facts only. |
| Native event ownership | editable.tsx, editable/editing-kernel.ts, input-router hooks | Kernel decisions are useful, but EditableDOMRoot still orchestrates every event family directly. | Internal EventRuntime with per-family handlers. |
| Shell DOM attrs and refs | slate-element.tsx, slate-text.tsx, slate-leaf.tsx, slate-spacer.tsx, void-element.tsx, use-slate-node-ref.tsx | Primitive components own browser-critical attrs directly; VoidElement still requires app authors to pass spacer children. | Internal ShellRuntime; primitives render runtime-owned attrs/content slots. |
| Void spacer placement | void-element.tsx, slate-spacer.tsx, app renderElement functions | Better than raw {children}, still too footgunny because app-visible void content and hidden spacer are coupled by component convention. | ShellRuntime owns hidden anchor/spacer; app renderers provide visible content only. |
| Decoration projection | projection-store.ts, use-slate-projections.tsx, EditableText | Projection store is a good internal shape, but dirtiness is source-level and recomputes snapshots by runtime id after full source refresh. | ProjectionRuntime consuming commit facts and source-specific invalidation. |
| Annotation projection | annotation-store.ts | Good separate store; resolves bookmarks and projects by runtime id, but still separate from commit facts. | AnnotationRuntime layered onto ProjectionRuntime. |
| Floating widgets | widget-store.ts | Good external-store shape; selection/node/annotation anchors are explicit. Node dirtiness is currently broad via isSlateSourceDirty('node'). | WidgetRuntime using selection/runtime-id impact facts. |
| Render breadth proof | render-profiler.ts, slate-browser/playwright helpers | New proof spine exists; selected runtime ids are measurable. | Keep in slate-browser as proof infrastructure; do not expose as app API. |
Do not start by moving all event code.
First extraction should be shell ownership because it is the highest-leverage DX and regression owner:
slate-react.VoidElement so app authors do not
manually own spacer placement in normal usage.Reason: selection/runtime extraction before shell ownership would still leave void spacers and hidden anchors as app-renderer footguns. Shell ownership is the smallest API-neutral move that attacks the repeated regressions directly.
useEditorSelectoruseNodeSelectoruseTextSelectoruseDecorationSelectorEditorShellElementShellTextShellLeafShellVoidShellContentSlotrenderElement, renderLeaf, and renderPlaceholder
contracts active.VoidShell.useEffectEvent for React listener bridges.0./examples/search-highlighting keeps focus in the search input while typing.slate-browser proof helpers to express node-family contracts:
test:stress.test:stress can replay a failure without rerunning the full matrix.Only start this after Phases 0-7 prove the runtime.
Public API target:
defineElementContentdefineMarkdefineTextBehaviordefineExtensionMigration stance:
Start here.
In /Users/zbeyens/git/slate-v2:
packages/slate-react/src/components/editable.tsxpackages/slate-react/src/components/editable-element.tsxpackages/slate-react/src/components/slate-element.tsxpackages/slate-react/src/components/slate-text.tsxpackages/slate-react/src/components/slate-leaf.tsxpackages/slate-react/src/components/void-element.tsxpackages/slate-react/src/stores/projection-store.tspackages/slate-react/src/hooks/use-generic-selector.tsxpackages/slate-react/src/hooks/use-slate-selector.tsxpackages/slate-react/src/editable/selection-controller.tspackages/slate-react/src/editable/mutation-controller.tspackages/slate-react/src/editable/composition-state.tspackages/slate-browser/src/*playwright/stress/*Per implementation slice:
bun run lint:fixBefore claiming runtime closure:
bun checkbun test:stress green for curated routes/familiesbun check:full green when making a release-quality architecture claimStop and replan if any of these happen:
This plan is complete when:
slate-browser owns replayable contracts for the regression familiesThis is the clean architecture/DX lane after the completed runtime/projection tranche.
Hard cut normal app responsibility for void spacers and hidden anchors.
Acceptance:
Hard cut broad React subscriptions on hot editor paths.
useSlateSelector filtering is removed from mounted
node/text/leaf/void paths once a specific selector exists.Acceptance:
Hard cut selection import/export policy from React components.
Editable wires runtime modules, but does not decide selection authority.Acceptance:
Do not cut the already-cut field APIs again. They are not the current problem.
Already cut as normal API:
editor.childreneditor.selectioneditor.markseditor.operationsRemaining candidates:
editor.apply(op) as a normal write pathsetChildren from ordinary public DXEditor.apply(editor, op) only if replay/import needs an explicit
single-op APIgetLiveNode, getLiveText, and getLiveSelection as internal or
advanced runtime tools<Slate onChange> only as React adapter output, not core ownershipAcceptance:
setChildren, or live
accessors.Hard cut wrong unpublished APIs.
Acceptance:
Hard cut example-specific fixes as the safety net.
slate-browser owns generated replay contracts by operation family.test:stress, not default fast CI.Acceptance:
test:stress can replay human-like editing scenarios without manual bug
reports for every variant.This is the execution plan for the remaining cleanup after runtime-owned void/atom shells. It starts with inventory because stale claims already caused bad guidance once. The first rule is simple: cut the live footguns, not the ghosts.
bun check.Item 6 scenario design can be drafted while item 4 is in progress, but code changes should not depend on the old public surface.
Owner: packages/slate.
Read first:
packages/slate/src/interfaces/editor.tspackages/slate/src/create-editor.tspackages/slate/src/core/public-state.tspackages/slate/src/core/editor-extension.tspackages/slate/test/surface-contract.tspackages/slate/test/write-boundary-contract.tspackages/slate/test/accessor-transaction.test.tspackages/slate/test/generic-editor-api-contract.tsInventory commands:
rg -n "readonly apply|applyOperations|setChildren|getLiveNode|getLiveSelection|getLiveText|reset:|replace:" packages/slate/src packages/slate/test
rg -n "editor\\.apply|Editor\\.apply|setChildren|getLiveNode|getLiveSelection|getLiveText|onChange|deprecated|compat|legacy|fallback|alias" packages docs site playwright
Classify every surfaced API into one bucket:
| Bucket | Meaning | Examples |
|---|---|---|
| Normal public | Human and app-author API | editor.update, transforms, snapshots, transactions |
| Explicit replay/import | Low-level operation ingestion | applyOperations or a renamed replay writer |
| Advanced runtime | Runtime-owned escape hatch, not app DX | live node/text/selection reads if they survive |
| Internal only | Package-private implementation detail | direct live reads used by DOM/runtime code |
| Delete | Unpublished wrong shape | compatibility aliases, fallback renderer props |
Current inventory after the 2026-04-27 activation pass:
| Surface | Current fact | Bucket | Next move |
|---|---|---|---|
editor.update(...) and transforms | Existing write-boundary contract routes ordinary edits through editor.update. | Normal public | Keep as the app-author write path. |
editor.withTransaction((tx) => ...) / tx.apply(op) | Transaction surface exists and tx.apply(op) is already the intended transaction-owned single-op primitive. | Normal public inside transaction | Keep, then migrate any direct single-op tests/callers that should be transaction-owned. |
editor.applyOperations(...) / Editor.applyOperations(...) | Existing contracts prove it imports operations and publishes one commit. | Explicit replay/import | Keep as the public replay/import writer unless a better final name wins before publish. |
instance editor.apply(op) | Cut from BaseEditor, createEditor, instance runtime shape, tests, and editor detection. Operation fixtures use applyOperations(...); transaction tests use transaction.apply(...). | Deleted from normal public | Keep deleted. Continue with getLive* fencing. |
Editor.apply(editor, op) | Cut from EditorInterface; operations docs now teach applyOperations. Snapshot tests still use instance editor.apply, not the static helper. | Deleted | Keep deleted. Do not reintroduce a static single-op helper. |
setChildren / Editor.setChildren | Cut from BaseEditor, EditorInterface, createEditor, docs, tests, and the root package barrel. Raw child replacement is fenced behind slate/internal as setEditorChildren for Slate-owned package setup only. | Deleted from normal public; internal-only fixture/runtime tool | Keep root deleted. Continue with instance editor.apply(op) and getLive* fencing. |
replace / reset | Snapshot-level writers exist beside setChildren. | Normal or explicit snapshot writer | Keep only if documented as full snapshot replacement, not child-list mutation. |
getChildren, getSelection, getSnapshot, getOperations | These are the already-cut field replacements and current normal read APIs. | Normal public | Keep and teach first. |
getLiveNode, getLiveText, getLiveSelection | BaseEditor, EditorInterface, surface tests, docs, React/DOM runtime, and core helpers still expose/use them. | Advanced runtime or internal only | Fence from ordinary app/docs; keep internal runtime use where snapshot reads are stale. |
core editor.onChange | apply-onChange contract already proves no instance onChange key. | Already deleted | Keep deleted. |
React <Slate onChange> / onValueChange | React docs still describe callback API and an alias. | React adapter output | Pick one final callback story before publish; remove alias if the duplicate is only compatibility. |
| compatibility and legacy inventories | escape-hatch-inventory-contract already tracks stale editor.apply/field/API pressure, but many rows are still compatibility or historical buckets. | Burn-down guard | Tighten after item 4 final public surface lands. |
Acceptance:
BaseEditor and EditorInterface write/read escape hatch has
an owner bucket.Target API shape:
editor.update(...), transforms, or
editor.withTransaction((tx) => ...).editor.applyOperations(...) / Editor.applyOperations(...).tx.apply(op) remains valid inside transaction-owned code.editor.apply(op) is not a normal public write path.Editor.apply(editor, op) survives only if replay/import genuinely needs a
single-op public helper; otherwise delete it too.setChildren is not ordinary public DX. Prefer replace/reset with a
full snapshot-shaped input, or an explicitly named test/import utility.Implementation slices:
BaseEditor does not expose ordinary apply.editor.apply.setChildren.apply to the kept replay/operation
boundary, or prove why a wrapper hook still belongs.tx.apply(op) or
explicit replay helpers.Editor.apply after the replay/import contract is green.setChildren. Keep replace/reset only if they
read as snapshot-level operations, not arbitrary child mutation.Tests:
bun test ./packages/slate/test/surface-contract.ts ./packages/slate/test/write-boundary-contract.ts ./packages/slate/test/transaction-contract.ts --bail 1
Acceptance:
update, or transactions.editor.apply.setChildren as the blessed public state path.Target API shape:
Editor.getSnapshot(editor), editor.getSnapshot(),
Editor.getChildren(editor), Editor.getSelection(editor), and dedicated
selector/runtime APIs.getLiveNode, getLiveText, and getLiveSelection are runtime tools.Implementation slices:
Acceptance:
getLive* no longer reads like ordinary app DX.<Slate onChange> As Adapter Output OnlyTarget API shape:
onChange field.<Slate onChange> is a component callback that receives adapter output
after committed editor changes.Implementation slices:
<Slate onChange>, onValueChange, and subscriber usage.Acceptance:
Owner: all public packages touched by the hard cut.
Inventory commands:
rg -n "deprecated|compat|legacy|fallback|alias|shim|migration|previously|old API|children.*spacer|spacer=.*children|renderElement.*children" packages docs site playwright
rg -n "SlateReactCompat|useSlateSelector|VoidElement.*children|InlineVoidElement.*children|editor\\.apply|Editor\\.apply|setChildren|getLive" packages docs site playwright
Classify results:
Acceptance:
Implementation slices:
Acceptance:
The guard should be cheap and static. It belongs in the release-discipline lane, not the slow browser stress lane.
Guard responsibilities:
Likely command shape:
bun test ./packages/slate/test/surface-contract.ts ./packages/slate/test/write-boundary-contract.ts --bail 1
bun test ./packages/slate-browser/test/core/release-proof.test.ts --bail 1
Acceptance:
editor.apply, setChildren, app-owned void spacer
prop, or compatibility alias trips a fast test or guard.Owner: packages/slate-browser plus Playwright stress routes.
Map reported regressions to generated contract families:
| Family | Routes | Contract |
|---|---|---|
| inline-void-boundary-navigation | mentions | arrow from left/right, delete, select across atom, model/DOM agreement |
| block-void-navigation | images, embeds | enter/exit void, vertical/horizontal arrows, delete/backspace, layout shell integrity |
| table-cell-boundary-navigation | tables | ArrowRight/Left/Up/Down across cells lands at expected offset |
| external-decoration-refresh | search-highlighting, external-decoration-sources | focus owner preserved, decoration output updated, render budget respected |
| mouse-selection-toolbar | hovering-toolbar | native mouse drag keeps DOM selection, model selection, toolbar visibility |
| paste-normalize-undo | richtext, plaintext, forced-layout | paste, normalize, undo, redo, replay artifact |
| selection-repair-ime | focused IME/mobile routes | composition state, DOM repair, selection export/import ownership |
The placeholder family stays a low-priority canary unless it reappears as part of a broader empty-block or composition contract.
Acceptance:
Target shape:
bun test:stress remains opt-in.STRESS_ROUTES, STRESS_FAMILIES, and STRESS_SEED select focused runs.bun test:stress:replay with STRESS_REPLAY=....Implementation slices:
createScenarioReplay.Acceptance:
Default fast gates:
Opt-in/release gates:
bun test:stressbun test:stress:replaybun check:fullbun test:integration-local only for full local closure or explicit requestAcceptance:
bun check:full, not vibes.Policy:
slate-browser contract row or a clear
reason it is not generalizable.Acceptance:
slate-browser owns the replay contract for variant coverage.Start with item 4, not browser stress.
editor.apply, setChildren, and getLive*
ownership.tx.apply, no normal app-owned editor.apply.First-slice verification:
bun test ./packages/slate/test/surface-contract.ts ./packages/slate/test/write-boundary-contract.ts ./packages/slate/test/transaction-contract.ts --bail 1
bun check
Closure verification for the whole items 4-6 lane:
bun check
STRESS_FAMILIES=inline-void-boundary-navigation,block-void-navigation,table-cell-boundary-navigation,external-decoration-refresh bun test:stress
bun test:stress:replay
bun check:full
Do not run the closure commands during every slice. Run them before claiming the architecture lane is done.
Slice name: complete-plan activation and item 4A inventory.
Owner classification: packages/slate public surface and replay/import
boundary.
Actions taken:
active goal state from the active items 4-6 plan.active goal state to pending for the new lane..tmp/slate-v2.Commands run:
rg -n "readonly apply|applyOperations|setChildren|getLiveNode|getLiveSelection|getLiveText|reset:|replace:" packages/slate/src packages/slate/testrg -n "editor\\.apply|Editor\\.apply|setChildren|getLiveNode|getLiveSelection|getLiveText|onChange|deprecated|compat|legacy|fallback|alias" packages docs site playwright --glob '!**/CHANGELOG.md'Evidence:
BaseEditor still exposes readonly apply, setChildren, and getLive*.EditorInterface still exposes Editor.apply, Editor.setChildren, and
Editor.getLive*.applyOperations already has focused replay/import contract coverage.editor.onChange is already cut by contract.escape-hatch-inventory-contract.ts already exists but still includes
compatibility burn-down rows.Decision:
applyOperations as the explicit replay/import path for now.setChildren as the ugliest remaining public DX leak.Rejected tactics:
Editor.apply merely because old operations docs teach it.getLive* as normal app DX.Next action:
editor.apply, no ordinary setChildren, and fenced
getLive* ownership.Slice name: remove public static Editor.apply.
Owner classification: packages/slate replay/import public surface.
Actions taken:
EditorInterface.apply.Editor.apply implementation from the Editor namespace object.core/apply.ts no longer depends
on Editor['apply'].editor.applyOperations(...).Editor.apply is not public.editor.update.Commands run:
rg -n "Editor\\.apply\\(" packages/slate/src packages/slate/test docs/concepts docs/api site playwright --glob '!docs/general/changelog.md'bun test ./packages/slate/test/surface-contract.ts ./packages/slate/test/write-boundary-contract.ts ./packages/slate/test/transaction-contract.ts --bail 1bun checkEvidence:
bun check passes: lint, package/site/root typecheck, Bun tests, and
slate-react Vitest suite.Editor.apply grep hits are false positives from variables named
directEditor / transactionEditor using instance editor.apply.Decision:
applyOperations remains the explicit replay/import public path.transaction.apply.Rejected tactics:
Editor.apply as a convenience alias.editor.apply as solved by this slice.Next action:
setChildren and route setup/reset examples through
snapshot-level replace/reset or test-only helpers.Slice name: remove public setChildren and raw state setters from the root
package surface.
Owner classification: packages/slate root export contract and Slate-owned
internal runtime bridge.
Actions taken:
slate does not export
setChildren, setCurrentSelection, setCurrentMarks, setOperations, or
setTargetRuntime.public-state wildcard export from packages/slate/src/core.slate/internal as the explicit Slate-owned package bridge for
setEditorChildren, setEditorMarks, setEditorSelection, and
setEditorTargetRuntime.slate-react and slate-hyperscript to import raw runtime setters
from slate/internal, not root slate.slate multi-entry build config and package export for
./internal.root.setChildren(...) fallback from utils/modify.slate/internal.Commands run:
bun test ./packages/slate/test/surface-contract.ts --bail 1bun test ./packages/slate/test/surface-contract.ts ./packages/slate/test/accessor-transaction.test.ts ./packages/slate/test/editor-methods-contract.ts ./packages/slate/test/primitive-method-runtime-contract.ts ./packages/slate/test/transaction-target-runtime-contract.ts ./packages/slate-hyperscript/test/index.spec.ts --bail 1bun --filter slate buildbun --filter slate typecheckbun --filter slate-react typecheckbun --filter slate-hyperscript typecheckbun lint:fixbun test:vitestbun checkEvidence:
slate still exported
setChildren.slate package build passes and emits the new internal subpath.slate, slate-react, and
slate-hyperscript.bun check passes: lint, package/site/root typecheck, Bun tests, and
slate-react Vitest suite.Decision:
slate is now clean of raw state setters.slate/internal is a fenced Slate-owned bridge for sibling packages, not an
app-author API.Rejected tactics:
root.setChildren(...) fallback in mutation utilities.Next action:
editor.apply(op) from the normal public write path.Slice name: remove instance editor.apply(op) from the normal public write
path.
Owner classification: packages/slate public write boundary and operation
replay/import contract.
Actions taken:
apply.readonly apply from BaseEditor.createEditor instance apply method and property descriptor.setBaseApply(...) internal so operation middleware and
transaction.apply(...) still dispatch through the core operation reducer.Editor.isEditor, Node.isEditor, and Element.isElement detection
so editor recognition no longer depends on an apply property.applyOperations(...) for
replay/import-style writes and transaction.apply(...) inside transaction
tests.apply() marker to the
current applyOperations() editor shape.Commands run:
bun test ./packages/slate/test/surface-contract.ts --bail 1bun test ./packages/slate/test/surface-contract.ts ./packages/slate/test/write-boundary-contract.ts ./packages/slate/test/apply-onchange-hard-cut-contract.ts ./packages/slate/test/operations-contract.ts ./packages/slate/test/read-update-contract.ts ./packages/slate/test/accessor-transaction.test.ts --bail 1bun --filter slate typecheckbun --filter slate-react typecheckbun --filter slate-hyperscript typecheckbun lint:fixbun --filter slate buildbun testbun checkEvidence:
apply.slate, slate-react, and
slate-hyperscript.slate package build passes.bun check passes: lint, package/site/root typecheck, Bun tests, and
slate-react Vitest suite.Decision:
applyOperations(...).transaction.apply(...).Rejected tactics:
editor.apply method.apply as an editor-shape marker in node/element detection.applyOperations(...) into a rejected primitive write; it is the
explicit replay/import boundary.Next action:
getLiveNode, getLiveText, and getLiveSelection accessors
from ordinary app-facing API.Slice name: fence broad getLive* reads from ordinary public API.
Owner classification: packages/slate public read surface and Slate-owned
runtime bridge.
Actions taken:
getLiveNode, getLiveText, and getLiveSelection from
BaseEditor, EditorInterface, editor instances, and normal surface
contracts.getEditorLiveNode, getEditorLiveText, and
getEditorLiveSelection to the fenced slate/internal bridge.slate-react and slate-dom runtime call sites to use the internal
live-read aliases where immediate DOM/input authority requires live state.Editor.getSelection(...) or
snapshot-style reads instead of Editor.getLiveSelection(...).slate, editor instances, and
EditorInterface all reject broad getLive* as ordinary app-facing API.Commands run:
bun test ./packages/slate/test/surface-contract.ts --bail 1bun --filter slate typecheckbun --filter slate-react typecheckbun --filter slate-dom typecheckbun test ./packages/slate/test/surface-contract.ts ./packages/slate/test/write-boundary-contract.ts ./packages/slate/test/apply-onchange-hard-cut-contract.ts ./packages/slate/test/operations-contract.ts ./packages/slate/test/read-update-contract.ts ./packages/slate/test/accessor-transaction.test.ts --bail 1bun lint:fixbun --filter slate buildbun checkEvidence:
slate, slate-react, and slate-dom.slate package build passes with the slate/internal live-read bridge.bun check passes: lint, package/site/root typecheck, Bun tests, and
slate-react Vitest suite.Editor.getLive* teaching path; remaining raw getLive* names are core
internals, internal bridge aliases, or negative public-surface assertions.Decision:
Rejected tactics:
slate.getLive* in docs/examples while claiming it is fenced.Next action:
<Slate onChange>, onValueChange, and subscriber usage, then
make the React adapter callback story one final public shape before the item
5 alias deletion sweep.Slice name: remove ambiguous <Slate onChange> and keep explicit adapter
callbacks.
Owner classification: packages/slate-react adapter callback surface and
public docs/examples.
Actions taken:
onChange prop from <Slate>.onSnapshotChange(snapshot, commit) for integrations that need every
committed editor snapshot from the React adapter.onValueChange(value) for committed document changes.onSelectionChange(selection) for committed selection changes.onSnapshotChange because it
derives UI from both selection and text.onValueChange directly instead of filtering
operations inside a broad callback.Editor.getLiveSelection(...).Commands run:
bun --filter slate-react test:vitest -- editable-behavior react-editor-contract
onSnapshotChange did not
exist.bun --filter slate-react typecheckcd site && bunx tsc --project tsconfig.jsonbun --filter slate-react test:vitest -- surface-contract editable-behavior react-editor-contract provider-hooks-contractbun lint:fixbun checkEvidence:
slate-react typecheck passes.<Slate onChange> prop.bun check passes: lint, package/site/root typecheck, Bun tests, and
slate-react Vitest suite.<Slate onChange> or
Editor.getLiveSelection(...); remaining onChange names are DOM form
events, changelog text, or internal selector callback names.Decision:
onSnapshotChange for every committed snapshot, onValueChange for document
changes, and onSelectionChange for selection changes.<Slate onChange>.Rejected tactics:
onChange as a broad alias for all commits.Next action:
Slice name: delete unpublished compatibility aliases and add the fast guard.
Owner classification: public package exports, docs/examples, and release-discipline contracts.
Actions taken:
SlateReactCompat namespace export.useEditor alias and kept useSlateStatic as the single static
editor hook.compat wording.editor.apply docs/tests/fixtures to
applyOperations(...) or operation replay/import language.packages/slate/test/compat-alias-hard-cut-contract.ts to keep
deleted aliases out of public source, docs, and examples.compat-alias-hard-cut-contract to the release-discipline guard list
and root test:release-discipline script.Commands run:
rg -n "deprecated|compat|legacy|fallback|alias|shim|migration|previously|old API|children.*spacer|spacer=.*children|renderElement.*children" packages docs site playwright ...rg -n "SlateReactCompat|useSlateSelector|VoidElement.*children|InlineVoidElement.*children|editor\\.apply|Editor\\.apply|setChildren|getLive" packages docs site playwright ...bun --filter slate-react test:vitest -- projections-and-selection-contract provider-hooks-contract surface-contract editable-behavior react-editor-contract with-react-contract editing-kernel-contractbun test ./packages/slate-browser/test/core/release-proof.test.ts ./packages/slate/test/interfaces-contract.ts ./packages/slate/test/surface-contract.ts ./packages/slate/test/apply-onchange-hard-cut-contract.ts --bail 1bun --filter slate-react typecheckbun --filter slate-browser typecheckbun --filter slate typecheckbun lint:fixbun --filter slate-react buildbun --filter slate-browser buildbun --filter slate buildbun test ./packages/slate/test/compat-alias-hard-cut-contract.ts --bail 1bun test:release-disciplinebun checkEvidence:
compat-alias-hard-cut-contract passes.test:release-discipline passes: 83 pass, 0 fail across 8 files.slate-react, slate-browser, and slate tests/typechecks pass.slate-react still has the known external
is-hotkey warning.bun check passes: lint, package/site/root typecheck, Bun tests, and
slate-react Vitest suite.SlateReactCompat, decorate compat adapter,
useEditor, public Editor.getLive*, or public <Slate onChange> hits.Decision:
Rejected tactics:
SlateReactCompat as a "temporary" namespace.useEditor as a deprecated alias.Next action:
slate-browser operation-family contracts and replay
artifacts for the reported browser regression families while keeping stress
out of default bun check.Slice name: make reported browser regression families generated, replayable,
and release-proof without slowing default bun check.
Owner classification: slate-browser operation-family contracts, React
runtime projection/render ownership, and static-site browser proof.
Actions taken:
test:stress and out of default
bun check.Control/Meta shortcuts so
generated undo rows exercise Slate-owned history instead of browser-native
DOM undo.Commands run:
bun --filter slate-browser typecheckbun --filter slate-browser buildbun --filter slate-react typecheckbun --filter slate-react buildbun --filter slate-react test:vitest -- provider-hooks-contract projections-and-selection-contractbun build:nextPLAYWRIGHT_BASE_URL=http://localhost:3101 PLAYWRIGHT_RETRIES=0 STRESS_ROUTES=plaintext STRESS_FAMILIES=paste-normalize-undo bunx playwright test playwright/stress/generated-editing.test.ts --project=mobile --reporter=linebun lint:fixbun checkbun check:fullEvidence:
bun check passes.bun check:full passes: release discipline, slate-browser proof contracts,
scoped mobile proof guard, persistent-profile soak, and 628 Playwright rows
passed with 4 skipped replay-placeholder rows.Decision:
Rejected tactics:
useNodeSelector / useTextSelector stale for app code
just to save render work in mounted editor internals.Slice name: continuation prompt and first instrumentation owner.
Owner classification: Phase 0 browser/runtime proof infrastructure.
Actions taken:
active goal state from this active plan.active goal state to pending.Current hypothesis:
Rejected tactics:
defineElement.bun check.Next action:
.tmp/slate-v2 Playwright/stress and slate-browser helpers,
then add the first focused canary/instrumentation slice for image void layout
and keyboard navigation.Slice name: reported-regression canary baseline.
Owner classification: browser proof infrastructure and server freshness.
Actions taken:
.tmp/slate-v2 Playwright integration examples and stress
helpers.Commands run:
bunx playwright test playwright/integration/examples/images.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/search-highlighting.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line
3101: 10 failed, 14 passed.PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/images.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/search-highlighting.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line
3100: 24 passed.Evidence:
3100 showed image ArrowRight moves from
[0,0]@113 to [1,0]@0.3101 run is not trustworthy for product behavior because the
Playwright config reused an existing server.Decision:
3101 red run.PLAYWRIGHT_BASE_URL=http://localhost:3100 during local dev
browser work.Rejected tactics:
Editor.positions from the stale server failures; the core
block-void movement contract already passes.Next action:
Slice name: internal render profiler and first browser render-budget canary.
Owner classification: slate-react render projection instrumentation plus
slate-browser Playwright proof helper.
Files changed:
.tmp/slate-v2/packages/slate-react/src/render-profiler.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsx.tmp/slate-v2/packages/slate-react/src/components/slate-element.tsx.tmp/slate-v2/packages/slate-react/src/components/slate-leaf.tsx.tmp/slate-v2/packages/slate-react/src/components/slate-spacer.tsx.tmp/slate-v2/packages/slate-react/src/components/slate-text.tsx.tmp/slate-v2/packages/slate-react/src/components/void-element.tsx.tmp/slate-v2/packages/slate-react/test/render-profiler-contract.test.tsx.tmp/slate-v2/packages/slate-browser/src/playwright/index.ts.tmp/slate-v2/playwright/integration/examples/search-highlighting.test.tsActions taken:
slate-react render profiler that is a no-op unless
globalThis.__SLATE_REACT_RENDER_PROFILER__ is installed.slate-react contract test proving absent-profiler no-op
behavior and installed-profiler primitive counts.slate-browser/playwright helpers to install, reset, and snapshot the
profiler before a page loads.takeSlateBrowserRenderStateSnapshot(...) helper that
returns the editor snapshot and render counts as one browser proof artifact.Commands run:
bun --filter slate-react test:vitest -- render-profiler-contract
../src/render-profiler, as intended.bun --filter slate-react typecheck
bun --filter slate-browser typecheck
bun --filter slate-browser build
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/search-highlighting.test.ts --project=chromium --reporter=line
bun lint:fix
Evidence:
3100 search-highlighting canary proves the user-reported focus
regression stays covered while also enforcing a narrow render budget for the
decoration refresh path.slate-browser helper, which is the reusable shape for the other examples.slate-browser import private slate-react modules.Rejected tactics:
Remaining risks:
Next action:
Slice name: image void horizontal navigation render/state budget.
Owner classification: image void browser navigation proof.
Files changed:
.tmp/slate-v2/playwright/integration/examples/images.test.tsActions taken:
Commands run:
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/images.test.ts --project=chromium --reporter=line -g "moves horizontally"
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/images.test.ts playwright/integration/examples/search-highlighting.test.ts --project=chromium --reporter=line
bun lint:fix
bun typecheck
Evidence:
Rejected tactics:
Remaining risks:
Next action:
Slice name: render/state budget coverage for all reported regression families.
Owner classification: browser proof infrastructure and focused regression canaries.
Files changed:
.tmp/slate-v2/playwright/integration/examples/embeds.test.ts.tmp/slate-v2/playwright/integration/examples/hovering-toolbar.test.ts.tmp/slate-v2/playwright/integration/examples/mentions.test.ts.tmp/slate-v2/playwright/integration/examples/tables.test.tsActions taken:
Commands run:
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/tables.test.ts --project=chromium --reporter=line -g "moves right"
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/mentions.test.ts playwright/integration/examples/tables.test.ts --project=chromium --reporter=line -g "arrow keys|moves right"
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/mentions.test.ts playwright/integration/examples/tables.test.ts --project=chromium --reporter=line
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line -g "real mouse|moves from"
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/images.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/search-highlighting.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line
bun --filter slate-react test:vitest -- render-profiler-contract
bun typecheck
bun lint:fix
Evidence:
0,
keeps DOM offset 0, and produces zero React renders.Rejected tactics:
Remaining risks:
Next action:
editable.tsx, primitives, projection
store, widget store, and annotation store.Slice name: selected runtime-id and shell summary proof.
Owner classification: browser proof metadata and React DOM binding.
Files changed:
.tmp/slate-v2/packages/slate-react/src/hooks/use-slate-node-ref.tsx.tmp/slate-v2/packages/slate-browser/src/playwright/index.ts.tmp/slate-v2/playwright/integration/examples/embeds.test.ts.tmp/slate-v2/playwright/integration/examples/images.test.ts.tmp/slate-v2/playwright/integration/examples/mentions.test.ts.tmp/slate-v2/playwright/integration/examples/tables.test.tsActions taken:
data-slate-runtime-id DOM binding beside the existing
data-slate-path binding.takeSlateBrowserRenderStateSnapshot(...) with
selectionShells, including anchor/focus node shell, nearest element shell,
and involved runtime ids.Commands run:
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/images.test.ts playwright/integration/examples/embeds.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/mentions.test.ts --project=chromium --reporter=line -g "moves horizontally|moves from|moves right|arrow keys"
bun --filter slate-browser typecheck
bun --filter slate-browser build
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/images.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/search-highlighting.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line
bun --filter slate-react test:vitest -- render-profiler-contract
bun typecheck
bun lint:fix
Evidence:
0.Rejected tactics:
data-slate-path as enough browser proof when runtime ids are
the future subscription key.Remaining risks:
Next action:
Slice name: ownership map plus first shell-runtime extraction.
Owner classification: shell DOM ownership.
Files changed:
.tmp/slate-v2/packages/slate-react/src/shell-runtime.ts.tmp/slate-v2/packages/slate-react/src/components/slate-element.tsx.tmp/slate-v2/packages/slate-react/src/components/slate-leaf.tsx.tmp/slate-v2/packages/slate-react/src/components/slate-spacer.tsx.tmp/slate-v2/packages/slate-react/src/components/slate-text.tsx.tmp/slate-v2/packages/slate-react/test/shell-runtime-contract.test.tsxActions taken:
editable.tsx currently owns too much event, selection, repair,
composition, and trace orchestration.projection-store.ts, annotation-store.ts, and widget-store.ts are
already closer to the desired external-store shape.shell-runtime.ts helper for primitive shell attrs:
element, text, leaf, and spacer.Commands run:
bun --filter slate-react test:vitest -- shell-runtime-contract
../src/shell-runtime, as intended.bun --filter slate-react test:vitest -- shell-runtime-contract primitives-contract render-profiler-contract
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/images.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/search-highlighting.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line
bun typecheck
bun lint:fix
Evidence:
useSelected/useFocused should allow selected shell
rerender, but still forbid editable-root or broad tree rerender.Rejected tactics:
shell-runtime by simple object spreading
unless render-budget canaries stay green. The first attempt did not.Remaining risks:
EditableDOMRoot.VoidElement still exposes spacer placement through component convention.EditableDOMRoot.Next action:
Slice name: API-neutral void spacer ownership step.
Owner classification: shell DOM ownership and void spacer DX.
Current-state correction:
VoidElement API.children or spacer props on
VoidElement; see Runtime-Owned VoidElement Spacer Children below.Files changed:
.tmp/slate-v2/packages/slate-react/src/shell-runtime.ts.tmp/slate-v2/packages/slate-react/src/components/void-element.tsx.tmp/slate-v2/packages/slate-react/test/shell-runtime-contract.test.tsx.tmp/slate-v2/packages/slate-react/test/primitives-contract.tsx.tmp/slate-v2/packages/slate-react/test/render-profiler-contract.test.tsx.tmp/slate-v2/site/examples/ts/images.tsx.tmp/slate-v2/site/examples/ts/embeds.tsx.tmp/slate-v2/site/examples/ts/paste-html.tsxdocs/solutions/developer-experience/2026-04-27-slate-react-void-renderers-should-not-own-hidden-spacer-children.md.tmp/slate-v2/playwright/integration/examples/images.test.tsActions taken:
resolveSlateVoidSpacerChildren(...) to the internal shell runtime.VoidElement so normal children populate the hidden spacer slot.spacer as an optional advanced escape hatch that overrides
children.spacer={children}.Commands run:
PLAYWRIGHT_BASE_URL=http://localhost:3100 bun -e "...takeSlateBrowserRenderStateSnapshot..."
bun --filter slate-react test:vitest -- shell-runtime-contract primitives-contract render-profiler-contract
bun lint:fix
bun typecheck
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/images.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/search-highlighting.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line
Evidence:
VoidElement no longer makes normal callers spell the hidden spacer slot as
spacer={children}.spacer prop still works for callers that need to override the
hidden anchor content explicitly.3100 reported-regression browser set remains green after the
void-spacer DX change.VoidElement wrapper itself, not a broad editor-tree rerender.Rejected tactics:
active goal state done while
selector-owned projection work remains.VoidElement; that undercounts the wrapper the profiler now
measures.Remaining risks:
{children} to VoidElement; the next DX step is a
runtime-owned visible-content API where normal void renderers never see the
hidden anchor at all.Next action:
Slice name: selector-owned selection sync tracer.
Owner classification: selection projection and editable-root render budget.
Files changed:
.tmp/slate-v2/packages/slate-react/src/editable/caret-engine.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsx.tmp/slate-v2/packages/slate-react/src/hooks/use-slate-selector.tsx.tmp/slate-v2/packages/slate-react/dist/index.js.tmp/slate-v2/playwright/integration/examples/images.test.ts.tmp/slate-v2/playwright/integration/examples/embeds.test.ts.tmp/slate-v2/playwright/integration/examples/mentions.test.ts.tmp/slate-v2/playwright/integration/examples/tables.test.ts.tmp/slate-v2/playwright/integration/examples/hovering-toolbar.test.tsActions taken:
forceRender from model-owned caret sync-selection repair.set_selection operations
do not invalidate EditableDOMRoot.useSelected
updates selected shells without relying on an editable-root render as the
flush trigger.slate-react dist because the local examples load the package
artifact.Commands run:
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/images.test.ts --project=chromium --reporter=line -g "moves horizontally"
0, received 1.slate-react build: 1 passed.PLAYWRIGHT_BASE_URL=http://localhost:3100 bun -e "...takeSlateBrowserRenderStateSnapshot..."
bun --filter slate-react build
is-hotkey external.bun --filter slate-react test:vitest -- use-selected surface-contract provider-hooks-contract shell-runtime-contract primitives-contract render-profiler-contract
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/images.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/search-highlighting.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line
bun lint:fix
bun typecheck
Evidence:
useSelected listeners can flush independently of
EditableDOMRoot.Rejected tactics:
slate-react source without
rebuilding dist; the examples import the package artifact.Remaining risks:
{children}. The full DX target remains:
app authors render visible void content only, while the runtime owns hidden
anchors/spacers.EditableDOMRoot still owns too much event, selection, repair, and
composition orchestration.Next action:
Slice name: commit-owned selection-impact runtime ids.
Owner classification: core commit facts plus selected-shell subscription filtering.
Files changed:
.tmp/slate-v2/packages/slate/src/interfaces/editor.ts.tmp/slate-v2/packages/slate/src/core/public-state.ts.tmp/slate-v2/packages/slate/test/snapshot-contract.ts.tmp/slate-v2/packages/slate-react/src/components/slate.tsx.tmp/slate-v2/packages/slate-react/src/hooks/use-selected.ts.tmp/slate-v2/packages/slate-react/src/hooks/use-slate-selector.tsx.tmp/slate-v2/packages/slate-react/test/provider-hooks-contract.tsxActions taken:
selectionImpactRuntimeIds to core commit output.slate-react selector bridge.useSelected so selected-shell listeners wake only when their
runtime id is in the commit impact set, while replace/unknown commits still
take the conservative path.shouldUpdate.Commands run:
bun test ./packages/slate/test/snapshot-contract.ts -t "publishes selection-only dirtiness"
selectionImpactRuntimeIds was missing.bun --filter slate-react test:vitest -- provider-hooks-contract use-selected surface-contract
bun lint:fix
bun typecheck
Map while snapshot
indexes use records.bun --filter ./packages/slate build && bun --filter slate-react build
slate-react still warns that is-hotkey is external.PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/images.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/search-highlighting.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line
Evidence:
touchedRuntimeIds empty.useSelected no longer has to recompute and compare for every selected-shell
subscriber on every selection-only commit.Rejected tactics:
useSelected
must work for elements inside expanded selections.Remaining risks:
useSelected, but the general
node/text/decoration selector API is not yet complete.EditableDOMRoot still owns event, selection, repair, and composition
orchestration that belongs behind internal runtime controllers.Next action:
Slice name: decoration-impact commit facts plus runtime-id projection subscriptions.
Owner classification: core decoration impact facts and first Phase 3 projection fanout cut.
Files changed:
.tmp/slate-v2/packages/slate/src/interfaces/editor.ts.tmp/slate-v2/packages/slate/src/core/public-state.ts.tmp/slate-v2/packages/slate/test/snapshot-contract.ts.tmp/slate-v2/packages/slate-react/src/hooks/use-slate-projections.tsx.tmp/slate-v2/packages/slate-react/src/projection-store.ts.tmp/slate-v2/packages/slate-react/test/projections-and-selection-contract.tsxActions taken:
decorationImpactRuntimeIds to core commit output.null broad
invalidation.useSlateProjections(runtimeId) to subscribe to that runtime id
instead of the whole projection snapshot when the store supports it.Commands run:
bun test ./packages/slate/test/snapshot-contract.ts -t "publishes touched runtime ids|publishes selection-only dirtiness|publishes replace-level broad invalidation"
decorationImpactRuntimeIds was missing.bun --filter slate-react test:vitest -- projections-and-selection-contract -t "notifies only subscribers"
bun --filter slate-react test:vitest -- projections-and-selection-contract provider-hooks-contract use-selected surface-contract
bun lint:fix
bun typecheck
bun --filter ./packages/slate build && bun --filter slate-react build
slate-react still warns that is-hotkey is external.PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/images.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/search-highlighting.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line
Evidence:
Rejected tactics:
useSlateProjections still subscribes
to the whole snapshot. The store fanout had to be cut too.Remaining risks:
Next action:
decorationImpactRuntimeIds proves their
runtime ids are unaffected.Slice name: projection source runtime-scope recompute guard.
Owner classification: Phase 3 source recompute filtering.
Files changed:
.tmp/slate-v2/packages/slate-react/src/projection-store.ts.tmp/slate-v2/packages/slate-react/src/index.ts.tmp/slate-v2/packages/slate-react/test/projections-and-selection-contract.tsxActions taken:
SlateProjectionRuntimeScope.runtimeScope to projection-store options.change.decorationImpactRuntimeIds to skip source recompute when a
text/selection commit misses the source runtime scope.Commands run:
bun --filter slate-react test:vitest -- projections-and-selection-contract -t "skips source recompute"
bun test ./packages/slate/test/snapshot-contract.ts -t "publishes touched runtime ids|publishes selection-only dirtiness|publishes replace-level broad invalidation"
bun --filter slate-react test:vitest -- projections-and-selection-contract provider-hooks-contract use-selected surface-contract
bun lint:fix
bun typecheck
bun --filter ./packages/slate build && bun --filter slate-react build
slate-react still warns that is-hotkey is external.PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/images.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/search-highlighting.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line
Evidence:
Rejected tactics:
Remaining risks:
runtimeScope.Next action:
runtimeScope to a real projection source path, starting with
search-highlighting or legacy decorate compatibility, and prove the browser
focus/render canary still holds.Slice name: scoped projection sources in live examples.
Owner classification: Phase 3 real source adoption and browser proof.
Files changed:
.tmp/slate-v2/site/examples/ts/search-highlighting.tsx.tmp/slate-v2/site/examples/ts/code-highlighting.tsxActions taken:
Commands run:
bun lint:fix
bun --filter slate-react test:vitest -- projections-and-selection-contract provider-hooks-contract use-selected surface-contract
bun typecheck
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/code-highlighting.test.ts playwright/integration/examples/search-highlighting.test.ts playwright/integration/examples/images.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line
Evidence:
runtimeScope option instead of leaving it as
only a contract-test hook.Rejected tactics:
Remaining risks:
Next action:
useNodeSelector, useTextSelector, useDecorationSelector, and source-key
subscriptions, keeping the existing browser canaries as the regression floor.Slice name: node/text/decoration selector hooks.
Owner classification: Phase 3 selector API shape and commit-fact filtering.
Files changed:
.tmp/slate-v2/packages/slate/src/interfaces/editor.ts.tmp/slate-v2/packages/slate/src/core/public-state.ts.tmp/slate-v2/packages/slate/test/snapshot-contract.ts.tmp/slate-v2/packages/slate-react/src/hooks/use-node-selector.tsx.tmp/slate-v2/packages/slate-react/src/hooks/use-decoration-selector.tsx.tmp/slate-v2/packages/slate-react/src/index.ts.tmp/slate-v2/packages/slate-react/test/provider-hooks-contract.tsx.tmp/slate-v2/packages/slate-react/test/projections-and-selection-contract.tsxActions taken:
nodeImpactRuntimeIds to core commit output.useNodeSelector and useTextSelector with runtime-id based
invalidation using nodeImpactRuntimeIds.useDecorationSelector that derives from one runtime id's projection
entries without subscribing to the whole projection snapshot.slate-react barrel.Commands run:
bun --filter slate-react test:vitest -- provider-hooks-contract -t "runtime selector hooks"
useNodeSelector was not exported.bun --filter slate-react test:vitest -- projections-and-selection-contract -t "useDecorationSelector"
useDecorationSelector was not exported.bun test ./packages/slate/test/snapshot-contract.ts -t "publishes touched runtime ids|publishes selection-only dirtiness|publishes replace-level broad invalidation|publishes marks-only"
bun --filter slate-react test:vitest -- provider-hooks-contract projections-and-selection-contract use-selected surface-contract
bun lint:fix
bun typecheck
bun --filter ./packages/slate build && bun --filter slate-react build
slate-react still warns that is-hotkey is external.PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/code-highlighting.test.ts playwright/integration/examples/search-highlighting.test.ts playwright/integration/examples/images.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line
Evidence:
shouldUpdate predicates
for the common runtime-id case.Rejected tactics:
decorationImpactRuntimeIds; node data needed
an explicit nodeImpactRuntimeIds fact.Remaining risks:
EditableTextBlocks and EditableText still use custom selector code in hot
paths instead of the new hooks.Next action:
useNodeSelector / useTextSelector and prove the reported canaries
plus focused render-budget tests stay green.Slice name: descendant binding on useNodeSelector plus selection export guard.
Owner classification: Phase 3 internal hot-path selector adoption.
Files changed:
.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx.tmp/slate-v2/packages/slate-react/src/components/editable.tsxdocs/solutions/ui-bugs/2026-04-27-slate-react-selection-export-listeners-must-skip-dom-owned-selection.mdActions taken:
EditableDescendantNodeInner from custom useSlateSelector
shouldUpdate logic to useNodeSelector({ runtimeId }).editable-text-blocks.tsx.EditableDOMRoot for
selection-only commits so app/programmatic editor.select(...) exports to
DOM without forcing an editable-root rerender.docs/solutions/ui-bugs/.Commands run:
bun --filter slate-react test:vitest -- app-owned-customization -t "scrollSelectionIntoView"
scrollSelectionIntoView saw no DOM range.PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/hovering-toolbar.test.ts -g "real mouse selection" --project=chromium --reporter=line
bun --filter slate-react test:vitest -- provider-hooks-contract projections-and-selection-contract use-selected surface-contract app-owned-customization rendered-dom-shape-contract
bun test ./packages/slate/test/snapshot-contract.ts -t "publishes touched runtime ids|publishes selection-only dirtiness|publishes replace-level broad invalidation|publishes marks-only"
bun lint:fix
bun typecheck
bun --filter ./packages/slate build && bun --filter slate-react build
slate-react still warns that is-hotkey is external.PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/code-highlighting.test.ts playwright/integration/examples/search-highlighting.test.ts playwright/integration/examples/images.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line
dev-browser --connect http://127.0.0.1:9222
/examples/hovering-toolbar: real mouse drag produced expanded
model selection [0,0]@1 -> [0,0]@32, non-empty DOM selection, and visible
positioned toolbar (opacity about 0.99, top 89.5px, left
348.082px).Evidence:
3100 server after rebuilding package output.Rejected tactics:
shouldUpdate logic when commit facts can
drive the runtime-id selector.Remaining risks:
BoundEditableText still has a custom useSlateSelector binding and should
move onto useTextSelector.EditableDOMRoot; the later
selection runtime extraction should own it.Next action:
BoundEditableText onto useTextSelector, then continue source-key
subscription design.Slice name: bound text on useTextSelector.
Owner classification: Phase 3 internal hot-path selector adoption.
Files changed:
.tmp/slate-v2/packages/slate-react/src/components/editable-text.tsxActions taken:
BoundEditableText from direct useSlateSelector binding to
useTextSelector.text and marks overrides working while node/path/runtime id
data comes from the runtime-id text selector context.Commands run:
bun --filter slate-react test:vitest -- provider-hooks-contract projections-and-selection-contract use-selected surface-contract app-owned-customization rendered-dom-shape-contract
bun test ./packages/slate/test/snapshot-contract.ts -t "publishes touched runtime ids|publishes selection-only dirtiness|publishes replace-level broad invalidation|publishes marks-only"
bun lint:fix
bun typecheck
bun --filter ./packages/slate build && bun --filter slate-react build
slate-react still warns that is-hotkey is external.PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/code-highlighting.test.ts playwright/integration/examples/search-highlighting.test.ts playwright/integration/examples/images.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line
dev-browser --connect http://127.0.0.1:9222
/examples/search-highlighting: search input stayed focused with
value a, and [data-cy="search-highlighted"] count was 12.Evidence:
Rejected tactics:
useSlateSelector path after the selector
hook exists.path when no runtime id is passed.Remaining risks:
Next action:
Slice name: targeted source-key projection refresh.
Owner classification: Phase 3 projection-store invalidation narrowing.
Files changed:
.tmp/slate-v2/packages/slate-react/src/projection-store.ts.tmp/slate-v2/packages/slate-react/src/hooks/use-slate-projections.tsx.tmp/slate-v2/packages/slate-react/test/projections-and-selection-contract.tsx.tmp/slate-v2/site/examples/ts/search-highlighting.tsx.tmp/slate-v2/site/examples/ts/external-decoration-sources.tsxActions taken:
refresh({ sourceId }) so external decoration refreshes can target one
projection source instead of recomputing every source.subscribeSourceId(sourceId, listener) to let source-local subscribers
update without waking unrelated projection readers.sourceId is passed.Commands run:
bun --filter slate-react test:vitest -- projections-and-selection-contract -t "targeted source refresh"
bun --filter slate-react test:vitest -- projections-and-selection-contract provider-hooks-contract use-selected surface-contract app-owned-customization rendered-dom-shape-contract
bun test ./packages/slate/test/snapshot-contract.ts -t "publishes touched runtime ids|publishes selection-only dirtiness|publishes replace-level broad invalidation|publishes marks-only"
bun lint:fix
bun typecheck
bun --filter ./packages/slate build && bun --filter slate-react build
slate-react still warns that is-hotkey is external.PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/code-highlighting.test.ts playwright/integration/examples/search-highlighting.test.ts playwright/integration/examples/images.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line
dev-browser --connect http://127.0.0.1:9222
/examples/external-decoration-sources: beta diagnostics updated
the source snapshot, last update text reported
refresh({ reason: "external", sourceId: "external-diagnostics" }), and
only warm diagnostics were visible.Evidence:
Rejected tactics:
Remaining risks:
slate-browser replay
contract layer.Next action:
EditableDOMRoot.Slice name: selection-only DOM export policy extraction.
Owner classification: Phase 5 internal selection runtime extraction.
Files changed:
.tmp/slate-v2/packages/slate-react/src/editable/selection-runtime.ts.tmp/slate-v2/packages/slate-react/src/components/editable.tsx.tmp/slate-v2/packages/slate-react/test/selection-runtime-contract.test.tsActions taken:
EditableDOMRoot.Commands run:
bun --filter slate-react test:vitest -- selection-runtime-contract
bun --filter slate-react test:vitest -- selection-runtime-contract provider-hooks-contract projections-and-selection-contract use-selected surface-contract app-owned-customization rendered-dom-shape-contract
bun test ./packages/slate/test/snapshot-contract.ts -t "publishes touched runtime ids|publishes selection-only dirtiness|publishes replace-level broad invalidation|publishes marks-only"
bun lint:fix
bun typecheck
bun --filter ./packages/slate build && bun --filter slate-react build
slate-react still warns that is-hotkey is external.PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/code-highlighting.test.ts playwright/integration/examples/search-highlighting.test.ts playwright/integration/examples/images.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --reporter=line
dev-browser --connect http://127.0.0.1:9222
/examples/hovering-toolbar: real mouse drag produced expanded
model selection, non-empty DOM selection, and visible toolbar opacity.Evidence:
Rejected tactics:
EditableDOMRoot.Remaining risks:
slate-browser contracts
still need a dedicated Phase 4/5 lane.Next action:
slate-browser replay contracts.Slice name: runtime-owned VoidElement spacer children.
Owner classification: hard-cut shell cleanup, runtime-owned void structure.
Files changed:
.tmp/slate-v2/packages/slate-react/src/context.tsx.tmp/slate-v2/packages/slate-react/src/components/void-element.tsx.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx.tmp/slate-v2/packages/slate-react/src/shell-runtime.ts.tmp/slate-v2/packages/slate-react/test/surface-contract.tsx.tmp/slate-v2/packages/slate-react/test/primitives-contract.tsx.tmp/slate-v2/packages/slate-react/test/shell-runtime-contract.test.tsx.tmp/slate-v2/packages/slate-react/test/render-profiler-contract.test.tsx.tmp/slate-v2/site/examples/ts/images.tsx.tmp/slate-v2/site/examples/ts/embeds.tsx.tmp/slate-v2/site/examples/ts/paste-html.tsxActions taken:
VoidSpacerChildrenContext so the runtime provides hidden spacer
children while ordinary app renderers render visible void content only.children and spacer from the public VoidElement props.resolveSlateVoidSpacerChildren.VoidElement.docs/solutions.Commands run:
bun --filter slate-react test:vitest -- surface-contract -t "void renderers do not pass"
bun --filter slate-react test:vitest -- surface-contract primitives-contract shell-runtime-contract render-profiler-contract
bun --filter slate-react test:vitest -- selection-runtime-contract provider-hooks-contract projections-and-selection-contract use-selected surface-contract app-owned-customization rendered-dom-shape-contract primitives-contract shell-runtime-contract render-profiler-contract
bun lint:fix
bun typecheck
bun --filter ./packages/slate build && bun --filter slate-react build
slate-react still warns that is-hotkey is external.PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/images.test.ts playwright/integration/examples/embeds.test.ts playwright/integration/examples/mentions.test.ts --project=chromium --reporter=line
dev-browser --connect http://127.0.0.1:9222
/examples/images: ArrowRight moved into DOM path 1,0,
editor focus stayed active, the image void had one spacer and one
zero-width node, and visible content offset was 0./examples/embeds: iframe was visible, the embed void had one
spacer and one zero-width node, and input-to-next-paragraph gap was 16.rg -n "resolveSlateVoidSpacerChildren|spacer=|spacer\\?:|children\\?: ReactNode|<VoidElement[^\\n]*>" packages/slate-react/src packages/slate-react/test site/examples/ts/images.tsx site/examples/ts/embeds.tsx site/examples/ts/paste-html.tsx
VoidElement spacer prop, helper, or children prop remains;
non-void generic children?: ReactNode props still exist where expected.Evidence:
VoidElement renderers no longer receive or pass hidden spacer
children.Rejected tactics:
spacer prop.children alias on VoidElement.Remaining risks:
slate-browser still needs generated replay contracts over operation
families, not only curated example canaries.Next action:
Slice name: runtime-owned InlineVoidElement hidden anchor children.
Owner classification: hard-cut shell cleanup, runtime-owned inline atom structure.
Files changed:
.tmp/slate-v2/packages/slate-react/src/context.tsx.tmp/slate-v2/packages/slate-react/src/components/inline-void-element.tsx.tmp/slate-v2/packages/slate-react/src/components/slate-element.tsx.tmp/slate-v2/packages/slate-react/src/components/void-element.tsx.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx.tmp/slate-v2/packages/slate-react/src/index.ts.tmp/slate-v2/packages/slate-react/test/surface-contract.tsx.tmp/slate-v2/site/examples/ts/mentions.tsxdocs/solutions/developer-experience/2026-04-27-slate-react-void-renderers-should-not-own-hidden-spacer-children.mdActions taken:
VoidHiddenChildrenContext, because the same runtime-owned children feed
block void spacers and inline void hidden anchors.InlineVoidElement as the visible-content primitive for inline voids.InlineVoidElement.SlateElement pass-through DOM props while keeping Slate shell
attributes owned by the primitive.Commands run:
bun --filter slate-react test:vitest -- surface-contract -t "inline void renderers"
bun --filter slate-react test:vitest -- surface-contract primitives-contract shell-runtime-contract render-profiler-contract
bun --filter slate-react test:vitest -- selection-runtime-contract provider-hooks-contract projections-and-selection-contract use-selected surface-contract app-owned-customization rendered-dom-shape-contract primitives-contract shell-runtime-contract render-profiler-contract
bun lint:fix
bun typecheck
bun --filter ./packages/slate build && bun --filter slate-react build
slate-react still warns that is-hotkey is external.PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/mentions.test.ts --project=chromium --reporter=line
PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/images.test.ts playwright/integration/examples/embeds.test.ts playwright/integration/examples/mentions.test.ts --project=chromium --reporter=line
dev-browser --connect http://127.0.0.1:9222
/examples/mentions: mention-R2-D2 rendered as
data-slate-inline="true" and data-slate-void="true" with
contenteditable="false", one zero-width hidden anchor, and text path
1,1,0; ArrowRight from the preceding text landed at DOM path 1,2
offset 0 with editor focus still active.rg -n "VoidSpacerChildrenContext|spacer=|spacer\\?:|children.*@|@.*children|IS_MAC|utils/environment|VoidHiddenChildrenContext|InlineVoidElement" ...
Evidence:
Rejected tactics:
children path for mention renderers.Remaining risks:
editable-voids and large-document runtime demos still contain advanced
renderer-owned content patterns and need a separate decision before cutting;
they are not ordinary visible-content-only void renderers.Next action:
slate-browser replay contracts over
void/atom operation families, or for shell event/repair/composition
extraction. The runtime-owned ordinary void/atom shell owner is complete.