docs/solutions/logic-errors/2026-04-20-editable-blocks-app-owned-surfaces-must-not-churn-runtime-ids-or-miss-plain-editor-updates.md
EditableBlocks looked like a valid app-owned surface, but the first real
app-owned owner exposed a bad split between the public block surface and the
legacy provider bridge.
Projection-backed markdown previews, block-level shortcuts, forced layout, and app-owned scroll forwarding all depended on stable runtime ids and live selector updates. The current package was quietly breaking both.
app-owned-customization.tsx stayed red even though the projection store
snapshot already contained the expected slicesscrollSelectionIntoView never fired for plain-editor selections because
DOM text nodes could not be resolvedEditableBlocks tests without fixing the provider/plain-editor wakeup.
That only hid the missing update owner.Editable surface.
That just traded one honest red for a fake green and a regression elsewhere.Keep the existing legacy Editable surface intact, but fix the app-owned
bridge seams that were actually broken.
EditableBlocks from reinitializing the editor with
initialValue. That mount-time replace churned runtime ids and immediately
desynced projection-store keys from the mounted tree.Slate provider wake selector subscribers for plain editors by
bridging editor.onChange when the editor is not DOM-enhanced.EditableTextBlocks so projection-backed text nodes
can stay on the direct projected rendering path.useSlateNodeRef creates the plain-editor key-to-element map when it
does not already exist, so DOM range resolution works for app-owned
selections.insert_node and remove_node in
with-dom, so mounted surfaces do not remount unchanged siblings when a
structural edit only shifts paths.Key files:
// packages/slate-react/src/components/editable-text-blocks.tsx
return (
<Slate editor={editor} projectionStore={projectionStore}>
{content}
</Slate>
)
// packages/slate-react/src/components/slate.tsx
if (!EDITOR_TO_KEY_TO_ELEMENT.has(editor)) {
const originalOnChange = editor.onChange
editor.onChange = (options) => {
EDITOR_TO_ON_CHANGE.get(editor)?.(options)
originalOnChange(options)
}
}
// packages/slate-react/src/hooks/use-slate-node-ref.tsx
const keyToElement = EDITOR_TO_KEY_TO_ELEMENT.get(editor) ?? new WeakMap()
if (!EDITOR_TO_KEY_TO_ELEMENT.has(editor)) {
EDITOR_TO_KEY_TO_ELEMENT.set(editor, keyToElement)
}
keyToElement.set(key, node)
// packages/slate-dom/src/plugin/with-dom.ts
case 'insert_node':
case 'remove_node': {
pathRefMatches.push(...getPathRefMatches(e, Path.parent(op.path)))
break
}
The app-owned row was not missing custom renderer logic. It was missing a stable bridge between editor state, selector wakeups, runtime-id keyed projection data, and DOM key maps.
Once the provider stopped replacing the editor on mount, the projection store
and mounted tree finally agreed on runtime ids. Once plain editors started
publishing selector updates, block-level app-owned transforms became visible.
Once plain-editor DOM key maps were created and shifted keys were preserved,
selection-to-DOM resolution became honest enough for scrollSelectionIntoView.
initialValue.createEditor() support as a real public contract. If a surface
accepts a plain editor, selector wakeups and DOM key maps must not rely on
withReact() or withDOM() being present.