docs/research/systems/editor-node-text-mark-dx-landscape.md
The best public node API is not Slate renderElement, ProseMirror NodeView,
Lexical node subclasses, or Tiptap React NodeViews.
The best API is:
spec-first nodes/marks/text behavior
+ runtime-owned DOM shells
+ app-owned visible React renderers
+ dirty-commit-backed selector subscriptions
The runtime owns the browser contract. App authors own the UI.
| Corpus | Best part | Bad default to avoid |
|---|---|---|
| ProseMirror | Declarative node/mark schema, atom/selectable/isolating flags, mapped decorations | Imperative NodeViews as normal React authoring surface |
| Lexical | Read/update lifecycle, dirty leaves/elements, text modes, NodeState | Public node classes, subclass replacement, fast-refresh full-refresh pressure |
| Tiptap | Extension packaging, commands, attrs, React selectors, product docs | React NodeView wrapper/contentDOM handoff as the normal node renderer |
Elements should be declared through typed specs:
const Image = defineElement({
type: 'image',
kind: 'block',
content: none(),
attrs: {
alt: string().optional(),
src: url(),
},
behavior: {
atom: true,
draggable: true,
selectable: true,
},
parse: html('img[src]', dom => ({
alt: dom.getAttribute('alt') ?? undefined,
src: dom.getAttribute('src')!,
})),
serialize: html(({ attrs }) => ['img', attrs]),
render: ImageView,
})
render receives typed props and returns visible UI only:
function ImageView({
actions,
attrs,
selected,
}: ElementRenderProps<typeof Image>) {
return (
<figure data-selected={selected}>
<button onClick={() => actions.remove()}>Remove</button>
</figure>
)
}
For containers, the runtime provides a branded content slot:
function CalloutView({ Content, attrs }: ElementRenderProps<typeof Callout>) {
return (
<aside data-tone={attrs.tone}>
<Content />
</aside>
)
}
The slot is not raw {children}. It is the runtime's owned editable content
mount. Omitting it in a node with editable content is a development error.
Text should remain an intrinsic primitive. Authors should not define text node classes.
The API should expose typed text behavior and annotation layers:
const MentionToken = defineTextBehavior({
match: mentionRange(),
mode: 'token',
render: MentionTokenView,
})
Steal Lexical's text modes as declarative behavior:
normaltokensegmentedunmergeabledirectionlessDo not let custom text renderers replace the text DOM mapping by default. Formatting belongs to marks, annotations, and decorations.
Marks should be typed specs with attrs, parse/serialize, exclusion rules, and pure inline renderers:
const Link = defineMark({
type: 'link',
attrs: {
href: url(),
title: string().optional(),
},
behavior: {
inclusive: false,
},
parse: html('a[href]', dom => ({
href: dom.getAttribute('href')!,
title: dom.getAttribute('title') ?? undefined,
})),
render: ({ attrs, children }) => (
<a href={attrs.href} title={attrs.title}>
{children}
</a>
),
})
Interactive mark UI should prefer overlays tied to mark ranges. Mark wrappers should stay light enough that text DOM mapping remains predictable.
The runtime should always own:
The app renderer should not place hidden spacer children, data-slate-*
attributes, DOM refs, or selection plumbing manually.
Feature packages should own the whole feature:
const ImageExtension = defineExtension({
name: 'image',
nodes: [Image],
commands: {
insertImage(editor, input) {
editor.update(() => {
editor.insertNode(Image.create(input))
})
},
},
shortcuts: {},
pasteRules: [imageUrlPasteRule()],
ui: {
ToolbarButton: ImageToolbarButton,
},
browserContracts: [
atomicBlockNavigation(Image),
noVisibleSpacerLayout(Image),
],
})
This steals Tiptap's packaging, but not its required
chain().focus().run() ceremony.
React integration should be based on explicit selector subscriptions and commit facts:
const selected = useEditorSelector(editor, commit => {
return commit.selection.intersectsNode(nodeId)
})
The editor body should not rerender for every transaction. Node renderers should rerender only when one of these changes:
This goes beyond Tiptap's selector posture by using dirty commit data instead of asking React components to derive everything from broad editor snapshots.
Advanced integrations can exist, but they should look dangerous:
defineElement({
type: 'custom-dom-owner',
renderShellUnsafe: CustomShell,
browserContracts: [customShellSelectionContract()],
})
Escape hatches must require browser-contract tests. Otherwise the API will slowly recreate the same void, selection, and NodeView bugs under nicer names.
Every node/mark/text behavior spec should be able to generate contract tests:
The fast CI lane can run a curated subset. test:stress should replay the full
generated browser matrix.