docs/solutions/developer-experience/2026-04-27-slate-react-public-selectors-must-stay-model-truth.md
slate-react needs direct DOM text-sync performance without letting public app
selectors lie about the model. The same runtime also needs live node, text, and
selection reads without scattering slate/internal imports across React
components and event modules.
skipSyncedTextOperations appeared on generic selector options.useNodeSelector / useTextSelector and mounted editor render
subscriptions shared the same stale-data knob.slate/internal live reads appeared in hooks, components, browser
handles, selection reconciliation, DOM repair, Android input, and clipboard
code.skipSyncedTextOperations as a public selector option. That makes a
render optimization look like a truth policy.forceRender() after direct text sync. That hides stale selector
boundaries by waking React instead of fixing ownership.getEditorLive* from slate/internal. That
spreads fallback policy and selection authority across too many files.Split selector truth from mounted render policy.
Public selectors stay model-truth-only:
useNodeSelector(selector, equalityFn, options)
useTextSelector(selector, equalityFn, options)
Internal mounted render subscribers carry the direct-DOM-sync skip policy:
useMountedNodeRenderSelector(selector, equalityFn, options)
useMountedTextRenderSelector(selector, equalityFn, options)
Under the hood, keep one shared selector implementation and make only the update policy internal:
type RuntimeSelectorUpdatePolicy =
| 'model-truth'
| 'skip-synced-text-render'
Then move core live reads behind React-owned runtime facades:
// editable/runtime-live-state.ts
readRuntimeNode(editor, path)
readRuntimeText(editor, path)
readRuntimeNodeById(editor, runtimeId)
readRuntimeTextById(editor, runtimeId)
// editable/runtime-selection-state.ts
readLiveSelection(editor)
readRuntimeSelection(editor)
// editable/runtime-mutation-state.ts
writeRuntimeMarks(editor, marks)
writeRuntimeSelection(editor, selection)
writeTargetRuntime(editor, targetRuntime)
The public barrel exports only the public selector hooks and types. Internal mounted render hooks are source-internal imports used by renderer code.
Selector truth and render invalidation are different contracts.
App code asks selectors for the current model. If a public selector can skip a text commit because the browser already owns the visible DOM text, the selector API is lying to consumers.
Mounted editor text and block renderers have a narrower job: avoid React churn when direct DOM sync already owns visible text. That optimization is valid, but only as an internal render subscription policy.
The live-read facades create one runtime-owned place for fallback order, runtime-id lookup, selection read policy, marks writes, and target-runtime writes. Components and event handlers depend on the React runtime boundary, not core internals.
slate/internal live reads behind runtime facade modules and guard that
with a static test.bun check:full retry as residual flake risk. If the exact row fails
again without retries, keep the lane open.