docs/plans/2026-05-16-slate-v2-render-element-extension-dx-ralplan.md
Status: done Date: 2026-05-16 Owner: slate-ralplan Runtime id: 019e1fc0-dba0-7de1-9236-b484a144cda6 Latest activation: 2026-05-17 render props versus extension-only HH feedback
Yes. The current examples are weird.
The problem is not that renderElement and editor.extend(...) both exist. The
problem is that first-party feature examples use editor.extend(...) for
schema, transforms, paste, or key behavior, then keep a raw top-level
renderElement dispatcher for rendering. That teaches users that rendering is
special-case React prop wiring instead of part of the feature extension.
The target is:
editor.extend(...) + editableRenderers(...) = reusable feature rendering
Editable render props = per-instance override or reference escape hatch
Do not hard-cut Editable.renderElement. Hard-cut the default teaching pattern
where a feature example owns behavior through extensions but renders through a
throwaway Element switch.
2026-05-17 refinement:
Do not make extension-owned rendering the only API. That is too far from Slate's plain React contract and turns raw Slate into a plugin framework. The best shape has one normal composition model and one escape hatch:
extension-owned editableRenderers(...) = default reusable feature rendering
Editable renderElement/renderLeaf/renderText/renderVoid = whole-surface escape hatch
The current all-or-nothing precedence is defensible if docs name it honestly:
when a caller passes renderElement, that caller owns element rendering for the
Editable. Do not present it as a small additive override that composes with
extension renderers, because live source does not do that today.
Do not add next() render middleware in the first architecture cut. It looks
clever, but it reintroduces callback composition into the hot render path and
makes ownership harder to teach. If real app pressure proves a partial local
override is needed, add one local typed renderer-map prop that uses the same
EditableRenderers shape and merges above extension maps. Do not make raw
renderElement itself a chainable mini-plugin system.
Intent:
Editable render props available for app-local overridesIn scope:
editableRenderers(...) renderer-map typingRenderElementPropsFor<T>Non-goals:
renderElement, renderLeaf, renderText,
renderSegment, or renderVoidslate#3177, #4317, or #5349Decision boundaries:
editor.extend(...)editableRenderers(...)Editable render props are still public, but examples should present them
as explicit whole-Editable overrides, reference snippets, tests, and stress
harnesses<p> semantics,
because the live fallback currently renders EditableElement as div or
spanRenderer registration already exists.
.tmp/slate-v2/packages/slate-react/src/editable/editable-renderers.ts:34-43
defines renderer maps for elements, leaves, text, segment, and voids..tmp/slate-v2/packages/slate-react/src/editable/editable-renderers.ts:47-54
registers those maps through the slate-react.editable.renderers capability..tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:1390-1404
reads registered renderers and disables element or void maps when raw
Editable render props are supplied..tmp/slate-v2/packages/slate-react/test/surface-contract.tsx:523-597
proves Editable consumes extension-registered element, leaf, text, segment,
and void renderers..tmp/slate-v2/packages/slate-react/test/surface-contract.tsx:599-635
proves raw Editable render props override extension-registered renderers.The public type export is not the main problem.
.tmp/slate-v2/packages/slate-react/src/index.ts:42-53 exports
EditableRenderElementProps as RenderElementProps..tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:538-559
shows that public element renderer props include attributes, children,
element, isInline, and slots.The fallback matters.
.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:892-898
falls back to EditableElement as={inline ? 'span' : 'div'}. If first-party
examples cut their raw paragraph fallback, they need a registered paragraph
renderer where <p> matters.These examples combine extension-owned behavior with raw render props:
.tmp/slate-v2/site/examples/ts/check-lists.tsx:77-88 passes
renderElement={Element} while withChecklists calls editor.extend(...)..tmp/slate-v2/site/examples/ts/check-lists.tsx:138-146 uses a raw switch with
a default <p>..tmp/slate-v2/site/examples/ts/images.tsx:74-108 registers image schema and
paste/key behavior through editor.extend(...), but still passes
renderElement and inline renderVoid..tmp/slate-v2/site/examples/ts/images.tsx:155-159 has a top-level Element
whose only job is <p {...attributes}>{children}</p>..tmp/slate-v2/site/examples/ts/embeds.tsx:47-64 registers video schema with
editor.extend(...), then uses raw renderElement and raw renderVoid..tmp/slate-v2/site/examples/ts/embeds.tsx:69-73 repeats the trivial paragraph
renderer..tmp/slate-v2/site/examples/ts/inlines.tsx:112-143 registers inline schema
and paste/insert behavior through editor.extend(...), then passes raw
renderElement and renderLeaf..tmp/slate-v2/site/examples/ts/inlines.tsx:363-374 uses a raw switch for
link, button, badge, and paragraph fallback.Docs also send mixed signals:
.tmp/slate-v2/docs/libraries/slate-react/editable.md:54-58 says
editableRenderers(...) is for extension-owned document rendering and raw
props take precedence..tmp/slate-v2/docs/libraries/slate-react/editable.md:83-102 still leads a
dedicated renderElement section with a normal-element switch..tmp/slate-v2/docs/walkthroughs/03-defining-custom-elements.md:81-109
teaches extension renderers..tmp/slate-v2/docs/walkthroughs/04-applying-custom-formatting.md:9-55
starts from a raw renderElement callback, then
.tmp/slate-v2/docs/walkthroughs/04-applying-custom-formatting.md:70-85
switches to extension renderers.The fake example type helper is worse than the Element callback:
.tmp/slate-v2/site/examples/ts/custom-types.d.ts:188 defines
RenderElementPropsFor<T extends Element> = RenderElementProps<any>.That generic parameter is theater. It gives the appearance of per-element typing while erasing the element type.
Chosen shape:
Editable render props.editableRenderers(...) inside the same feature extension, or a clearly
named example rendering extension installed beside it.Element switch.RenderElementPropsFor<T> = RenderElementProps<any>.Why this wins:
Editable instance.Rejected alternatives:
renderElement: too harsh, breaks the simple app-level override
story, and contradicts current surface-contract tests.slate: wrong package boundary. Core must stay
non-React.any.next(): too much middleware ceremony in
a React hot path. It also blurs the clean rule that extension renderers compose
and raw props override.EditableRenderers map shape instead of inventing another renderer model.Status: complete for this activation's review pass.
Harsh take: "only extensions" is the wrong correction. It solves the two-way-story smell by deleting the Slate-ish part of Slate. A raw Slate React editor should still let a user pass a plain React renderer without creating a feature extension.
The real smell is examples and docs treating both surfaces as equal feature authoring paths. They are not equal:
| User intent | Public shape | Why |
|---|---|---|
| Reusable feature owns schema, behavior, and UI | editor.extend(feature()) plus editableRenderers(...) | Feature pieces move together and merge by element/leaf/void type. |
| One editor surface wants total custom rendering | <Editable renderElement={...} renderLeaf={...} /> | Slate-close escape hatch; caller knowingly owns that render family. |
| One-off demo or test probes render internals | raw render props | Lowest friction and closest to React. |
| Partial local override while preserving extension renderers | not in first cut | Add a local renderers map only if real demand appears. |
Live source makes the distinction concrete:
.tmp/slate-v2/packages/slate-react/src/editable/editable-renderers.ts:34-54
stores extension renderers as maps keyed by element/leaf/void names..tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:1398-1405
keeps registered leaf/text/segment renderers unless raw props are supplied,
and disables element/void maps when renderElement or renderVoid is passed..tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:856-865
resolves a node renderer from either the raw prop or the typed extension map..tmp/slate-v2/packages/slate-react/test/surface-contract.tsx:523-635
already proves both registered renderer consumption and raw-prop override
behavior.Architecture rule:
There is one composable renderer registry.
Extensions contribute to it.
Raw Editable render props bypass it for that render family.
That gives users a clear mental model without pretending there is only one installation site. "One architecture, two ownership scopes" is better than "everything must be an extension" and better than "every example writes a switch".
External-editor synthesis still backs this:
The examples should converge on this shape:
const checklists = defineEditorExtension({
name: "checklists",
capabilities: editableRenderers({
elements: {
paragraph: ParagraphElement,
"check-list-item": CheckListItemElement,
},
}),
transforms: {
deleteBackward({ editor, next }) {
if (applyChecklistBackspaceStart(editor)) return;
next();
},
},
});
const CheckListsExample = () => {
const editor = useSlateEditor({
initialValue,
withEditor: (editor) => {
editor = withHistory(editor);
editor.extend(checklists);
return editor;
},
});
return (
<Slate editor={editor}>
<Editable autoFocus placeholder="Get to work..." spellCheck />
</Slate>
);
};
The important part is that the reusable checklist renderer is installed through
the checklist extension, not passed as a raw Editable callback. Live source
shows editor.extend(...) returns a cleanup function, so examples should not
teach fluent chaining.
Before mass example rewrite, fix renderer typing so keyed element renderers get their narrowed element props.
Typed renderer-map target:
type ElementTypeOf<TElement> = TElement extends {
type: infer TType extends string;
}
? TType
: string;
type ElementForType<TElement, TType extends string> = Extract<
TElement,
{ type: TType }
> extends never
? TElement
: Extract<TElement, { type: TType }>;
export type EditableElementRendererMap<TElement extends Element = Element> = {
[K in ElementTypeOf<TElement>]?: RenderElementRenderer<
ElementForType<TElement, K>
>;
};
export type EditableVoidRendererMap<TElement extends Element = Element> = {
[K in ElementTypeOf<TElement>]?: RenderVoidRenderer<
ElementForType<TElement, K>
>;
};
Then EditableRenderers<T, TElement> should use those maps:
type EditableRenderers<T = unknown, TElement extends Element = Element> = {
elements?: EditableElementRendererMap<TElement>;
leaves?: Record<string, EditableLeafRenderer<T>>;
segment?: EditableSegmentRenderer<T>;
text?: EditableTextRenderer;
voids?: EditableVoidRendererMap<TElement>;
};
If TypeScript cannot infer cleanly from the current editableRenderers(...)
signature, use a curried helper or a satisfies-friendly exported type. Do not
invent a second renderer system.
Preferred first attempt:
editableRenderers<unknown, CustomElement>({
elements: {
"check-list-item": CheckListItemElement,
paragraph: ParagraphElement,
},
});
If generic arguments at every call site feel noisy, add a narrow helper such as
defineEditableRenderers<CustomElement>()({...}). Do not add it unless the type
test proves the generic call is too clumsy.
Example cleanup rule:
RenderElementPropsFor<T> = RenderElementProps<any>RenderVoidProps<ImageElement> only when it documents a
content-only void renderer and inference cannot express it cleanlyType pass:
Example pass:
editor.extend(...) to register
reusable renderers with editableRenderers(...)<p> outputconst Element = (props: RenderElementProps) => <p ... />
components from feature examplesDocs pass:
renderElement docs under escape hatch or per-instance overrideProof pass:
slate-react surface contractseditor.extend(...) with non-whitelisted raw render propsStatus: complete.
Decision:
element.typeElement fallback valideditableRenderers<unknown, CustomElement>(...) form is too noisyDesign constraints:
Element may not always expose a typed type property, so the helper needs a
string fallbackRequired tests for Ralph:
Element consumers can still pass arbitrary string renderer mapsEditable renderElement override precedence stays unchangedHigh-risk pass:
slate-react renderer types, example renderer code, docs<p> output if paragraph renderer registration is skippedEditable reference docs for raw render props#3177 is directly related. The live runtime already has plugin/extension-owned
renderer registration, but current examples still under-teach it. This plan
does not claim Fixes #3177 or Improves #3177.
#5349 stays blocked on repro. This plan does not prove render churn on empty
editors.
#4317 stays related pressure only. Registered renderers reduce callback
identity pressure, but the exact onSelect render-callback repro still needs
browser proof.
Objection: raw renderElement is the classic Slate mental model, so examples
should keep showing it.
Answer: keep it in the Editable reference and override docs. Do not teach it
as the normal feature-package pattern when the same example already uses
editor.extend(...) for schema and behavior.
Objection: extension renderers can become more ceremony for simple apps.
Answer: simple app-local overrides can still use raw props. Reusable document features should pay the tiny extension cost because that is how they compose.
Objection: typed renderer maps are not ready.
Answer: correct. That is why the type-DX pass comes first. Rewriting examples with casts would be fake polish.
Total: 0.92. The architecture verdict is strong enough for Ralph execution, and the later closure activation found no remaining Ralplan pass with a runnable planning move.
| Dimension | Score | Evidence |
|---|---|---|
| React runtime performance | 0.92 | Registered renderers are editor-owned and raw props disable maps only when explicitly supplied: .tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:1398-1405; rejecting next() middleware avoids new render-path callback chains. |
| Slate-close unopinionated DX | 0.94 | Raw render props remain public and tested as override escape hatches: .tmp/slate-v2/packages/slate-react/test/surface-contract.tsx:599-635; typed maps remove fake example typing without removing the escape hatch. |
| Plate and slate-yjs migration backbone | 0.91 | Research target keeps feature package ownership together without requiring current Plate API compatibility: docs/research/decisions/editor-node-dx-should-use-runtime-owned-shells-and-spec-first-renderers.md:176-186. |
| Regression-proof testing strategy | 0.92 | Plan names type contracts, surface-contract preservation, docs/example guard, and browser smoke for rewritten examples. Existing surface tests cover registered renderers and override precedence: .tmp/slate-v2/packages/slate-react/test/surface-contract.tsx:523-635. |
| Research evidence completeness | 0.91 | Corpus result favors spec-first extension APIs plus runtime-owned DOM and app-owned React renderers: docs/research/sources/editor-architecture/node-text-mark-render-dx-corpus-ledger.md:200-215; no new external source read was needed for this scoped review. |
| shadcn-style composability | 0.93 | Target preserves normal React components for visible UI and keeps runtime browser ownership separate: docs/research/systems/editor-node-text-mark-dx-landscape.md:21-30. |
current_pass: closure-final-gates current_pass_status: complete completed_passes:
skipped_this_activation:
Fixes #... or behavior claimfinal_handoff_status: complete next_pass: none next_action: none next_owner: ralph execution only if the user chooses implementation