docs/plans/2026-04-27-slate-v2-selector-and-live-read-runtime-hard-cut-plan.md
Done as of 2026-04-27.
This plan covers the next two cleanup cuts after the completed items 4/5/6 hard-cut lane:
skipSyncedTextOperations from generic public selector options.slate/internal live-read imports behind
slate-react runtime modules.active goal state is done for this lane.
active goal state to pending, refreshed
active goal state, and marked this plan active.active goal state, active goal state,
docs/plans/2026-04-27-slate-v2-selector-and-live-read-runtime-hard-cut-plan.md..tmp/slate-v2, add selector contracts proving public
useTextSelector reports text changes while mounted render subscriptions
can skip directly synced text-only commits.skipSyncedTextOperations from public selector options.slate-react runtime live-state, selection-state, and mutation-state
facade modules.slate/internal imports.bun --filter slate-react test:vitest -- provider-hooks-contractbun --filter slate-react test:vitest -- provider-hooks-contract surface-contractbun --filter slate-react test:vitest -- provider-hooks-contract projections-and-selection-contractbun --filter slate-react test:vitest -- runtime-live-state-contract surface-contractbun --filter slate-react test:vitest -- provider-hooks-contract projections-and-selection-contract selection-controller-contract editing-kernel-contract target-runtime-contract runtime-live-state-contract surface-contractbun --filter slate-react typecheckbun --filter slate-react buildPLAYWRIGHT_BASE_URL=http://localhost:3101 PLAYWRIGHT_RETRIES=0 STRESS_ROUTES=plaintext STRESS_FAMILIES=paste-normalize-undo bunx playwright test playwright/stress/generated-editing.test.ts --project=mobile --reporter=linebun lint:fixbun --filter slate-react test:vitest -- provider-hooks-contract projections-and-selection-contract runtime-live-state-contract surface-contractbun checkPLAYWRIGHT_BASE_URL=http://localhost:3101 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/mentions.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/images.test.ts playwright/integration/examples/search-highlighting.test.ts --reporter=linebun check:fullPLAYWRIGHT_BASE_URL=http://localhost:3101 PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/integration/examples/richtext.test.ts --project=chromium --grep "keeps model and DOM coherent after persistent native word-delete" --reporter=lineuseMountedNodeRenderSelector did not exist.rg "from 'slate/internal'" packages/slate-react/src is limited to the
three runtime facade modules.bun check passes.bun check:full passes. It reported one Chromium richtext word-delete row
as flaky; the exact row passed alone with retries disabled.active goal stateactive goal statedocs/plans/2026-04-27-slate-v2-selector-and-live-read-runtime-hard-cut-plan.md.tmp/slate-v2/packages/slate-react/src/hooks/use-node-selector.tsx.tmp/slate-v2/packages/slate-react/src/components/editable-text.tsx.tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx.tmp/slate-v2/packages/slate-react/src/editable/runtime-live-state.ts.tmp/slate-v2/packages/slate-react/src/editable/runtime-selection-state.ts.tmp/slate-v2/packages/slate-react/src/editable/runtime-mutation-state.ts.tmp/slate-v2/packages/slate-react/src/** callers migrated to runtime
facades..tmp/slate-v2/packages/slate-react/test/provider-hooks-contract.tsx.tmp/slate-v2/packages/slate-react/test/surface-contract.tsx.tmp/slate-v2/packages/slate-react/test/runtime-live-state-contract.ts.tmp/slate-v2/packages/slate-react/test/runtime-live-state-contract.test.tsforceRender() fallback.slate/internal imports in React callers.ce-compound extraction.rg -n "slate-react|selector|live-read|skipSyncedTextOperations|slate/internal|runtime facade" docs/solutions.docs/solutions/developer-experience/2026-04-27-slate-react-public-selectors-must-stay-model-truth.mddocs/solutions/.The last mobile paste/undo fix was correct, but the shape is still too footgunny.
skipSyncedTextOperations currently sits on generic selector options, which
makes it look like a public truth-policy knob. That is wrong. Public selectors
must report model truth. The optimized "do not rerender mounted text after a
directly synced text-only commit" policy belongs to internal render
subscriptions only.
The scattered slate/internal live-read imports are also not the final shape.
They are better than public Editor.getLive*, but they leak core runtime
mechanics across React components, hooks, Android input, browser handles,
selection reconciliation, clipboard handling, and repair queues. The runtime
should expose a small React-owned live-read facade, not ask every hot file to
import core internals directly.
Public app code gets truthful selector APIs and clean DX.
Internal mounted editor render paths get high-performance subscriptions without lying to app selectors.
React components consume a slate-react runtime facade; core live-read imports
stay inside that facade and a few explicitly classified runtime modules.
Editor.getLive*.useNodeSelector / useTextSelector stale.forceRender() to hide selector mistakes.bun check.Current relevant hits:
packages/slate-react/src/hooks/use-node-selector.tsx
SlateRuntimeSelectorOptionsskipSyncedTextOperations?: booleangetEditorLiveNode from slate/internalpackages/slate-react/src/components/editable-text.tsx
skipSyncedTextOperations: truepackages/slate-react/src/components/editable-text-blocks.tsx
skipSyncedTextOperations: truegetEditorLiveNode from slate/internalslate-react/src/** modules import getEditorLiveNode,
getEditorLiveText, or getEditorLiveSelection from slate/internalpackages/slate/src/internal/index.ts re-exports the core live-read helpers
under getEditorLive* namesskipSyncedTextOperations From Generic SelectorsGeneric selectors:
useNodeSelector(selector, equalityFn?, options?)
useTextSelector(selector, equalityFn?, options?)
must always observe model truth. Their options may include runtime id and defer/scheduling policy, but not stale-data policy.
Internal mounted render subscriptions get a separate owner API, for example:
useMountedTextRenderSelector(selector, equalityFn?, options?)
useMountedNodeRenderSelector(selector, equalityFn?, options?)
or a lower-level internal helper:
useRuntimeNodeSelector(selector, equalityFn, {
runtimeId,
updatePolicy: "model-truth" | "skip-synced-text-render",
});
The exact naming can change during implementation, but the boundary cannot:
skipSyncedTextOperations is gone from public exported selector option types.useNodeSelector and useTextSelector stay model-correct for app code.editable-text.tsx and editable-text-blocks.tsx still avoid mounted render
churn after directly synced text-only commits.Add or update slate-react contracts so the desired split is executable:
useTextSelector reports text updates after insert_text /
remove_textLikely test files:
packages/slate-react/test/provider-hooks-contract.tsxpackages/slate-react/test/projections-and-selection-contract.tsxRefactor packages/slate-react/src/hooks/use-node-selector.tsx:
skipSyncedTextOperations from the exported public options typeDo not duplicate selector logic. The split should be policy-level, not a copied hook stack.
Move the two known render optimizations:
components/editable-text.tsxcomponents/editable-text-blocks.tsxto the internal render selector helper.
No app-facing or exported hook should expose the skip policy.
Add a release-discipline or package contract guard that fails on:
skipSyncedTextOperations in exported typesskipSyncedTextOperations usage outside the internal render-selector module
and its mounted render callersRun:
bun --filter slate-react test:vitest -- provider-hooks-contract projections-and-selection-contract
bun --filter slate-react typecheck
bun --filter slate-react build
PLAYWRIGHT_BASE_URL=http://localhost:3101 PLAYWRIGHT_RETRIES=0 STRESS_ROUTES=plaintext STRESS_FAMILIES=paste-normalize-undo bunx playwright test playwright/stress/generated-editing.test.ts --project=mobile --reporter=line
bun lint:fix
bun check
If selector contracts touch public exports, also run the release-discipline guard directly.
slate/internal Live Reads Behind React Runtime ModulesOnly a small number of slate-react runtime modules should import
slate/internal.
Proposed owner modules:
editable/runtime-live-state.ts
editable/runtime-selection-state.ts
editable/runtime-mutation-state.ts
setEditorMarkssetEditorTargetRuntime if they remainEverything else imports from these runtime modules, not from slate/internal.
This is not hiding the core dependency for aesthetics. It creates one place to enforce:
rg "from 'slate/internal'" packages/slate-react/src returns only the
approved runtime facade modules.slate/internal directly.Keep direct core live reads only where the file is itself a runtime owner:
| Area | Current problem | Target |
|---|---|---|
| public hooks | use-node-selector.tsx imports core live node | route through runtime facade |
| mounted components | text blocks import core live node | route through render/runtime facade |
Editable | imports live selection and target runtime setter | route through selection/mutation runtime |
| keyboard/input strategies | import live selection | route through selection runtime |
| browser handle | imports live selection | route through selection runtime |
| Android manager | imports live selection and marks setter | route through selection/mutation runtime |
| selection reconciler | imports live node/text/selection | keep as runtime owner or depend on facade |
| DOM repair queue | imports live text/selection | keep as runtime owner or depend on facade |
| clipboard strategy | imports live node/selection | route through clipboard/runtime facade |
Add a static contract that lists allowed direct slate/internal import files in
packages/slate-react/src.
Initial allowlist should be intentionally tiny:
editable/runtime-live-state.tseditable/runtime-selection-state.tseditable/runtime-mutation-state.tsDuring the first implementation slice, a temporary allowlist can include the current heaviest runtime owners if needed, but the end state should keep direct core internals out of components and hooks.
Create editable/runtime-live-state.ts with wrappers like:
readRuntimeNode(editor, path);
readRuntimeText(editor, path);
readRuntimeSelection(editor);
readRuntimeNodeById(editor, runtimeId);
Rules:
null instead of throwing for absent live state unless the
caller needs strict behaviorStart with callers that only read:
hooks/use-selected.tshooks/use-slate-selection.tsxhooks/use-node-selector.tsxlarge-document/island-shell.tsxcomponents/editable-text-blocks.tsxeditable/model-input-strategy.tsRun focused tests after this slice before touching event-heavy files.
Then migrate:
editable/keyboard-input-strategy.tseditable/browser-handle.tseditable/selection-controller.tseditable/dom-repair-queue.tseditable/clipboard-input-strategy.tseditable/editing-kernel.tshooks/android-input-manager/android-input-manager.tsDo not big-bang these. These files carry selection authority, IME, clipboard, Android, undo, and DOM repair risk.
After migration, decide whether these remain direct runtime owners or consume the facade:
editable/selection-reconciler.tseditable/dom-repair-queue.tsIf they remain owners, document why in the static contract. If they can use the facade without loss, move them too.
Add or extend release-discipline tests:
slate/internal imports outside approved runtime modulesgetEditorLive* imports from components/hooks/input strategiesRun focused package checks first:
bun --filter slate-react test:vitest -- provider-hooks-contract projections-and-selection-contract selection-controller-contract editing-kernel-contract target-runtime-contract
bun --filter slate-react typecheck
bun --filter slate-react build
Then run browser rows that touch the migrated event owners:
PLAYWRIGHT_BASE_URL=http://localhost:3101 PLAYWRIGHT_RETRIES=0 bunx playwright test \
playwright/integration/examples/hovering-toolbar.test.ts \
playwright/integration/examples/mentions.test.ts \
playwright/integration/examples/tables.test.ts \
playwright/integration/examples/images.test.ts \
playwright/integration/examples/search-highlighting.test.ts \
--reporter=line
Then run the generated stress row that caused the last selector/live-read fix:
PLAYWRIGHT_BASE_URL=http://localhost:3101 PLAYWRIGHT_RETRIES=0 STRESS_ROUTES=plaintext STRESS_FAMILIES=paste-normalize-undo bunx playwright test playwright/stress/generated-editing.test.ts --project=mobile --reporter=line
Close with:
bun lint:fix
bun check
Use bun check:full only if this lane is activated as a release-quality
architecture claim.
skipSyncedTextOperations out of public selector options.slate/internal import allowlist contract for item 2.bun lint:fix, and
bun check.active goal state and require bun check:full before done.Stop and replan if:
forceRender() againEditable gains more policy while trying to remove direct live-read importsThis plan is complete only when:
slate/internal imports in slate-react/src are limited to approved
runtime owner modules