docs/plans/2026-04-21-slate-v2-data-model-first-react-perfect-runtime-plan.md
The huge-document perf lane proved the v2 direction can beat legacy chunking on important measured lanes:
But the current implementation also introduced the exact risks chunking mostly avoided:
The next architecture pass should not chase another isolated benchmark win. It should turn the current winning shape into a clean, data-model-first runtime architecture.
Ian's feedback changes the hierarchy. The rewrite must not become React-first.
The correct order is:
slate-reactReact is the first-class runtime target, not the core identity.
The core must stay useful for:
Transactions should change how Slate executes local edits. They should not replace operations as the canonical collaboration/history layer.
The slate package owns:
Core must not know React concepts, DOM nodes, rendered leaves, or browser selection mechanics.
Operations remain the canonical serialized fact.
Transactions become the local runtime boundary:
This gives renderers a clean commit signal without making renderers part of core semantics.
Editor.getSnapshot() should stay useful for:
It should not be the urgent hot read path for:
Hot rendering needs first-class live reads with explicit constraints.
The perfect runtime needs explicit public or internal APIs such as:
Editor.getLiveNode(editor, path)Editor.getLiveText(editor, path)Editor.getLiveSelection(editor)Editor.getRuntimeId(editor, path)Editor.getPathByRuntimeId(editor, runtimeId)Editor.getChangedOperations(editor, since)Editor.getDirtyRegion(editor, transaction)These APIs must be:
Do not hide live reads behind getSnapshot() compatibility.
Every commit should publish a compact change record:
Core should not force observers to infer dirtiness by diffing full snapshots.
slate-dom Owns Browser TranslationThe slate-dom package owns:
It consumes core runtime identity and committed/live reads. It must not own React subscription policy.
slate-react Owns Runtime Subscription And RenderingThe slate-react package owns:
It should consume core dirtiness and slate-dom mapping. It should not invent
core data semantics.
Purpose:
Allowed work:
useSlateSelectorNot allowed:
Purpose:
Used by:
Rules:
Purpose:
Allowed only when all are true:
renderTextrenderLeafrenderSegmentIf any condition fails, fall back to React render.
This must be a named runtime capability, not a DOM-shape accident.
Purpose:
Used by:
Purpose:
Rules:
activeRadius: 0.middleBlockPromoteThenTypeMs, which stops hiding
activation cost inside model-only typing.Direct DOM sync currently relies on DOM shape:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/hooks/use-slate-node-ref.tsxIt needs a capability contract that accounts for custom renderers, projections, composition, and accessibility.
Shell promotion currently mutates editor.selection directly:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsxThat should become either activation-only state or an explicit selection operation.
Shell-backed paste currently intercepts before proving the clipboard lane:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/editable.tsxThat needs fragment-aware and rich-clipboard-safe behavior.
Shells currently expose button semantics with tabIndex={-1}:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/large-document/island-shell.tsxThat is not a valid accessibility contract.
Goal:
Work:
docs/slate-v2/replacement-gates-scoreboard.mddocs/slate-v2/true-slate-rc-proof-ledger.mddocs/slate-v2/release-readiness-decision.mddocs/slate-v2/master-roadmap.mddocs/slate-v2/commands/run-perf-gates.mdProof:
REACT_HUGE_COMPARE_BLOCKS=5000 REACT_HUGE_COMPARE_ITERATIONS=5 REACT_HUGE_COMPARE_TYPE_OPS=10 bun run bench:react:huge-document:legacy-compare:local
1000 blocks is smoke/debug only. It must not be used as a closure or
superiority proof gate for the huge-doc runtime lane.
Goal:
Work:
slate-react, for example:
canUseDOMTextSync(...)syncTextOperationsToDOM(...) consume this predicateFiles:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/hooks/use-slate-node-ref.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/editable-text.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsxTests:
renderTextrenderLeafrenderSegmentProof:
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bun run bench:react:rerender-breadth:local
Goal:
Work:
editor.selection = ... in shell promotionactiveTopLevelIndexactiveRuntimeIdFiles:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/large-document/island-shell.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/large-document/large-document-commands.tsTests:
onSelectionChange unless it intentionally
creates a user-visible selectionProof:
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/editable-behavior.tsx --bail 1
Goal:
Decision:
Recommended direction:
button-equivalent behavior:
tabIndex={0}role="button" or actual <button> if markup allowsaria-labelFiles:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/large-document/island-shell.tsxTests:
Proof:
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
Goal:
Work:
slate-domFiles:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/editable.tsx/Users/zbeyens/git/slate-v2/packages/slate-dom/src/plugin/with-dom.ts/Users/zbeyens/git/slate-v2/packages/slate-dom/src/plugin/dom-editor.tsTests:
Proof:
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-dom/test/clipboard-boundary.ts --bail 1
Goal:
Work:
Files:
/Users/zbeyens/git/slate-v2/packages/slate/src/core/public-state.ts/Users/zbeyens/git/slate-v2/packages/slate/src/interfaces/editor.ts/Users/zbeyens/git/slate-v2/packages/slate/src/utils/runtime-ids.tsTests:
Proof:
bun test ./packages/slate/test/snapshot-contract.ts --bail 1
bun test ./packages/slate/test/transaction-contract.ts --bail 1
bun run bench:core:observation:compare:local
bun run bench:core:huge-document:compare:local
Goal:
Work:
Files:
/Users/zbeyens/git/slate-v2/packages/slate/src/core/public-state.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/projection-store.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/widget-store.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/annotation-store.tsTests:
Proof:
bun run bench:react:rerender-breadth:local
bun run bench:react:huge-document-overlays:local
Goal:
Add browser proof rows for:
renderLeaf fallbackrenderText fallbackFiles:
/Users/zbeyens/git/slate-v2/playwright/integration/examples/**/Users/zbeyens/git/slate-v2/site/examples/ts/large-document-runtime.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/huge-document.tsxProof:
bun run test:integration-local
This architecture is not complete until all are true:
slate-dom declaration aliasing no longer blocks typecheckVerdict: keep course
Harsh take: the old fast path was a DOM-shape accident; now it is an explicit capability, but the shell activation path still mutates selection directly.
Why:
data-slate-dom-sync="true" capability to default plain
text rendering onlysyncTextOperationsToDOM(...) now requires that capability and skips
composition and empty-text casesrenderText, renderLeaf, renderSegment, and
projection-backed rendering opt out of direct DOM syncRisks:
Actions:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/slate-text.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/editable-text.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/hooks/use-slate-node-ref.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/test/large-doc-and-scroll.tsxCommands:
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
9 testsbun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
4 testsbun run bench:react:rerender-breadth:local
/Users/zbeyens/git/slate-v2/tmp/slate-react-rerender-breadth-benchmark.jsonbun run bench:react:huge-document-overlays:local
/Users/zbeyens/git/slate-v2/tmp/slate-react-huge-document-overlays-benchmark.json/Users/zbeyens/git/slate-v2/tmp/slate-react-huge-document-legacy-compare-benchmark.jsonbun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-react
bunx turbo typecheck --filter=./packages/slate-react
packages/slate-dom/dist/index.d.ts aliasing issue:
BaseEditor, Editor, AncestorDecision:
active goal state as status: pendingNext move:
editor.selection = ... mutation and routing intentional caret placement
through an explicit selection operation.Verdict: keep course
Harsh take: shell activation no longer mutates selection silently, but focus activation is still the wrong accessibility contract.
Why:
Transforms.select
instead of direct editor.selection = ...Risks:
Actions:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/large-document/island-shell.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/test/large-doc-and-scroll.tsxCommands:
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
10 testsbun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
4 testsbun run bench:react:rerender-breadth:local
/Users/zbeyens/git/slate-v2/tmp/slate-react-rerender-breadth-benchmark.jsonbun run bench:react:huge-document-overlays:local
/Users/zbeyens/git/slate-v2/tmp/slate-react-huge-document-overlays-benchmark.json/Users/zbeyens/git/slate-v2/tmp/slate-react-huge-document-legacy-compare-benchmark.jsonDecision:
Transforms.select for intentional mouse
activationNext move:
Verdict: keep course
Harsh take: shells are no longer fake buttons; they are keyboard-reachable activators with labels, and focus alone no longer mutates the document.
Why:
role="button", tabIndex={0}, and an aria-labelRisks:
Actions:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/large-document/island-shell.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/test/large-doc-and-scroll.tsxCommands:
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
11 testsbun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
4 testsbun run bench:react:rerender-breadth:local
/Users/zbeyens/git/slate-v2/tmp/slate-react-rerender-breadth-benchmark.jsonbun run bench:react:huge-document-overlays:local
/Users/zbeyens/git/slate-v2/tmp/slate-react-huge-document-overlays-benchmark.json/Users/zbeyens/git/slate-v2/tmp/slate-react-huge-document-legacy-compare-benchmark.jsonbun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-react
bunx turbo typecheck --filter=./packages/slate-react
packages/slate-dom/dist/index.d.ts aliasing issue:
BaseEditor, Editor, AncestorDecision:
active goal state as status: pendingNext move:
Verdict: pivot
Harsh take: the React/browser safety lane is materially better; the next real owner is core live reads and operation dirtiness, not more paste work.
Why:
ReactEditor.insertData(...) instead
of being downgraded to plain textRisks:
slate-dom/dist/index.d.ts aliasing still blocks package
typecheckActions:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/editable.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/slate-text.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/editable-text.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/hooks/use-slate-node-ref.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/large-document/island-shell.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/test/large-doc-and-scroll.tsxCommands:
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
12 testsbun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
4 testsbun run bench:react:rerender-breadth:local
/Users/zbeyens/git/slate-v2/tmp/slate-react-rerender-breadth-benchmark.jsonbun run bench:react:huge-document-overlays:local
/Users/zbeyens/git/slate-v2/tmp/slate-react-huge-document-overlays-benchmark.json/Users/zbeyens/git/slate-v2/tmp/slate-react-huge-document-legacy-compare-benchmark.jsonbun run bench:core:observation:compare:local
readChildrenLengthAfterEachMs: 4.46ms vs 1.20msnodesAtRootAfterEachMs: 11.06ms vs 8.76mspositionsFirstBlockAfterEachMs: 38.70ms vs 1.70ms/Users/zbeyens/git/slate-v2/tmp/slate-core-observation-benchmark.jsonbun run bench:core:huge-document:compare:local
4.04ms vs 0.71ms4.05ms vs 0.54ms3.37ms vs 9.08ms3.39ms vs 8.53ms/Users/zbeyens/git/slate-v2/tmp/slate-core-huge-document-benchmark.jsonbun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-react
bunx turbo typecheck --filter=./packages/slate-react
packages/slate-dom/dist/index.d.ts aliasing:
BaseEditor, Editor, AncestorDecision:
active goal state as status: pendingNext move:
Verdict: pivot
Harsh take: core now has real live-read and operation-dirtiness APIs, and the
snapshot-backed positions cliff is gone; remaining completion blockers are
browser proof and the generated declaration aliasing issue.
Why:
Editor.positions(...) now builds position segments from live editor state
instead of immutable snapshot projectionpositionsFirstBlockAfterEachMs dropped from the old ~38ms class to
4.02msRisks:
slate-dom/dist/index.d.ts aliasing still blocks package
typecheckActions:
/Users/zbeyens/git/slate-v2/packages/slate/src/core/public-state.ts/Users/zbeyens/git/slate-v2/packages/slate/src/create-editor.ts/Users/zbeyens/git/slate-v2/packages/slate/src/interfaces/editor.ts/Users/zbeyens/git/slate-v2/packages/slate/src/editor/positions.ts/Users/zbeyens/git/slate-v2/packages/slate/test/surface-contract.tsCommands:
bun test ./packages/slate/test/surface-contract.ts --bail 1
8 testsbun test ./packages/slate/test/snapshot-contract.ts --bail 1
190 testsbun test ./packages/slate/test/transaction-contract.ts --bail 1
11 testsbun test ./packages/slate/test/query-contract.ts --bail 1
69 testsbun run bench:core:observation:compare:local
positionsFirstBlockAfterEachMs: current 4.02ms, legacy 1.72msreadChildrenLengthAfterEachMs: current 4.23ms, legacy 1.23msnodesAtRootAfterEachMs: current 10.59ms, legacy 8.83ms/Users/zbeyens/git/slate-v2/tmp/slate-core-observation-benchmark.jsonbun run bench:core:huge-document:compare:local
/Users/zbeyens/git/slate-v2/tmp/slate-core-huge-document-benchmark.jsonbun run bench:slate:6038:local
bun run bench:core:normalization:compare:local
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate --filter=./packages/slate-react
bunx turbo typecheck --filter=./packages/slate --filter=./packages/slate-react
packages/slate-dom/dist/index.d.ts aliasing:
BaseEditor, Editor, AncestorDecision:
active goal state as status: pendingNext move:
Verdict: keep course
Harsh take: the three P1 review findings are no longer only defended by jsdom tests, but this still is not full browser-editing closure.
Why:
large-document-runtime example that uses
EditableBlocks large-document mode instead of the legacy chunking examplerenderText and projection-backed text do not
expose data-slate-dom-sync="true" and still type through React/browser
fallbackdata-slate-fragment pastes as
Slate fragment semantics, not plain fallback textRisks:
renderLeaf has unit coverage but not browser coverageslate-dom/dist/index.d.ts aliasing still blocks package
typecheckActions:
/Users/zbeyens/git/slate-v2/site/examples/ts/large-document-runtime.tsx/Users/zbeyens/git/slate-v2/site/constants/examples.ts/Users/zbeyens/git/slate-v2/site/pages/examples/[example].tsx/Users/zbeyens/git/slate-v2/playwright/integration/examples/large-document-runtime.test.ts/Users/zbeyens/git/slate-v2/packages/slate-react/test/large-doc-and-scroll.tsxFailed probes:
3101 server was reused
before the new route existed; restarted the server and reranEditableTextRenderTextProps; fixed the
example to derive the type from public EditableBlocks propsCommands:
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
14 testsbun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
4 testsbunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium
3 testsbun build:next through Playwright web serverbun run bench:react:rerender-breadth:local
/Users/zbeyens/git/slate-v2/tmp/slate-react-rerender-breadth-benchmark.jsonbun run bench:react:huge-document-overlays:local
/Users/zbeyens/git/slate-v2/tmp/slate-react-huge-document-overlays-benchmark.json/Users/zbeyens/git/slate-v2/tmp/slate-react-huge-document-legacy-compare-benchmark.jsonbun run lint:fix
2 filesbun run lint
Decision:
active goal state as status: pendingNext move:
renderLeaf
fallback, and undo/redo after DOM-owned typing; do not call the architecture
lane complete before those user paths are covered.Verdict: pivot
Harsh take: the editing-regression fear is now backed by browser rows instead of good intentions. The remaining owner is not another shell/DOM-sync patch; it is closeout verification and the generated declaration blocker.
Why:
data-slate-dom-sync="true" capabilityrenderText, custom renderLeaf, and projection-backed text omit the
DOM-sync capability and still accept browser typingRisks:
slate-dom/dist/index.d.ts aliasing still blocks package
typecheckActions:
/Users/zbeyens/git/slate-v2/site/examples/ts/large-document-runtime.tsx/Users/zbeyens/git/slate-v2/playwright/integration/examples/large-document-runtime.test.tsFailed probes:
Commands:
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium
5 testsbun build:next through Playwright web serverbun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
14 testsbun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
4 testsbun run lint:fix
1 filebun run lint
Decision:
active goal state as status: pendingNext move:
slate-dom declaration aliasing blocker if it still prevents package
typecheck.Verdict: stop
Harsh take: the architecture lane is complete for the active plan. Keeping the loop alive would be fake motion; remaining release work belongs to broader claim-width / RC ledger closure, not this huge-doc runtime safety lane.
Why:
middleBlockPromoteThenTypeMs against
chunking-on, which remains the explicit first-activation occlusion tradeoffslate-dom/dist/index.d.ts aliasing no longer blocks
slate-react typecheckRisks:
Actions:
/Users/zbeyens/git/slate-v2/packages/slate-dom/src/plugin/with-dom.ts/Users/zbeyens/git/slate-v2/packages/slate-dom/src/utils/range-list.ts/Users/zbeyens/git/plate-2/docs/plans/2026-04-21-slate-v2-data-model-first-react-perfect-runtime-plan.md/Users/zbeyens/git/plate-2/docs/slate-v2/slate-react-perf-loop-context.md/Users/zbeyens/git/plate-2/docs/slate-v2/replacement-gates-scoreboard.md/Users/zbeyens/git/plate-2/docs/slate-v2/release-readiness-decision.md/Users/zbeyens/git/plate-2/docs/slate-v2/true-slate-rc-proof-ledger.md/Users/zbeyens/git/plate-2/docs/research/decisions/slate-v2-data-model-first-react-perfect-runtime.md/Users/zbeyens/git/plate-2/active goal stateCommands:
REACT_HUGE_COMPARE_BLOCKS=5000 REACT_HUGE_COMPARE_ITERATIONS=5 REACT_HUGE_COMPARE_TYPE_OPS=10 bun run bench:react:huge-document:legacy-compare:local
middleBlockPromoteThenTypeMs mean 40.55ms vs
chunking-on 37.29ms, chunk-off 170.78ms/Users/zbeyens/git/slate-v2/tmp/slate-react-huge-document-legacy-compare-benchmark.jsonbunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react
bun test ./packages/slate-dom/test/bridge.ts --bail 1
4 testsbun test ./packages/slate-dom/test/clipboard-boundary.ts --bail 1
6 testsbun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
14 testsbun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
4 testsbun run lint:fix
2 filesbun run lint
bun completion-check
active goal state was set to status: doneDecision:
Next move: