docs/plans/2026-05-23-slate-v2-root-event-selection-intent-architecture-ralplan.md
Yes, the current multi-root click/focus fix is too fragile.
The bug class keeps moving because useSlateRootChrome is doing five jobs at once:
That is an event engine hidden inside a hook. It works until the next browser sequence crosses a root, padding, stale selection, native editable descendant, or toolbar focus path. The better architecture is not another local patch. The better architecture is one runtime-owned root interaction resolver that turns browser events into typed editor intents before focus or selection is changed.
Intent:
<Slate editor> with many <Editable root> surfaces robust by default.Desired outcome:
<Slate editor={editor}>
<Editable root="header" />
<Editable />
<Editable root="footer" />
</Slate>
const chrome = useSlateRootChrome("header")
return (
<section {...chrome.props}>
<Editable root="header" />
</section>
)
In scope:
slate-react runtime event/selection policy.slate-dom pointer and selection hit testing primitives.site/examples/ts/multi-root-document.tsx teaches the library DX.Non-goals:
MultiRootDocument component in raw Slate.Decision boundary:
useSlateRootChrome.slate-dom or a narrow slate-react adapter around slate-dom.Fragile current code:
.tmp/slate-v2/packages/slate-react/src/hooks/use-slate-root-chrome.ts:19-22 defines selector strings for chrome and native editable targets..tmp/slate-v2/packages/slate-react/src/hooks/use-slate-root-chrome.ts:59-80 classifies event targets locally..tmp/slate-v2/packages/slate-react/src/hooks/use-slate-root-chrome.ts:93-94 tracks pending mouse state with booleans..tmp/slate-v2/packages/slate-react/src/hooks/use-slate-root-chrome.ts:96-191 owns focus, restore, native recovery, and editable-root click placement..tmp/slate-v2/packages/slate-react/src/hooks/use-slate-root-chrome.ts:194-263 branches across mouse down/up capture and calls preventDefault.Better substrate already exists:
.tmp/slate-v2/packages/slate-react/src/editable/runtime-root-engine.ts:231-287 already composes selection reconciliation, selection export/import, repair, trace, and event runtime..tmp/slate-v2/packages/slate-react/src/editable/runtime-root-engine.ts:299-324 already returns central editable event bindings..tmp/slate-v2/packages/slate-react/src/editable/runtime-event-engine.ts:88-164 already defines the editable event runtime shape..tmp/slate-v2/packages/slate-react/src/editable/runtime-event-engine.ts:181-220 already bridges browser handles, target runtime, and beforeinput flow..tmp/slate-v2/packages/slate-dom/src/plugin/dom-editor.ts:692-775 already resolves DOM event coordinates into Slate ranges.Regression pressure:
.tmp/slate-v2/playwright/integration/examples/multi-root-document.test.ts:367-580 now contains several root chrome click regressions: inactive surface clicks, body line-end clicks, body-to-footer first-click, and stale body padding selection.Research pressure:
docs/research/decisions/slate-v2-data-model-first-react-perfect-runtime.md:37-45 keeps Slate data-model-first while making the runtime explicit.docs/research/sources/editor-architecture/prosemirror-transaction-view-dom-runtime.md:73-83 supports one DOM bridge owner.docs/research/sources/editor-architecture/lexical-read-update-extension-runtime.md:108-122 supports lifecycle metadata on edits instead of implicit event timing.docs/research/decisions/slate-v2-react-19-2-perf-architecture-vs-field.md:100-112 says the winning shape is below React, not another React trick.Create one internal root interaction resolver in slate-react, backed by slate-dom hit testing.
Working name:
type RootInteractionIntent =
| { type: "native-editable-text"; root: EditorRootKey }
| { type: "editable-root-coordinate"; root: EditorRootKey; event: MouseEvent }
| { type: "root-chrome-activate"; root: EditorRootKey }
| { type: "interactive-descendant"; root?: EditorRootKey }
| { type: "external" }
The exact names can change during execution. The boundary should not.
Pipeline:
slate-dom when the event has a meaningful text coordinate.The root chrome hook becomes an adapter:
function useSlateRootChrome(root: EditorRootKey) {
return useRootInteractionProps({ root, role: "chrome" })
}
It should not:
forceSelectionThe current hook already is a root interaction engine. It is just untyped, local, untraceable, and hard to test. Moving it into the runtime does not add architecture; it admits the architecture already exists and puts it in the right package boundary.
This matches the accepted Slate v2 direction:
slate-dom owns DOM point/range translation.slate-react owns mounted editable views, browser event lifecycle, focus, and DOM selection import/export.Patch useSlateRootChrome again:
Make app examples restore focus manually:
Expose low-level focus: "preserve-dom" style knobs everywhere:
Let browser native selection always win:
Always restore last root selection:
Trigger:
Blast radius:
slate-react editable runtime, root chrome hook, focus scheduling, mounted root registry.slate-dom event range resolution contract.Pre-mortem:
Verdict:
Strongest objection:
Best argument for not doing it:
Why the chosen option still wins:
Adoption answer:
useSlateRootChrome stays, but becomes a small adapter.Docs/example answer:
Proof required:
Unit contracts:
React hook contracts:
useSlateRootChrome only binds root interaction props.Browser rows:
Stress rows:
Trace assertions:
This matters because Playwright can pass while native caret behavior is still wrong. The assertion should prove ownership, not just text content.
Keep the resolver off React render state.
Rules:
Expected performance:
React should render views and subscribe narrowly. React should not be the source of truth for caret policy.
Relevant guidance applied:
No issue fixed claims in this pass.
Related issue pass: complete.
Ledger updates:
docs/slate-v2/ledgers/issue-coverage-matrix.md records the root interaction intent planning sync.Related clusters classified:
Representative related issue policy:
#4789 and #4984 keep existing fixed claims owned by DOM selection boundary browser proof.#4564, #3723, #5711, and adjacent DOM-point rows stay related or existing improves rows; this plan does not replay exact repros.#3634, #4961, and #5537 stay related focus/input pressure; exact programmatic focus closure needs targeted browser proof after implementation.#3893 stays related external-control focus pressure; exact HTML button closure is not claimed.#5088, #5473, #4376, and #5171 stay related scroll/blur/unfocused-selection pressure.#5603, #5669, #6022, #5983, and #4400 stay related input/mobile/IME pressure; no device closure claim exists.#3705, #3756, and #3921 preserve existing history-selection statuses; root interaction policy must not broaden history claims.Do not update PR fixed issue claims until implementation proof exists.
Overall confidence: 0.83
useSlateRootChrome remains optional and tiny.Planning closure threshold met.
Implementation proof is still required in the later Ralph execution before any source, browser, or issue-fix claim is valid.
None for this Slate Ralplan lane.
Execution owner implements:
useSlateRootChromeDo not build another root chrome patch.
Build the runtime-owned interaction resolver, make root chrome a prop adapter, and prove the repeated bug class with a matrix plus browser rows.
Status: complete.
Implemented:
root-interaction-resolver as the typed policy matrix for root chrome, editable root, native editable, interactive descendant, and external targets.root-interaction-controller as the internal runtime controller that owns pending pointer action state, focus scheduling, event range import, and root selection fallback.useSlateRootChrome to a props adapter around the internal controller.SlateRootEditor typing so root view editors expose the DOM runtime APIs they actually carry.Proof:
bun --filter slate-react test:vitest -- ./test/root-interaction-resolver.test.ts ./test/use-slate-root-chrome.test.tsxbun --filter slate-react typecheckbun lint:fixPLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bun run playwright playwright/integration/examples/multi-root-document.test.ts --project=chromium --workers=1bun --filter slate-react test:vitestIssue claims: