docs/plans/2026-05-04-slate-v2-legacy-example-dx-ralplan.md
Score: 0.91, status: ready-for-user-review.
Hard take: the legacy examples are drifting into two bad teaching patterns:
Do not paper over that by making Slate magically opinionated. Fix the reusable surfaces that examples are exposing.
This plan updates, but does not replace, the accepted initialization plan:
Intent:
Outcome:
In scope:
.tmp/slate-v2/site/examples/ts/**.tmp/slate-v2/packages/slate-react/src/hooks/use-slate-editor.ts.tmp/slate-v2/packages/slate-react/src/decoration-source.ts.tmp/slate-v2/packages/slate-react/src/hooks/use-slate-annotations.tsx.tmp/slate-v2/packages/slate-react/src/components/slate.tsx.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx.agents/rules/ralph.mdc rule update, during execution onlyNon-goals:
slate-react depend on slate-history.commands; the current hard cut rejects
that surface.Issue-ledger accounting:
Fixes #.... rows are added by this plan.Editor creation:
useSlateEditor creates withReact(createEditor(editorOptions)) and only
runs withEditor when provided:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/hooks/use-slate-editor.ts:23.CreateEditorOptions only carries initialValue and initialSelection:
/Users/zbeyens/git/slate-v2/packages/slate/src/interfaces/editor.ts:616.slate-react has slate-history only as a dev dependency, not runtime
dependency:
/Users/zbeyens/git/slate-v2/packages/slate-react/package.json.History examples:
withEditor: (editor) => withHistory(editor) as CustomEditor, for example code highlighting:
/Users/zbeyens/git/slate-v2/site/examples/ts/code-highlighting.tsx:54.withEditor: withHistory, for example plain text:
/Users/zbeyens/git/slate-v2/site/examples/ts/plaintext.tsx:6.Checklist behavior:
Editable onKeyDown:
/Users/zbeyens/git/slate-v2/site/examples/ts/check-lists.tsx:75.true, which Editable treats as
handled and prevents default:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/editable/keyboard-input-strategy.ts:74./Users/zbeyens/git/slate-v2/site/examples/ts/check-lists.tsx:97.Code highlighting:
useMemo and manually destroys
it in useEffect:
/Users/zbeyens/git/slate-v2/site/examples/ts/code-highlighting.tsx:60.createDecorationSource(editor, options):
/Users/zbeyens/git/slate-v2/packages/slate-react/src/decoration-source.ts:111.code-highlighting.tsx imports type Node as SlateNode only to annotate
match callbacks:
/Users/zbeyens/git/slate-v2/site/examples/ts/code-highlighting.tsx:25.Annotations:
Slate currently accepts annotationStores and composes their projection
stores:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/slate.tsx:62.useSlateAnnotations still requires the store argument:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/hooks/use-slate-annotations.tsx:68.collaborative-comments.tsx passes the same store to <Slate> and then
manually down to CommentList:
/Users/zbeyens/git/slate-v2/site/examples/ts/collaborative-comments.tsx:540.Void renderer props:
renderVoid receives target, but that value is literally a Path:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:456.target: Path:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:496.target: Path and use it as at:
/Users/zbeyens/git/slate-v2/site/examples/ts/images.tsx:119.Lexical:
createEditor does not include history by default; vanilla examples
register history explicitly with registerHistory:
/Users/zbeyens/git/lexical/examples/vanilla-js-iframe/src/main.ts:38.<HistoryPlugin /> as a plugin:
/Users/zbeyens/git/lexical/examples/react-rich/src/App.tsx:159.HistoryExtension explicitly in
dependencies:
/Users/zbeyens/git/lexical/examples/extension-vanilla-tailwind/src/main.ts:83.onKeyDown branches:
/Users/zbeyens/git/lexical/packages/lexical-list/src/checkList.ts:68 and
/Users/zbeyens/git/lexical/packages/lexical-extension/src/TabIndentationExtension.ts:83.ProseMirror:
/Users/zbeyens/git/prosemirror/history/src/history.ts:258.Tiptap:
undoRedo: false:
/Users/zbeyens/git/tiptap/packages/starter-kit/src/starter-kit.ts:95 and
/Users/zbeyens/git/tiptap/packages/starter-kit/src/starter-kit.ts:218./Users/zbeyens/git/tiptap/packages/extensions/src/undo-redo/undo-redo.ts:46.addKeyboardShortcuts, which
the extension manager turns into keymap plugins:
/Users/zbeyens/git/tiptap/packages/core/src/ExtensionManager.ts:110.Conclusion:
Principles:
<Slate>.Path, call it path. If the runtime wants a stable
target, make it a real target object.Decision: do not put history in useSlateEditor by default.
Why:
slate-react does not own slate-history at runtime.Target:
const editor = useSlateEditor({
initialValue,
withEditor: withHistory,
});
For custom behavior:
const editor = useSlateEditor({
initialValue,
withEditor: (editor) => withImages(withHistory(editor)),
});
Execution requirement:
withHistory generic preservation enough that common examples stop
needing as CustomEditor.slate-starter package or example-only helper,
that preset may include history by default and expose an explicit disable
switch. Keep that out of raw useSlateEditor.Rejected:
const editor = useSlateEditor({ initialValue }); // hidden history
That is convenient but wrong for Slate.
Decision: the current onKeyDown move is mechanically safe but not the best DX.
Why it happened:
Editable owns keydown classification, composition, shell/virtualization
repair, and default prevention. Returning true from a user keydown handler
plugs into that runtime path.Why it is not good enough:
onKeyDown every time they
use checklists.Target:
withChecklists composer, not through per-example Editable onKeyDown.Do not resurrect public extension commands. The v2 extension hard cut
explicitly rejects public commands slots. Use capabilities or runtime input
handlers.
Decision: add a React lifecycle hook for decoration sources.
Current code is too much:
const codeHighlightingSource = useMemo(
() => createDecorationSource(editor, options),
[editor],
);
useEffect(
() => () => codeHighlightingSource.destroy(),
[codeHighlightingSource],
);
Target:
const codeHighlightingSource = useSlateDecorationSource(editor, {
id: "code-highlighting",
dirtiness: ["text", "node"],
read: ({ snapshot }) => collectCodeProjections(snapshot.children),
runtimeScope: ({ snapshot }) => collectCodeRuntimeScope(snapshot),
});
Rules:
createDecorationSource as the low-level API.useSlateDecorationSource in slate-react for the common React lifecycle.dirtiness or runtimeScope; they are the performance contract.code-highlighting.tsx by extracting code-block editor behavior and
projection source setup into named helpers.Decision: clean examples aggressively.
Current worst offender:
match: (n: SlateNode) => Node.isElement(n) && n.type === ParagraphType;
Target:
match: (node) => Node.isElement(node) && node.type === ParagraphType;
Execution:
type Node as SlateNode imports.as CustomEditor, as any, and alias casts after composer
typing is fixed. If a cast remains, it needs a local reason..agents/rules/ralph.mdc, then run pnpm install because
rules are source of truth.Rule text to encode:
For TypeScript examples, prefer inference. Do not annotate callback parameters,
alias broad node types, or use `as any` / public type casts unless the compiler
cannot infer the public API shape. Prefer type guards, `satisfies`, and fixed
generic surfaces over local assertions.
Decision: yes, the store should be consumable from Slate context, and the
public provider prop should be singular: annotationStore.
Current shape passes annotationStores into <Slate>, but that is worse DX.
One SlateAnnotationStore already stores many annotations through allIds and
byId; channel/source distinctions should live in annotation data/projection,
not in a plural provider prop.
Target:
<Slate annotationStore={annotationStore} editor={editor}>
<CommentList />
</Slate>
const snapshot = useSlateAnnotations();
API shape:
useSlateAnnotations() uses the nearest annotation store.useSlateAnnotations(store) remains valid for external sidebars,
cross-editor inspectors, and explicit non-context reads.Do not put annotation stores on the core editor. They are React/projection runtime state, not document model state.
renderVoid Prop NameDecision: rename public renderVoid prop from target to path, unless the
value becomes a real target object first.
Current API says:
target: Path;
That is vague. If the value is a Path, the prop should be:
path: Path;
Future option, only if useful:
target: {
path: Path;
runtimeId: RuntimeId;
}
Do not keep target: Path as the stable v2 public API. It is worse for agents
and humans because it sounds abstract while being less informative than
path.
useSlateEditor history-free by default.withHistory and withReact generics.withHistory preserving ValueOf<T> and editor
intersections.Acceptance:
withEditor: withHistory when no custom composer is needed.onKeyDown as baseline proof.withChecklists or a checklist extension.Acceptance:
Editable.useSlateDecorationSource.createDecorationSource exported.Acceptance:
createDecorationSource with cleanup useEffect
unless it is demonstrating the low-level API.annotationStores to
annotationStore.<Slate annotationStore>.useSlateAnnotations(store?) and useSlateAnnotation(id, store?)
support context defaults.Acceptance:
collaborative-comments.tsx does not pass the same store through <Slate>
and component props just to list annotations.RenderVoidProps<T>['target'] to path.Acceptance:
path, not target, when passing at to transforms or
useElementSelected..agents/rules/ralph.mdc with the TypeScript inference rule.pnpm install to sync generated skills/rules.Acceptance:
rg -n "match: \\(n: SlateNode\\)|type Node as SlateNode| as any| as CustomEditor" .tmp/slate-v2/site/examples/ts
only returns justified leftovers..agents/rules/ralph.mdc includes the inference rule.Required focused proof after implementation:
bun --filter slate-react typecheckbun --filter slate-history typecheck/examples/check-lists/examples/code-highlighting/examples/collaborative-comments/examples/images/examples/embeds/examples/mentionsDo not run full browser integration unless one of these focused rows points at runtime selection/input risk.
No. Everyone wants undo until collaboration, read-only, history-forking, or custom batching arrives. Lexical, ProseMirror, and Tiptap all keep the raw engine separate from the history plugin/extension. Slate should too.
Correct. Fix the composer typing and example helper story, not the package boundary.
onKeyDown is simpler."It is simpler for one file and worse as the example people copy. Model behavior belongs to editor extension behavior, with React event hooks as escape hatches.
Only if they hide dirtiness and runtimeScope. The hook should hide
lifecycle cleanup, not the invalidation contract.
Explicit store arguments should remain. The default should use the nearest
Slate context because <Slate annotationStore> is the editor-local projection
channel.
Put them in one SlateAnnotationStore with a kind, channel, or source
field. A store is already a collection. If independent lifecycles are truly
needed, compose them before passing to <Slate>.
target sounds future-proof."Not while typed as Path. Future-proofing with a misleading name is fake
design. Either call it path or make it a real target object.
slate-ralplan: applied. This is a planning/review pass with live source
grounding.intent-boundary-pass: applied. Intent, in-scope, non-goals, and issue
boundaries are explicit.steelman-pass: applied. Maintainer objections recorded.high-risk-deliberate-pass: applied. Public API and runtime behavior
changes are gated by proof.performance / performance-oracle: applied. Decoration invalidation and
runtime store lifecycle keep dirtiness and runtimeScope visible.react-useeffect: applied. The plan removes repeated userland
useMemo/useEffect cleanup ceremony behind a hook.tdd: applied as proof requirements, not implementation.| Dimension | Score | Evidence |
|---|---|---|
| React 19.2 runtime performance | 0.90 | Decoration hook keeps runtime-scope contract; annotation context avoids prop threading without adding per-node state. |
| Slate-close unopinionated DX | 0.94 | History remains explicit; withEditor remains current accepted shape. |
| Plate and slate-yjs migration backbone | 0.88 | No hidden history; annotation stores remain external, collaboration-compatible. |
| Regression-proof testing strategy | 0.90 | Focused tests and browser examples listed by affected behavior. |
| Research evidence completeness | 0.92 | Local Lexical, ProseMirror, Tiptap, and live Slate source cited. |
| shadcn-style composability and minimalism | 0.92 | Hook hides lifecycle, props become literal, examples reduce casts. |
Weighted score: 0.91.
This plan is ready for a later ralph execution pass.
Do not start by implementing all phases at once. First execution should take Phase 1 plus one narrow example cleanup so type evidence is real before touching the rest.
Status: complete.
Changed:
/Users/zbeyens/git/slate-v2/packages/slate-react/test/generic-react-editor-contract.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/check-lists.tsxResult:
useSlateEditor({ withEditor: withHistory }) remains history-free by
default and still preserves the ReactEditor & HistoryEditor intersection.withEditor: withHistory with no
as CustomEditor cast.Evidence:
bunx tsc --project packages/slate-react/test/tsconfig.generic-types.json --noEmitbunx tsc --project packages/slate-history/test/tsconfig.generic-types.json --noEmitbun --filter slate-react typecheckbun --filter slate-history typecheckbun typecheck:sitebun lint:fixNext:
Editable onKeyDown wiring and into a cleaner editor behavior surface.Status: complete.
Changed:
/Users/zbeyens/git/slate-v2/packages/slate/src/internal/index.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/editable/runtime-editor-api.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/editable/editable-input-rules.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/editable/runtime-root-engine.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/index.ts/Users/zbeyens/git/slate-v2/packages/slate-react/test/editable-behavior.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/check-lists.tsx/Users/zbeyens/git/plate-2/docs/slate-v2/references/pr-description.mdResult:
editableInputRules(...) as the Slate React helper for registering
Editable input behavior from editor extension capabilities.Editable combines explicit prop inputRules with editor extension input
rules.checklists extension and no longer
wires checklist Backspace through example-level Editable onKeyDown.Evidence:
bun test ./packages/slate-react/test/editable-behavior.tsxbunx tsc --project packages/slate-react/test/tsconfig.generic-types.json --noEmitbun --filter slate typecheckbun --filter slate-react typecheckbun --filter slate-history typecheckbun typecheck:sitebun lint:fixdev-browser smoke on http://localhost:3100/examples/check-lists: Backspace
at the start of "Slide to the left." changed checkbox count from 6 to 5 and
kept the text visible.Next:
useSlateDecorationSource and migrate decoration examples away
from manual useMemo/useEffect lifecycle glue.Status: complete.
Changed:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/hooks/use-slate-decoration-source.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/index.ts/Users/zbeyens/git/slate-v2/packages/slate-react/test/app-owned-customization.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/code-highlighting.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/search-highlighting.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/markdown-preview.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/highlighted-text.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/external-decoration-sources.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/rendering-strategy-runtime.tsx/Users/zbeyens/git/plate-2/docs/slate-v2/references/pr-description.mdResult:
useSlateDecorationSource(editor, options) for React-owned decoration
source lifecycle.createDecorationSource plus
cleanup useEffect glue.createDecorationSource as the low-level API.dirtiness and runtimeScope visible at call sites.Evidence:
bun test ./packages/slate-react/test/app-owned-customization.tsxbun test ./packages/slate-react/test/editable-behavior.tsxbunx tsc --project packages/slate-react/test/tsconfig.generic-types.json --noEmitbun --filter slate typecheckbun --filter slate-react typecheckbun typecheck:sitebun lint:fixdev-browser smoke on code-highlighting, markdown-preview,
highlighted-text, external-decoration-sources,
rendering-strategy-runtime, and search-highlighting; search query
search produced 3 highlighted segments.Next:
annotationStore and context-default hooks.Status: complete.
Changed:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/slate.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/hooks/use-slate-annotations.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/test/annotation-store-contract.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/collaborative-comments.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/review-comments.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/persistent-annotation-anchors.tsx/Users/zbeyens/git/slate-v2/scripts/benchmarks/browser/react/rerender-breadth.tsx/Users/zbeyens/git/slate-v2/docs/libraries/slate-react/annotations.md/Users/zbeyens/git/slate-v2/docs/libraries/slate-react/editable.md/Users/zbeyens/git/slate-v2/docs/libraries/slate-react/slate.md/Users/zbeyens/git/slate-v2/docs/general/docs-proof-map.md/Users/zbeyens/git/plate-2/docs/slate-v2/references/pr-description.mdResult:
<Slate annotationStores={[store]}> to singular
<Slate annotationStore={store}>.useSlateAnnotations() / useSlateAnnotation(id).Evidence:
bun test ./packages/slate-react/test/annotation-store-contract.tsxbun --filter slate-react typecheckbun typecheck:sitebun lint:fixrg "annotationStores" packages site/examples scripts docs/libraries docs/general -n
returns no source/doc hits.dev-browser cold-load smoke on /examples/collaborative-comments,
/examples/review-comments, and /examples/persistent-annotation-anchors.dev-browser interaction smoke:
/examples/review-comments seeded a comment card,
/examples/persistent-annotation-anchors rendered
comment-anchor:Comment anchor:0:1|0:4, and
/examples/collaborative-comments enabled Add comment after real text
selection and incremented comment writes to 1.Next:
renderVoid target to path and update examples.Status: complete.
Changed:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/hooks/use-element-selected.ts/Users/zbeyens/git/slate-v2/packages/slate-react/test/surface-contract.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/images.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/embeds.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/paste-html.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/mentions.tsx/Users/zbeyens/git/slate-v2/docs/libraries/slate-react/editable.md/Users/zbeyens/git/slate-v2/docs/libraries/slate-react/hooks.md/Users/zbeyens/git/plate-2/docs/slate-v2/references/pr-description.mdResult:
renderVoid now receives { element, path }, not { element, target }.RenderVoidProps['path'] is the only public path field; no target alias is
kept.useElementSelected(path?) names its optional Path parameter literally.path through to transforms and selected-state hooks.Evidence:
bun test ./packages/slate-react/test/surface-contract.tsxbun --filter slate-react typecheckbun typecheck:sitebun lint:fixrg "props\\.target|renderVoid = \\(\\{ element, target|RenderVoidProps.*target|renderVoidProps\\?\\.target|target\\}: RenderVoidProps|target: Path" packages/slate-react site/examples/ts docs/libraries/slate-react -n
returns no hits.dev-browser smoke on /examples/images, /examples/embeds,
/examples/paste-html, and /examples/mentions.Next:
.agents/rules/ralph.mdc.Status: complete.
Changed:
/Users/zbeyens/git/slate-v2/site/examples/ts/code-highlighting.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/huge-document.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/forced-layout.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/images.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/hovering-toolbar.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/editable-voids.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/iframe.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/tables.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/markdown-shortcuts.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/inlines.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/richtext.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/embeds.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/paste-html.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/mentions.tsx/Users/zbeyens/git/slate-v2/.changeset/slate-react-example-dx-cleanup.md/Users/zbeyens/git/plate-2/.agents/rules/ralph.mdc/Users/zbeyens/git/plate-2/.agents/skills/ralph/SKILL.mdResult:
type Node as SlateNode, match: (n: SlateNode), as any, and
as CustomEditor.ralph.mdc and synced generated
skills with pnpm install.slate-react changeset for the public annotation/void API cleanup.Evidence:
rg -n "match: \\(n: SlateNode\\)|type Node as SlateNode| as any| as CustomEditor" site/examples/ts
returns no hits.bun test ./packages/slate-react/test/editable-behavior.tsxbun test ./packages/slate-react/test/app-owned-customization.tsxbun test ./packages/slate-react/test/annotation-store-contract.tsxbun test ./packages/slate-react/test/surface-contract.tsxbun --filter slate-react typecheckbun --filter slate-history typecheckbun typecheck:sitebun lint:fixpnpm installdev-browser cold-load smoke on forced-layout, images,
hovering-toolbar, editable-voids, iframe, tables,
markdown-shortcuts, inlines, richtext, embeds, paste-html,
mentions, code-highlighting, and huge-document.Next:
done.