docs/plans/2026-05-12-slate-v2-render-path-prop-performance-ralplan.md
Date: 2026-05-12
Status: done
Owner: slate-ralplan
Completion:
active goal state
No, the current render path prop is not the absolute-best architecture.
This plan is ready for execution. The current implementation is not done; the planning decision is done.
The runtime architecture is right: mounted nodes are keyed by stable runtime ids, root-order commits can update root runtime-id lists without notifying every mounted node, and current live reads can resolve a runtime id back to the latest path.
The public render contract is the weak point. RenderElementProps currently
exposes eager path and index, and renderVoid exposes eager path. A Path
is a moving tree address. If a block is inserted before mounted siblings, Slate
has only two choices when eager path is public:
path props / handlers / metadata.Neither is the best Slate v2 shape.
Accepted target: hard-cut eager path and index from public render props.
Keep Slate-close DX through lazy current-path APIs:
ReactEditor.findPath(editor, element) / equivalent must resolve
by runtime id first, not by stale weak-map indexes;useElementPath() only for render-time path-dependent UI;| Field | Decision |
|---|---|
| Intent | Decide whether passing path to renderers is performant and whether it should survive Slate v2. |
| Desired outcome | A later ralph pass can remove the hot public path prop without reopening the whole React runtime architecture. |
| In scope | slate-react render props, void render props, element path context, DOM/path metadata, event-time path resolution, examples that close over path, and React/runtime fanout tests. |
| Non-goals | Editing implementation in this Slate Ralplan pass, broad GitHub rediscovery, virtualization changes, product-specific Plate APIs. |
| Decision boundary | Default render props must not force path-shift rerenders. Apps can opt into current path reads only where they need them. |
| User decision needed | None. This is a hard-cut recommendation before publish. |
| Surface | Current owner | Current shape | Verdict |
|---|---|---|---|
| Public render props | .tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:480 | EditableRenderElementProps includes index: number and path: Path. | Cut eager props. |
| Props construction | .tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:800 | renderElementPropsBase passes index and path into every custom element render. | Cut from base props. |
| Void render props | .tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx:505 | EditableRenderVoidProps includes path: Path. | Cut eager path; use lazy resolver. |
| Runtime-id lookup | .tmp/slate-v2/packages/slate/src/core/public-state.ts:640 | Editor.getPathByRuntimeId(editor, runtimeId) returns the current path from the live runtime index. | Keep as the backbone. |
| Runtime node selector | .tmp/slate-v2/packages/slate-react/src/editable/runtime-live-state.ts:35 | readRuntimeNodeById already resolves current path from runtime id before snapshot fallback. | Reuse. |
| Runtime fanout skip | .tmp/slate-v2/packages/slate-react/src/hooks/use-editor-selector.tsx:218 | root-order commits with null affected ids can skip runtime fanout when selection/full document did not change. | Keep; do not weaken for path props. |
| Existing fanout proof | .tmp/slate-v2/packages/slate-react/test/provider-hooks-contract.tsx:695 | Appending a root node does not notify every mounted runtime node. | Good but not the leading-insert proof. |
| Existing path-shift hook proof | .tmp/slate-v2/packages/slate-react/test/surface-contract.tsx:441 | useElementSelected survives selected path shift, but selection-changing structural edits may still fan out. | Not enough for public path prop. |
| Weak-map path lookup | .tmp/slate-v2/packages/slate-dom/src/plugin/dom-editor.ts:598 | DOMEditor.findPath walks NODE_TO_PARENT / NODE_TO_INDEX. | Must become runtime-id-first to be safe after skipped rerenders. |
| DOM path metadata | .tmp/slate-v2/packages/slate-react/src/hooks/use-slate-node-ref.tsx:198 | node refs set data-slate-path from the provided/current path. | Keep runtime-owned, but do not expose as public render-prop truth. |
Verification run from /Users/zbeyens/git/slate-v2:
bun test ./packages/slate-react/test/provider-hooks-contract.tsx -t "Editable root-order commits do not fan out to every mounted runtime node"
Result: pass.
bun test ./packages/slate-react/test/surface-contract.tsx -t "useElementSelected remains stable when the selected element path shifts after structural edits"
Result: pass.
Principles:
Path is a current address, not stable identity.Top drivers:
path
is a real correctness footgun.Options:
| Option | Pros | Cons | Verdict |
|---|---|---|---|
Keep eager path and re-render all shifted mounted nodes | Always fresh props/context. | Reintroduces sibling-wide render fanout for leading inserts. | Reject. |
Keep eager path and skip shifted-node rerenders | Fast in React. | Stale props, event handlers, context, DOM metadata, and weak maps. | Reject. |
Hard-cut eager path / index; use runtime-id-first lazy resolution | Fast default, correct event-time current path, close to legacy findPath DX. | Breaking API and examples need migration. | Choose. |
Keep eager path only behind compat alias | Easier migration. | Encourages the same footgun before v2 ships. | Reject for v2 publish. |
Chosen option:
Hard-cut eager path and index from RenderElementProps, and hard-cut eager
path from RenderVoidProps. Add lazy current-path APIs only where needed.
Consequences:
renderElement becomes closer to legacy Slate: attributes, children,
element, plus v2-specific isInline / slots if kept.path.useElementPath(), and only those nodes
rerender on path shifts.Target render element props:
type RenderElementProps<TElement extends Element = Element> = {
attributes: RenderElementAttributes;
children: ReactNode;
element: TElement;
isInline: boolean;
slots: EditableElementSlots;
};
Target render void props:
type RenderVoidProps<TElement extends Element = Element> = {
element: TElement;
};
Target path APIs:
const path = ReactEditor.findPath(editor, element);
const path = useElementPath();
Rules:
ReactEditor.findPath(editor, element) is the event-time/default API.useElementPath() is opt-in render-time UI state and may rerender when the
current path changes.index; it is just path.at(-1) with the same invalidation
problem.Implementation target for later ralph:
NODE_TO_RUNTIME_ID weak map populated by the node ref / render binding.DOMEditor.findPath(editor, node) prefer:
Editor.getPathByRuntimeId(editor, runtimeId);NODE_TO_PARENT / NODE_TO_INDEX fallback only when runtime id
is unavailable.NodeRuntimeIdContext as the internal identity context.ElementPathContext public reliance with lazy path resolution.data-slate-runtime-id; data-slate-path remains fallback/debug.Keep:
useElement() for current element access.useElementSelected() but make its no-arg mode resolve from runtime id or
runtime-id-backed findPath, not stale context path.ReactEditor.findPath(editor, element) as the Slate-close path read.Add or revise:
useElementPath(): Path | null for opt-in render-time path display or
path-derived UI.Cut:
RenderElementProps.pathRenderElementProps.indexRenderVoidProps.pathdata-slate-path as if it is app state| System | Source | Mechanism | Avoids | Steal | Reject | Slate target | Verdict |
|---|---|---|---|---|---|---|---|
| Slate legacy | current source-close API shape and v2 DOMEditor.findPath | renderer receives element; path can be resolved on demand | render-prop path invalidation | event-time findPath DX | stale weak-map-only lookup | runtime-id-first findPath | partial |
| Slate v2 live runtime | public-state.ts:640, runtime-live-state.ts:35 | runtime id maps to current path | path as stable identity | id-to-current-path lookup | eager path props | lazy current path resolver | agree |
| React 19.2 external store pattern | use-editor-selector.tsx:68 | selector subscriptions update only when relevant | global rerender fanout | opt-in hook subscriptions | prop-churn as freshness mechanism | useElementPath() only for opt-in UI | agree |
| ProseMirror | compiled PM runtime research | position mapping is transaction-owned, not React render-prop-owned | stale position captures | current-position resolution at command time | making every node view re-render for shifted positions | event-time path resolver | partial |
| Lexical | compiled dirty-runtime research | keyed nodes drive dirty buckets | tree-address fanout | runtime-id keyed dirtiness | exposing tree addresses as primary node identity | runtime id backbone, path as query | agree |
ClawSweeper related-issue pass: skipped for this pass because cached issue ledgers already cover the rerender-breadth and path-stability surface, and this planning pass makes no new fixed issue claim.
Live ledger rows read from docs/slate-issues/gitcrawl-live-open-ledger.md:
#3656 leaf rerender pressure.#4141 nested ancestor rerender pressure.#4210 general rerender prevention.#3748 wrap/unwrap parent rerender pressure.#2051 leaf-level rerender pressure.Manual v2 sync ledger status:
Improves, Related, cluster-synced, or
Not claimed according to current proof.Issue matrix:
| Issue | Cluster | Claim | Why | Proof route | V2 sync ledger | PR line |
|---|---|---|---|---|---|---|
| #3656 | react-runtime-and-rerender-breadth | Improves | Existing breadth proof covers sibling leaves/parent on leaf edit; render path hard cut protects the same class for structural shifts. | add leading-insert render/path contract | unchanged | related matrix only |
| #4141 | react-runtime-and-rerender-breadth | Improves | Existing deep-edit proof covers ancestors; render path hard cut prevents a new ancestor/sibling path-shift fanout. | add leading-insert render/path contract | unchanged | related matrix only |
| #4210 | react-runtime-and-rerender-breadth | Related | This plan advances rerender prevention but does not fully close a broad issue. | benchmark + React contract | unchanged | related matrix only |
| #3748 | react-runtime-and-rerender-breadth | Related | Structural wrap/unwrap rerender pressure is adjacent; this plan covers path-shift fanout, not exact wrap/unwrap repro. | future structural shift contract | unchanged | related matrix only |
| #2051 | singleton-performance-benchmark | Related | Leaf rerender pressure remains represented by benchmark gates; no exact closure. | benchmark lane | unchanged | related matrix only |
PR reference sync:
pr-description unchanged: no fixed issue claim, public PR body, release gate, or accepted API line is changed by this planning-only pass yet.| Contract | Must prove |
|---|---|
| Leading root insert before 1000 mounted blocks | existing shifted siblings do not re-render solely because path/index changed. |
| Leading root insert with selection unaffected | root selector updates order, runtime-node fanout stays bounded, DOM/path lookup resolves current paths. |
| Leading root insert with selection affected | selection proof updates selected surfaces without notifying every unrelated mounted runtime node. |
Event-time findPath after leading insert | handler on shifted sibling resolves the new path, not the stale render path. |
useElementPath() opt-in | only components using the hook rerender when their runtime id's path changes. |
| DOM-to-Slate conversion | runtime-id-first DOM bridge resolves current paths even when data-slate-path is stale or absent. |
| Examples | check-lists, images, embeds, inlines no longer close over render-time path. |
| Browser harness | path selectors either use runtime-id-backed helpers or have a metadata refresh contract. |
| Lens | Applicability | Finding | Plan delta |
|---|---|---|---|
vercel-react-best-practices | applied | Do not use prop churn to synchronize external mutable editor state. Use external-store selectors only where UI needs the value. | Cut eager path/index; add opt-in hook. |
performance-oracle | applied | Leading insert makes eager path freshness O(shifted mounted siblings) if correctness is preserved. | Runtime-id-first resolver; no default sibling fanout. |
performance | applied | This is repeated-unit fanout pressure and must be measured as mounted sibling render count plus selector notification count. | Add 1000/5000 block leading-insert gates. |
tdd | applied | The dangerous behavior is externally visible through event-time handlers and DOM selection, not implementation shape alone. | Add tests before runtime cuts. |
build-web-apps:shadcn | skipped | No UI chrome design surface. | None. |
react-useeffect | applied | Effects should sync DOM/metadata, not app path props. | Keep path metadata runtime-owned. |
Triggered because this changes public render API and browser/path runtime.
Pre-mortem:
path and overuse useElementPath(), recreating broad
path-shift rerenders.findPath misses a mounted node and falls back to stale
weak maps.[data-slate-path] pass in simple cases but fail after
structural shifts.Proof plan:
RenderElementProps or RenderVoidProps
still expose eager path / index;findPath returns the shifted current path after
a skipped-rerender root-order commit;Rollback / hard-cut answer:
The hard cut is worth it before publish because keeping eager path either forces exactly the sibling-wide render fanout v2 is designed to avoid or leaves stale public props. A compat alias would preserve the footgun.
| Change | Likely objection | Steelman antithesis | Tradeoff tension | Answer | Verdict |
|---|---|---|---|---|---|
Remove path from render props | "I need the path to update/delete the current node." | Eager path is convenient and source-close for examples. | Event handlers need one extra resolver call. | Use ReactEditor.findPath(editor, element) inside the handler; make it runtime-id-first so it is current without rerender fanout. | keep |
Remove index from render props | "Index is handy for numbered UI." | Some UI displays sibling index. | Opt-in hook needed for live index display. | index has the same invalidation problem as path; derive from useElementPath() only where live display is intentional. | keep |
Remove path from renderVoid | "Void controls need to mutate themselves." | Void UI often needs remove/update actions. | Same event-time resolver migration. | renderVoid gets element; event handlers resolve current path. | keep |
Runtime-id-first findPath | "Runtime id is v2 machinery leaking into a legacy-named helper." | Weak maps are simpler. | Internal map maintenance required. | Runtime id stays internal; public API remains findPath. Weak maps remain fallback. | keep |
Files:
.tmp/slate-v2/packages/slate-react/test/provider-hooks-contract.tsx.tmp/slate-v2/packages/slate-react/test/rendered-dom-shape-contract.tsx.tmp/slate-v2/packages/slate-dom/test/bridge.ts.tmp/slate-v2/packages/slate-react/test/surface-contract.tsxAdd tests:
DOMEditor.findPath returns current path after root-order shift without
requiring shifted-node React rerender;RenderElementProps.path, RenderElementProps.index,
and RenderVoidProps.path.Files:
.tmp/slate-v2/packages/slate-dom/src/utils/weak-maps.ts.tmp/slate-v2/packages/slate-dom/src/plugin/dom-editor.ts.tmp/slate-v2/packages/slate-react/src/hooks/use-slate-node-ref.tsx.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsxImplement:
DOMEditor.findPath runtime-id-first;Files:
.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.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/test/public-surface-contract.tsImplement:
path / index from EditableRenderElementProps;path from EditableRenderVoidProps;ElementPathContext from public path freshness duties or keep it
internal-only until replaced;useElementPath() if render-time path display needs a public hook.Files to inspect first:
.tmp/slate-v2/site/examples/ts/check-lists.tsx.tmp/slate-v2/site/examples/ts/images.tsx.tmp/slate-v2/site/examples/ts/embeds.tsx.tmp/slate-v2/site/examples/ts/inlines.tsxRenderElementPropsFor<...> custom example types.Migration rule:
path from render props;ReactEditor.findPath.Commands:
cd /Users/zbeyens/git/slate-v2
bun test ./packages/slate-react/test/provider-hooks-contract.tsx
bun test ./packages/slate-react/test/surface-contract.tsx
bun test ./packages/slate-react/test/rendered-dom-shape-contract.tsx
bun test ./packages/slate-dom/test/bridge.ts
bun run bench:react:rerender-breadth:local
Add a focused browser row if any DOM-to-model path changes affect examples:
cd /Users/zbeyens/git/slate-v2
bun playwright test playwright/integration/examples/check-lists.test.ts --project=chromium
Closure reviewed the first pass and changed the status from pending to
done because the remaining gaps are implementation gates owned by ralph, not
missing planning decisions.
The Ralph execution pass is complete. The current v2 implementation no longer
exposes eager render path / index props.
| Dimension | Score | Evidence |
|---|---|---|
| React 19.2 runtime performance | 0.94 | Runtime-id fanout source, existing root-order no-fanout test, explicit hard cut avoids prop-churn freshness as the runtime mechanism. |
| Slate-close unopinionated DX | 0.93 | ReactEditor.findPath(editor, element) preserves Slate-close event-time path reads; optional useElementPath() is narrowly scoped. |
| Plate and slate-yjs migration-backbone shape | 0.92 | Runtime identity remains the shared backbone; no product-layer API is pushed into raw Slate. |
| Regression-proof testing strategy | 0.92 | Replayable red contracts are named by file, scenario, expected render/fanout counters, and DOM/path behavior. |
| Research evidence completeness | 0.91 | Live v2 source plus runtime-identity, React external-store, ProseMirror transaction-position, and Lexical keyed-dirtiness synthesis. |
| shadcn-style composability and hook/component minimalism | 0.95 | Default render props get smaller; path is opt-in state instead of a universal prop. |
Weighted total: 0.93.
Planning status: done.
Implementation status: done.
| Pass | Status | Evidence added | Plan delta | Open issues | Next owner |
|---|---|---|---|---|---|
| Current-state read and initial score | complete | live render prop, selector, runtime id, DOM bridge, and existing tests | hard-cut verdict added | implementation proof still to write | ralplan closure |
| Related issue discovery | complete | cached matrix/dossier/live ledger rows for rerender breadth | no issue claim changes | none | none |
| Decision brief | complete | options and rejected alternatives | chose lazy resolver | none | none |
| Regression proof plan | complete | leading-insert tests named | red tests are execution gates | implementation tests | ralph |
| Closure score | complete | weighted score 0.93 | plan ready for user review and Ralph execution | none for planning | ralph |
| Ralph execution start | complete | active goal state; active goal state | reopened scoped completion state as pending; started red contracts and hard cut | none | ralph |
| Ralph hard cut | complete | RenderElementProps no longer exposes path / index; RenderVoidProps no longer exposes path; DOMEditor.findPath is runtime-id-first; touched examples resolve paths at event time; .tmp/slate-v2/.changeset/slate-react-render-path-props.md and .tmp/slate-v2/.changeset/slate-dom-runtime-id-find-path.md added | public render contract cut, lazy useElementPath() added, docs/reference synced | check-list Backspace browser row still fails independently of this migration | done |
path / index.editor.dom.findPath is runtime-id-first and current after skipped-rerender
structural shifts.path..tmp/slate-v2.ralph gates.done because no further Slate Ralplan decision is
missing and the Ralph execution gates passed.