docs/plans/2026-05-23-slate-v2-library-owned-multi-root-history-dx-ralplan.md
Status: done - Ralph execution complete
Runtime id: 019e46be-4ec4-7d11-bc6e-9fcf033a8803
Skill: slate-ralplan
Scope: .tmp/slate-v2 multi-root history, shortcut, and root chrome focus DX
Make the multi-root document example show normal Slate app code.
The package should own active-root history targeting, keyboard shortcut parsing, history stack availability, and root chrome focus scheduling. The example should own only document layout, title state, and displayed status.
The user is right. The previous multi-root API work moved the big runtime/view shape into the package, but the canonical example still carries library-shaped plumbing:
site/examples/ts/multi-root-document.tsx:180 defines
getHistoryShortcut, duplicating history hotkey parsing in app code.site/examples/ts/multi-root-document.tsx:199 defines
getHistoryBatchCount, exposing history-stack plumbing in the example.site/examples/ts/multi-root-document.tsx:209 defines updateHistory,
including selection metadata that app authors should not have to know.site/examples/ts/multi-root-document.tsx:256 / :275 wires root chrome
focus through a ref plus requestAnimationFrame.site/examples/ts/multi-root-document.tsx:304 / :320 makes the example
compose active-root editor lookup, history application, and post-command focus
repair through applyDocumentHistory.site/examples/ts/multi-root-document.tsx:325 makes an external title input
parse undo/redo, stop propagation, inspect stack count, and preserve DOM focus
manually.Live package evidence already points to the right owner:
packages/slate-react/src/editable/editing-kernel.ts:983 maps undo/redo
keyboard input to a history command.packages/slate-react/src/editable/mutation-controller.ts:537 applies
history as an editable command.packages/slate-react/src/editable/keyboard-input-strategy.ts:232 states
Slate React must own history hotkeys because browser native history cannot see
Slate's stack.packages/slate-react/src/hooks/use-slate-runtime.tsx:599 exposes
active-root state, and :617 exposes root view editors. The missing layer is
the author-facing React hook that composes these safely.Keep the multi-root runtime/view architecture. Rewrite the public React DX around library-owned hooks.
Do not ship an example that teaches users to write getHistoryShortcut,
getHistoryBatchCount, updateHistory, applyDocumentHistory, or
requestAnimationFrame(() => editor.api.dom.focus()).
Those are not app concerns. They are Slate React concerns.
useSlateHistoryAdd a package hook that owns history state, undo/redo commands, active-root targeting, keyboard shortcut parsing, empty-stack no-ops, and focus policy.
Target shape:
type SlateHistoryFocusPolicy = 'restore-root' | 'preserve-dom' | 'none'
type UseSlateHistoryOptions = {
focus?: SlateHistoryFocusPolicy
root?: RootKey
}
type SlateHistoryController = {
canRedo: boolean
canUndo: boolean
onKeyDown: (event: React.KeyboardEvent) => void
redo: () => void
root: RootKey
undo: () => void
}
Default behavior:
root omitted means "use the current active root".focus defaults to 'restore-root'.focus: 'preserve-dom' is for external inputs like document title fields.onKeyDown reuses the same hotkey semantics as the editing kernel; it must
not copy a second parser.undo / redo no-op when the corresponding stack is empty.Expected example shape:
const history = useSlateHistory()
const titleHistory = useSlateHistory({ focus: 'preserve-dom' })
return (
<>
<input onKeyDown={titleHistory.onKeyDown} />
<button disabled={!history.canUndo} onClick={history.undo}>
Undo document change
</button>
<button disabled={!history.canRedo} onClick={history.redo}>
Redo document change
</button>
</>
)
No app-owned activeRootEditor, getHistoryShortcut,
getHistoryBatchCount, updateHistory, or post-history RAF focus repair.
useSlateRootChromeAdd a package hook for non-editable chrome around an Editable root.
Target shape:
type UseSlateRootChromeOptions = {
disabled?: boolean
focus?: 'end' | 'restore'
}
type SlateRootChromeController = {
props: {
'data-slate-root-chrome': RootKey
onMouseDownCapture: React.MouseEventHandler<HTMLElement>
}
root: RootKey
}
Expected example shape:
const chrome = useSlateRootChrome(root)
return (
<section {...chrome.props}>
<div>{label}</div>
<Editable root={root} />
</section>
)
Behavior:
requestAnimationFrame, DOM query, manual selection import, or
manual focus retry appears in userland;Do not add a product-shaped MultiRootDocument component.
Raw Slate should expose primitives:
<Slate editor><Editable root>useSlateHistoryuseSlateRootChromeuseSlateActiveRootuseSlateRootEditoruseSlateRootStatePlate can build richer product components on top.
Editable and one in
every external input example.requestAnimationFrame should not
be the public API.Add two Slate React hooks: useSlateHistory and useSlateRootChrome.
This is the best shape because it removes the dirty example helpers without inventing an opinionated document shell. It also reuses the runtime/view foundation that already exists instead of exposing more of it to users.
Rejected. This ships bad practice and normalizes copy-paste of internal timing and selection policy.
useSlateActiveEditorRejected as insufficient. It reduces one line but still leaves users to parse shortcuts, read stack counts, choose selection metadata, and schedule focus.
editor.historyRejected for React UI. Core tx.history.undo() already exists. The missing
surface is a React controller that understands active root, selectors, DOM
focus, external inputs, and event prevention.
MultiRootEditor wrapperRejected for raw Slate. This belongs in Plate or a demo app, not core Slate.
state / tx, but let React
hooks choose focus and history metadata rather than making app code pass raw
metadata objects.editor.update((tx) => ...).useSlateHistory under packages/slate-react/src/hooks/.useSlateRootChrome under packages/slate-react/src/hooks/.packages/slate-react/src/index.ts.getHistoryShortcut.canUndo / canRedo through selector subscriptions, not full editor
rerenders.requestAnimationFrame in app code.Delete from site/examples/ts/multi-root-document.tsx:
KeyboardEvent import if only used for history helper typing;useRef if only used for root chrome focus;ReactEditor type import;useSlateActiveRoot and useSlateRootEditor from the example if history is
fully hook-owned;getHistoryShortcut;getHistoryBatchCount;updateHistory;focusRoot;editorSurfaceRef;applyDocumentHistory;requestAnimationFrame.Keep:
onChange;Editable surfaces.Add focused contracts in packages/slate-react/test/:
useSlateHistory returns canUndo=false and canRedo=false for empty
stacks.useSlateHistory updates canUndo / canRedo after state-field and root
content commits.useSlateHistory().undo() targets the active root by default.useSlateHistory({ root: 'header' }).undo() targets the fixed root.useSlateHistory({ focus: 'preserve-dom' }).onKeyDown handles Cmd/Ctrl-Z and
Cmd/Ctrl-Shift-Z from an external input while preserving the input focus and
selection.useSlateHistory().undo() from a toolbar button restores the active root
focus without an app-level RAF.Editable keydown handling.useSlateRootChrome(root) focuses the root when clicking non-interactive
chrome.useSlateRootChrome(root) ignores clicks inside the editable.useSlateRootChrome(root) ignores interactive descendants.Update the multi-root browser route test:
Source cleanliness assertions:
rg -n "getHistoryShortcut|getHistoryBatchCount|updateHistory|applyDocumentHistory|requestAnimationFrame" site/examples/ts/multi-root-document.tsx
Expected result: no matches for library-owned behavior.
Ralph execution should run from .tmp/slate-v2:
bun --filter ./packages/slate-react test:vitest use-slate-history
bun --filter ./packages/slate-react test:vitest use-slate-root-chrome
bun --filter ./packages/slate-react test:vitest slate-runtime-provider-contract
bun --filter ./packages/slate-react typecheck
bun typecheck:site
bun lint:fix
PLAYWRIGHT_RETRIES=0 bun run playwright playwright/integration/examples/multi-root-document.test.ts --project=chromium --workers=1
This plan changes public React hook targets and browser behavior expectations, so the issue pass is closed as an accounting sync.
It adds no fixed issue claims and no improved issue claims. The plan is an API and proof-route target only until Ralph execution lands implementation and browser proof.
Updated reference docs:
docs/slate-issues/gitcrawl-v2-sync-ledger.mddocs/slate-v2/ledgers/issue-coverage-matrix.mddocs/slate-v2/ledgers/fork-issue-dossier.mddocs/slate-v2/references/pr-description.mdReviewed issue set:
| Issue | Decision |
|---|---|
#6016 | Keep triage-closed/non-fix. Shared node-object graphs across independent editor runtimes stay unsupported; this API keeps the one-runtime, many-root answer. |
#5537 | Keep cluster-synced. useSlateRootChrome and root focus proof strengthen the multi-editor focus owner, but exact programmatic multi-editor focus closure is not claimed. |
#5117 | Keep future-proof/example pressure. Root-local chrome/focus must not leak DOM state across views, but placeholder measurement closure is not claimed. |
#5515 | Keep cluster-synced. useSlateHistory is active-root history, not Undo/Redo All. |
#3893 | Keep related. External controls and buttons motivate focus-state proof; exact HTML button focus closure is not claimed. |
#3634, #4961 | Keep related. Root focus APIs strengthen programmatic focus/input ownership; exact focus-after-insert and focus-after-ReactEditor.focus closure still need targeted browser proof. |
#3705, #3756, #3921 | Preserve existing history-selection statuses. useSlateHistory must not broaden partial set_selection or refocus claims. |
#3534, #3551, #4559, #3499 | Preserve existing fixed claims. The new hook must keep those history-selection guarantees but does not add new closure scope. |
#3460 | Treat as API pressure only. Toolbar and command UI outside the editor subtree need stable editor access; this plan solves the raw Slate hook shape, not a legacy issue closure. |
Status: complete.
The hook names survive pressure.
useSlateHistory is better than useSlateUndoRedo because the owned surface
includes stack selectors, keyboard history shortcuts, state-field history,
root-content history, and focus policy.useSlateRootChrome is better than useSlateRootFocus because the hook owns
the root's non-editable surrounding surface, not only the final focus call.focus: 'restore-root' | 'preserve-dom' | 'none' is explicit enough for raw
Slate. It avoids timing vocabulary and maps directly to user-visible
behavior.root?: RootKey on useSlateHistory is the right override. Default
active-root behavior stays ergonomic, while fixed-root toolbars remain
possible.Research evidence is consistent:
editor.update, not copy chain-first product DX.Status: complete.
The required coverage is strong enough for Ralph execution:
useSlateHistory;useSlateRootChrome;Do not rely on model-only selection helpers for this surface. The browser row must assert real focus/native selection and follow-up typing.
Status: complete.
| Objection | Answer |
|---|---|
| "Why not keep this example-local?" | Because users copy examples. Shipping app-owned hotkey parsing, stack reads, selection metadata, and RAF focus repair teaches bad Slate React usage. |
| "Why not editor methods?" | Core already has tx.history.undo() and tx.history.redo(). The missing piece is React UI behavior: active root, selector subscriptions, event prevention, and DOM focus policy. That belongs in hooks. |
| "Why does raw Slate need a chrome hook?" | Multi-root roots have labels, badges, borders, and non-editable surrounding UI. A tiny hook keeps raw Slate unopinionated while preventing every app from hand-rolling brittle focus scheduling. |
| "Does this implement Undo/Redo All?" | No. It intentionally does not broaden #5515. Active-root history is the sane default for one editor with multiple views. |
| "Is this a Plate component?" | No. It is still primitive: one hook for history, one hook for root chrome. Plate can compose richer UI from them. |
| Pass | Status | Notes |
|---|---|---|
| current-state-read | complete | Live example and package internals were re-read. |
| related-issue-discovery | complete | Ledger-first issue pass closed with zero new fixed/improved claims. |
| research-synthesis | complete | Lexical/ProseMirror/Tiptap evidence supports hooks plus runtime-owned focus/history policy. |
| API pressure pass | complete | Keep useSlateHistory and useSlateRootChrome; no product wrapper. |
| test-plan pressure pass | complete | Contract/browser/source-cleanliness proof rows are named. |
| maintainer objection pass | complete | Main objections answered without broadening raw Slate scope. |
| final closure gates | complete | Plan is ready for Ralph execution. |
Final score: 0.93. No dimension is below 0.85.
| Dimension | Score | Evidence |
|---|---|---|
| React 19.2 runtime performance | 0.92 | Hook target uses selector subscriptions, stable callbacks, and package-owned focus scheduling instead of app-level rerender/focus loops. |
| Slate-close unopinionated DX | 0.94 | Two primitive hooks, no product shell, no MultiRootEditor wrapper, and no example-local command plumbing. |
| Plate and slate-yjs migration backbone | 0.90 | Root-aware history/focus target stays aligned with runtime/view/state-field architecture and does not invent product APIs. |
| Regression-proof testing strategy | 0.94 | Contract, browser, and source-cleanliness rows are named, including external-input history and root chrome focus. |
| Research evidence completeness | 0.93 | Lexical/ProseMirror/Tiptap compiled research supports update metadata, view-owned DOM selection, and discoverable UI command hooks. |
| shadcn-style composability | 0.93 | Hook + prop object shape is minimal, composable, and works for toolbar/input/root chrome composition. |
Run Ralph execution. Implement useSlateHistory, useSlateRootChrome, clean the
canonical example, and prove the behavior from .tmp/slate-v2.
ralph reset the runtime completion state to pending.tdd-pass..tmp/slate-v2/packages/slate-react.useSlateHistory hook contract.active goal state.useSlateHistory with stack availability, undo/redo, keyboard shortcut
handling, fixed-root override, external-input focus preservation, and last
editor-root fallback for toolbar clicks.useSlateRootChrome with package-owned root chrome focus, editable and
interactive descendant ignore rules, mounted-root editor focus, and root
selection initialization.<Editable root> view for focus.DOMEditor.focus to trust the real DOM active element before returning
from stale internal focus state.site/examples/ts/multi-root-document.tsx; it no longer owns history
shortcut parsing, stack reads, manual history updates, active-root editor
plumbing, root chrome refs, or app-level RAF focus repair.docs/solutions/ui-bugs/2026-05-23-slate-react-mounted-root-editor-focus-for-root-chrome-history.md.bun --filter slate-react test:vitest -- ./test/use-slate-history.test.tsx ./test/use-slate-root-chrome.test.tsx ./test/slate-runtime-provider-contract.test.tsx ./test/react-editor-contract.tsxbun --filter slate-react typecheckbun --filter slate-dom typecheckbun typecheck:sitebun lint:fixPLAYWRIGHT_RETRIES=0 bun run playwright playwright/integration/examples/multi-root-document.test.ts --project=chromium --workers=1http://localhost:3100/examples/multi-root-document: header/footer chrome focus, toolbar undo/redo refocus, title input Meta+Z focus preservation, no browser error logs.