docs/research/decisions/editor-node-dx-should-use-runtime-owned-shells-and-spec-first-renderers.md
The editor should make spec-first extension definitions the normal way to define elements, marks, text behavior, renderers, commands, and browser contracts.
The runtime should own DOM shells, hidden anchors, editable content slots, selection mapping, mutation filtering, and commit dirtiness.
App authors should render visible UI with normal React components. They should not be responsible for void spacer placement, editable child wrappers, internal data attributes, or DOM selection repair.
const Callout = defineElement({
type: 'callout',
kind: 'block',
content: blockPlus(),
attrs: {
tone: enumValue(['info', 'warning']),
},
render: CalloutView,
})
Container renderers receive a branded Content slot:
function CalloutView({ Content, attrs }: ElementRenderProps<typeof Callout>) {
return (
<aside data-tone={attrs.tone}>
<Content />
</aside>
)
}
const Image = defineElement({
type: 'image',
kind: 'block',
content: none(),
attrs: {
src: url(),
},
behavior: {
atom: true,
selectable: true,
},
render: ImageView,
})
Atom renderers do not receive raw children. The hidden text anchor is automatic.
defineTextBehavior({
mode: 'token',
range: mentionRange(),
render: MentionTokenView,
})
Text remains an intrinsic primitive. Custom text classes are not part of the main API.
const Link = defineMark({
type: 'link',
attrs: {
href: url(),
},
behavior: {
inclusive: false,
},
render: ({ attrs, children }) => <a href={attrs.href}>{children}</a>,
})
Marks own text formatting. Interactive mark controls should normally be overlays tied to ranges, not DOM-heavy mark views.
defineExtension({
name: 'image',
nodes: [Image],
commands: {
insertImage(editor, attrs) {
editor.update((tx) => {
tx.nodes.insert(Image.create(attrs))
})
},
},
browserContracts: [atomicBlockNavigation(Image)],
})
Use the state / tx decision as the current authority for public command
examples. Element and extension specs can define commands, but normal writes
inside those commands should go through editor.update((tx) => tx.*).
Primitive editor.* writes are not the public example shape for this decision.
The correct split is:
runtime owns browser correctness
extension specs own behavior
React renderers own visible UI
commit dirtiness owns performance
{children} the public void/atom contract.There can be an advanced DOM-owner API:
renderShellUnsafe: CustomShell
It must require explicit browser contracts. A powerful escape hatch without generated browser tests will reproduce the same selection and void layout regressions at plugin scale.
Plate plugins can move feature by feature:
Yjs should sync document content and typed node state. Runtime ids, shell DOM, hidden anchors, and selection import/export remain local runtime facts.
Accepted for the next editor node API design pass.