docs/plans/2026-04-28-slate-v2-runtime-owned-void-shell-render-api-plan.md
Done.
Execution started from complete-plan on 2026-04-28.
Current next owner: none. The lane passed its completion target.
Remove the remaining public void-rendering footgun.
The target is not "make VoidElement safer." The target is to make app and
plugin authors unable to own hidden spacer, hidden anchor, contentEditable,
selection mapping, or void shell placement by default.
This plan covers only:
renderVoid API.No internal compatibility lane. VoidElement and InlineVoidElement are not
kept as normal authoring APIs after this cut.
Normal elements stay close to Slate:
renderElement({ attributes, children, element }) {
return <p {...attributes}>{children}</p>
}
Voids stop pretending hidden children are user interface:
renderVoid({ element, selected, focused, actions }) {
return <ImageCard src={element.url} onRemove={actions.remove} />
}
slate-react owns the browser contract:
<ElementShell data-slate-node="element" data-slate-void>
<VoidContent contentEditable={false}>{visibleContent}</VoidContent>
<HiddenTextAnchor>{hiddenChildren}</HiddenTextAnchor>
</ElementShell>
VoidElement / InlineVoidElement as public authoring
routes.<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
renderText={renderText}
renderVoid={renderVoid}
/>
type RenderVoidProps<TElement extends Element = Element> = {
actions: {
focus: () => void;
remove: () => void;
select: () => void;
};
element: TElement;
focused: boolean;
selected: boolean;
};
No attributes.
No children.
No contentEditable.
No spacer prop.
No hidden anchor prop.
No path prop by default.
If authors need path-like targeting, expose actions or runtime-id based hooks,
not ReactEditor.findPath as the normal flow.
isVoid remains the legacy model predicate, but slate-react needs explicit
render/runtime kinds:
type VoidRenderKind =
| "block"
| "inline"
| "markable-inline"
| "editable-island";
Expose it as editor/runtime configuration, not as Plate-style node specs:
editor.voidKind = (element) => {
switch (element.type) {
case "image":
case "video":
return "block";
case "mention":
return "markable-inline";
case "editable-void":
return "editable-island";
default:
return null;
}
};
Default behavior:
Editor.isVoid(editor, element) && Editor.isInline(editor, element) maps to
inlineEditor.isVoid(editor, element) maps to blockeditor.markableVoid(element) upgrades inline voids to markable-inlineeditable-islandPurpose: make the DOM contract owned by slate-react before changing public
author APIs.
Actions:
SlateVoidShellSlateInlineVoidShellSlateEditableIslandShellVoidContentHiddenTextAnchorEditableDescendantNode choose the shell when Editor.isVoid(...)
returns true.renderElement flow.data-slate-node="element"data-slate-voiddata-slate-inline when neededcontentEditable={false} around visible contentVoidHiddenChildrenContext from public renderer responsibility.Hard cuts:
VoidElement export.InlineVoidElement export.Acceptance:
VoidElement / InlineVoidElement imports.Driver gates:
bun --filter slate-react test:vitest test/surface-contract.test.tsx test/rendered-dom-shape-contract.test.tsx test/primitives-contract.test.tsx
bun --filter slate-react typecheck
renderVoidPurpose: give authors the best DX without a compatibility detour.
Actions:
renderVoid to EditableProps.RenderVoidProps types beside RenderElementProps.renderVoid instead of renderElement.renderElement.selectfocusremoveselected and focused state using node/runtime-id selectors, not broad
editor subscriptions.editable-island as a distinct kind. Its visible content may include
internal inputs or nested editors, but the outer hidden anchor remains
runtime-owned.Hard cuts:
renderVoid, render a minimal runtime-owned fallback
shell, not the old renderElement children path.renderElement for voids as a fallback.renderVoidShellUnsafe in this lane unless a current example
genuinely cannot be represented. If it is added, it must require explicit
browser contracts and must not be documented as normal DX.Acceptance:
renderVoid.renderElement docs/tests prove it is for non-void elements.renderVoid props do not include attributes or children.children access in renderVoid.Driver gates:
bun --filter slate-react test:vitest test/surface-contract.test.tsx test/provider-hooks-contract.test.tsx test/render-profiler-contract.test.tsx
bun --filter slate-react typecheck
Purpose: stop finding void regressions by hand.
Actions:
slate-browser contract builders for:
contentEditable=falseFast CI subset:
bun test
bun --filter slate-react test:vitest test/surface-contract.test.tsx test/rendered-dom-shape-contract.test.tsx
PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/images.test.ts playwright/integration/examples/embeds.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/editable-voids.test.ts --project=chromium
Sparing stress gate:
bun test:stress
Release closure:
bun check:full
Acceptance:
bun check:full passes before this lane is marked done.Likely Slate v2 files:
.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx.tmp/slate-v2/packages/slate-react/src/components/void-element.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/slate-spacer.tsx.tmp/slate-v2/packages/slate-react/src/index.ts.tmp/slate-v2/packages/slate-react/test/surface-contract.tsx.tmp/slate-v2/packages/slate-react/test/rendered-dom-shape-contract.tsx.tmp/slate-v2/packages/slate-react/test/primitives-contract.tsx.tmp/slate-v2/playwright/stress/generated-editing.test.ts.tmp/slate-v2/packages/slate-browser/src/**.tmp/slate-v2/site/examples/ts/images.tsx.tmp/slate-v2/site/examples/ts/embeds.tsx.tmp/slate-v2/site/examples/ts/mentions.tsx.tmp/slate-v2/site/examples/ts/editable-voids.tsx.tmp/slate-v2/site/examples/ts/paste-html.tsx.tmp/slate-v2/site/examples/ts/large-document-runtime.tsxAdd or update guards that fail when:
VoidElement is exported from slate-react.InlineVoidElement is exported from slate-react.VoidElement or InlineVoidElement.renderVoid props include children.renderVoid props include attributes.renderElement.This lane is done only when:
renderVoidVoidElement and InlineVoidElement are gone as public APIsrenderVoidbun check:full passesDo not stop at Phase 1. The shell cut without renderVoid leaves DX awkward.
Do not stop at Phase 2. The new DX without generated browser contracts is just a prettier way to reintroduce the same bugs.
Stop only when Phase 3 closure proof passes, or when a real blocker prevents all autonomous progress.
Actions:
complete-plan.active goal state to status: pending.active goal state for this lane.EditableDOMRoot / root selector review findings are stale
for this lane because the root-runtime selector guard lane is already done.Commands:
Evidence:
done.Changed files:
active goal stateactive goal statedocs/plans/2026-04-28-slate-v2-runtime-owned-void-shell-render-api-plan.mdDecision:
Rejected tactics:
VoidElement / InlineVoidElement as public APIs while adding
renderVoid.Next action:
slate-react surface contracts that fail if public void helper
authoring paths remain, then implement runtime-owned void shell ownership.Actions:
renderVoid.SlateVoidShell and SlateInlineVoidShell.renderElement.renderVoid({ element, selected, focused, actions }).focus, select, remove, and setElement.VoidElement / InlineVoidElement implementations and
exports.renderVoid..test.tsx entrypoint for the rendered DOM shape contract.Commands:
bun --filter slate-react test:vitest test/surface-contract.test.tsx test/primitives-contract.test.tsx test/render-profiler-contract.test.tsxbun --filter slate-react typecheckbun typecheck:sitebun --filter slate-react test:vitest test/surface-contract.test.tsx test/rendered-dom-shape-contract.test.tsx test/primitives-contract.test.tsx test/render-profiler-contract.test.tsxbun biome check --write packages/slate-react/src/components/editable-text-blocks.tsx site/examples/ts/embeds.tsx site/examples/ts/paste-html.tsx site/examples/ts/images.tsx site/examples/ts/mentions.tsx site/examples/ts/editable-voids.tsx site/examples/ts/large-document-runtime.tsx site/examples/ts/custom-types.d.ts packages/slate-react/src/components/slate-void-shell.tsx packages/slate-react/src/index.ts packages/slate-react/src/context.tsx packages/slate-react/test/surface-contract.tsx packages/slate-react/test/primitives-contract.tsx packages/slate-react/test/render-profiler-contract.test.tsx packages/slate-react/test/rendered-dom-shape-contract.test.tsxbun lintEvidence:
slate-react typecheck passed.EditableVoidElement model type.Changed files:
.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx.tmp/slate-v2/packages/slate-react/src/components/slate-void-shell.tsx.tmp/slate-v2/packages/slate-react/src/components/void-element.tsx.tmp/slate-v2/packages/slate-react/src/components/inline-void-element.tsx.tmp/slate-v2/packages/slate-react/src/context.tsx.tmp/slate-v2/packages/slate-react/src/index.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/render-profiler-contract.test.tsx.tmp/slate-v2/packages/slate-react/test/rendered-dom-shape-contract.test.tsx.tmp/slate-v2/site/examples/ts/custom-types.d.ts.tmp/slate-v2/site/examples/ts/images.tsx.tmp/slate-v2/site/examples/ts/embeds.tsx.tmp/slate-v2/site/examples/ts/mentions.tsx.tmp/slate-v2/site/examples/ts/paste-html.tsx.tmp/slate-v2/site/examples/ts/editable-voids.tsx.tmp/slate-v2/site/examples/ts/large-document-runtime.tsxDecision:
pending. Phase 1/2 core DX is verified, but Phase 3 generated
browser contracts are still required before completion.Rejected tactics:
path or ReactEditor.findPath.
Use node-local actions.setElement(...).Next action:
slate-browser generated contract
builders and adding void/atom family rows for the migrated examples.Actions:
renderVoid to the large-document runtime void editor.root.focus() restores the outer editor selection instead of landing in the
nested editor.Commands:
bun biome check --write packages/slate-react/src/editable/runtime-focus-mouse-events.ts packages/slate-react/src/editable/runtime-event-engine.ts packages/slate-react/src/editable/selection-reconciler.ts playwright/integration/examples/embeds.test.tsPLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/editable-voids.test.ts playwright/integration/examples/embeds.test.ts --project=chromium --project=firefox --project=webkitSTRESS_FAMILIES=editable-island-native-focus,block-void-navigation PLAYWRIGHT_WORKERS=1 PLAYWRIGHT_RETRIES=0 bun test:stressbun --filter slate-react typecheckbun --filter slate-browser typecheckbun typecheck:sitebun typecheck:rootbun lintbun check:fullEvidence:
slate-react, slate-browser, site, and root typechecks passed.bun check:full passed: package lint/type/unit/vitest, release discipline,
slate-browser proof contracts, scoped mobile proof, persistent-profile soak,
and full integration/local browser sweep.Changed files:
.tmp/slate-v2/packages/slate-react/src/components/slate-void-shell.tsx.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx.tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts.tmp/slate-v2/packages/slate-react/src/editable/runtime-focus-mouse-events.ts.tmp/slate-v2/packages/slate-react/src/editable/runtime-event-engine.ts.tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.ts.tmp/slate-v2/packages/slate-browser/src/playwright/index.ts.tmp/slate-v2/playwright/stress/generated-editing.test.ts.tmp/slate-v2/playwright/stress/replay.test.ts.tmp/slate-v2/playwright/stress/stress-utils.ts.tmp/slate-v2/playwright/integration/examples/embeds.test.ts.tmp/slate-v2/site/examples/ts/large-document-runtime.tsxactive goal statedocs/plans/2026-04-28-slate-v2-runtime-owned-void-shell-render-api-plan.mdDecision:
bun check:full
passed.Rejected tactics:
root.focus().Next action: