docs/plans/2026-05-06-slate-v2-range-delete-replace-children-ralplan.md
The next iteration should target #5992 exact closure by replacing the remaining large-cut hot path with a scoped child-range replacement operation.
Hard take:
remove_node loop. 3 operations is
better than legacy behavior, but the 50,000-block cut still spends
621.26ms copy-plus-delete and 511.47ms edit-only in the latest issue
benchmark.replace_fragment as the long-term operation name. It is
paste-shaped. The engine primitive is a parent child-range splice.Accepted implementation target:
type ReplaceChildrenOperation<V extends Value = Value> = {
type: "replace_children";
path: Path;
index: number;
children: DescendantIn<V>[];
newChildren: DescendantIn<V>[];
selection: Range | null;
newSelection: Range | null;
};
This is the next implementation substrate for:
path: [] and the range covers all children;replace_fragment was the right proof artifact, but it should not freeze as the
final operation. During implementation, migrate the current semantic uses to
replace_children; keep a temporary internal bridge only if it is needed to
land the change safely, then remove it before release.
Intent:
Improves into a defensible Fixes candidate by removing
the remaining document-size cost from small-range cut/delete.Desired outcome:
In scope:
slate core operation shape.tx.text.delete for exact whole child-range deletes.replace_fragment.Non-goals:
Decision boundaries:
replace_fragment operation with
replace_children if the proof and migration story are stronger.replace_fragment as an internal alias only if source
compatibility pressure beats operation clarity.Unresolved user-decision points:
ralph.Principles:
Top drivers:
replace_fragment.replace_fragment payload shape is too broad for small-range delete in a huge
document because root-level usage carries full children and newChildren
arrays.Viable options:
| Option | Verdict | Why |
|---|---|---|
Keep current remove_node loop | reject | It emits one remove_node per removed child and each op goes through child-array replacement, selection/path transforms, dirty classification, and snapshot/commit work. |
Use root-level replace_fragment for cut/delete | transitional only | It gives one op but stores full old/new child arrays for small edits in huge docs. That is the wrong payload shape for #5992. |
Add delete_fragment | reject | Delete is just replacement with newChildren: []; a delete-specific op duplicates transform/inverse logic. |
Add/generalize to replace_children | choose after hardening | It is Slate-shaped, range-scoped, inverse-friendly, paste-compatible, and closest to ProseMirror's replace-step lesson without copying integer positions. |
| Add a mutable root child splice outside operations | reject | It hides the real change from history/collaboration and recreates snapshot bypass risk. |
Chosen candidate:
replace_fragment proof into a child-range operation:
replace_children.Consequences:
replace_fragment should move to
replace_children before public freeze.Follow-ups:
replace_children directly or lowers it to remove/insert inside one remote
transaction.| Dimension | Score | Evidence |
|---|---|---|
| React 19.2 runtime performance | 0.91 | React is not the #5992 bottleneck. The plan keeps this as a core model operation change and uses browser proof only for regression rows. |
| Slate-close unopinionated DX | 0.94 | Apps still call editor.update; replace_children names the Slate JSON parent-child array change instead of a paste event. |
| Plate and slate-yjs migration backbone | 0.90 | Plate sees no product API. yjs can consume a child splice directly or lower it inside one remote transaction. |
| Regression-proof testing strategy | 0.94 | The proof matrix now covers op apply/inverse, path/point/range refs, history, collab replay, benchmark, and browser cut/undo rows. |
| Research evidence completeness | 0.93 | Gitcrawl, live ledgers, live .tmp/slate-v2 source, and compiled Lexical/ProseMirror/Tiptap research all point at a range operation, not clipboard or virtualization. |
| shadcn-style composability/minimalism | 0.90 | No UI/product API surface. Keep it core-only. |
Total: 0.92.
Verdict: done for planning. replace_children is accepted as the next
execution target; #5992 remains Improves until implementation proof beats the
explicit benchmark and browser gates below.
Current #5992 proof:
.tmp/completion-checks/slate-v2-best-pasting-strategy-ralplan.md records the
latest issue-size run: 50,000-block two-node cut is 621.26ms
copy-plus-delete and 511.47ms edit-only at 3 operations.docs/slate-v2/ledgers/issue-coverage-matrix.md keeps #5992 at Improves,
not Fixes.docs/slate-issues/open-issues-ledger.md row #5992 says exact closure still
needs remaining model delete/snapshot cost below an accepted target.Current copy/fragment extraction:
.tmp/slate-v2/packages/slate/src/interfaces/node.ts:285 defines the whole
top-level child fragment fast path..tmp/slate-v2/packages/slate/src/interfaces/node.ts:466 calls that fast path
before falling back to the old range slicer.Current delete path:
.tmp/slate-v2/packages/slate/src/transforms-text/delete-text.ts:1613 detects
exact whole top-level block ranges..tmp/slate-v2/packages/slate/src/transforms-text/delete-text.ts:1659 deletes
that range by looping from endIndex to startIndex..tmp/slate-v2/packages/slate/src/transforms-text/delete-text.ts:1678 emits a
separate remove_node for every removed top-level child..tmp/slate-v2/packages/slate/src/transforms-text/delete-text.ts:1709 routes
matching ranges into that fast path.Current operation mechanics:
.tmp/slate-v2/packages/slate/src/interfaces/operation.ts:103 defines
replace_fragment with full children and newChildren arrays..tmp/slate-v2/packages/slate/src/interfaces/operation.ts:310 inverts
replace_fragment by swapping those full arrays..tmp/slate-v2/packages/slate/src/interfaces/transforms/general.ts:237 applies
remove_node by replacing the parent child array once per node..tmp/slate-v2/packages/slate/src/interfaces/transforms/general.ts:319
applies replace_fragment by replacing all children at op.path..tmp/slate-v2/packages/slate/src/core/public-state.ts:2013 classifies any
replace_fragment commit as replace.Current benchmark:
.tmp/slate-v2/scripts/benchmarks/core/current/clipboard-large-payload.mjs:475
measures cut as copy plus delete..tmp/slate-v2/scripts/benchmarks/core/current/clipboard-large-payload.mjs:502
measures prepared edit-only cut..tmp/slate-v2/scripts/benchmarks/core/current/clipboard-large-payload.mjs:597
records #5992 as the 50,000-block two-node cut pressure row.Current tests:
.tmp/slate-v2/packages/slate/test/delete-contract.ts:13 locks bounded
operation count for selected top-level block deletion..tmp/slate-v2/packages/slate/test/clipboard-contract.ts:86 locks whole
top-level fragment extraction from a large surrounding document.| System | Source | Mechanism | Avoids | Steal | Reject | Slate target | Verdict |
|---|---|---|---|---|---|---|---|
| Lexical | docs/research/sources/editor-architecture/lexical-read-update-extension-runtime.md | editor.update, dirty leaves/elements, lifecycle tags | global recompute after local edits | dirty runtime buckets and update tags for commit consumers | class nodes and $ helper API | one child-range op plus dirty parent/range metadata | partial |
| ProseMirror | docs/research/sources/editor-architecture/prosemirror-transaction-view-dom-runtime.md | transactions accumulate steps and map selections | post-hoc selection repair and per-node mutation loops | replace-step discipline and mapped selection | integer position model and schema-first identity | replace_children with path/index/range transform semantics | agree |
| Tiptap | docs/research/sources/editor-architecture/tiptap-extension-command-react-dx.md | command/chain sugar over one transaction | fragmented product commands | extension DX stays above transaction engine | command chain as required Slate API | keep editor.update; product sugar can lower to one op | partial |
| Slate v2 current | live source above | replace_fragment proof plus remove_node delete loop | proves one-op replacement can work | reuse proof surface and tests | paste-shaped op name and full-array payload for small range | generalized replace_children | revise |
Strategy:
Lexical-style dirty metadata
+ ProseMirror-style range replacement
+ Slate paths/runtime ids
+ Tiptap-like extension sugar above the engine
No new app-facing command is accepted.
Operation target:
type ReplaceChildrenOperation<V extends Value = Value> = {
type: "replace_children";
path: Path;
index: number;
children: DescendantIn<V>[];
newChildren: DescendantIn<V>[];
selection: Range | null;
newSelection: Range | null;
};
Transaction usage stays:
editor.update((tx) => {
tx.text.delete({ at: selection });
});
Internal lowering:
replace_children with newChildren: [];replace_children with inserted
children;replace_children at path: [],
index: 0.Public docs should not teach users to construct this operation by hand unless
the final operation surface is deliberately public. Normal app code remains
editor.update.
Target flow:
tx.text.delete({ at })
-> detect exact replaceable child window
-> compute parent path + index + removed children + new children
-> apply replace_children once
-> map selection to newSelection
-> classify dirty parent + changed child window
-> history stores one inverse
-> collab can lower one deterministic child splice
The operation should not:
remove_node per deleted child for the #5992 exact whole-child
range;No React API changes.
React impact:
remove_node or replace_children.Plate should see:
editor.update authoring shape;Plate should not need:
Accepted backbone:
replace_children directly when they can
represent a parent child splice.Hard gate:
replace_children is not release-ready until collab replay/lowering has a
focused contract test or the release notes explicitly mark collab lowering as
unsupported for the first slice.ClawSweeper:
docs/slate-issues.Current claim map after the bounded ClawSweeper pass:
| Issue | Cluster | Claim | Why | Proof route | Live ledger sync | PR line |
|---|---|---|---|---|---|---|
| #5992 | large-document-edit-performance | Improves | Current benchmark improved from the old multi-second owner, but 50,000-block cut is still 621.26ms / 511.47ms. | benchmark | open-issues-ledger.md says improves-claimed | keep related matrix until target passes |
| #5945 | large-document-edit-performance | Improves | Paste path is relevant but already handled by the previous plan. | benchmark + browser row | already synced | unchanged |
| #4056 | large-document-edit-performance | Improves | Populated paste/copy is relevant but already handled by the previous plan. | benchmark | already synced | unchanged |
| #6038 | transactionality-and-batch-engine | Improves | Range replacement advances batch-aware core execution but does not prove the issue's broader repeated-update benchmark threshold. | transaction benchmark + new op benchmark | already matrixed | related matrix only |
| #2288 | operation-granularity-and-range-steps | Related / improves after implementation | This is the strongest architecture support: it explicitly asks for range-capable operations because selectAll + delete explodes into many ops. | op contract tests + benchmark | matrixed + dossiered | related matrix only |
| #1770 | collaboration-op-metadata-and-transaction-boundaries | Related | A range op reduces operation overhead, but it does not solve general operation-composition utilities. | collab replay/lowering | matrixed + dossiered | related matrix only |
| #2500 | select-all-delete-and-structural-reset | Related | replace_children gives the right whole-child delete primitive, but exact list-heavy rich-text browser closure is separate. | structural delete/browser row | matrixed + dossiered | related matrix only |
| #2195 | performance-normalization-and-dirty-paths | Related | Dirty-path cost must not move the #5992 win into normalization scans. | dirty path benchmark/assertion | matrixed + dossiered | related matrix only |
| #2405 | performance-normalization-and-dirty-paths | Related | Command-scoped normalization pressure is represented by dirty-window metadata, not fixed by this op alone. | normalization scope proof | matrixed + dossiered | related matrix only |
| #2355 | selection-normalization-and-commit-boundaries | Related | Old selection-normalization pressure becomes newSelection/ref-mapping proof, not a new public normalizer. | selection ref tests | matrixed + dossiered | related matrix only |
| #5811 | normalization-and-custom-schema-conflicts | Related | Range replace must not reintroduce broad normalization loops; exact custom wrap/unwrap loop is not claimed. | normalization regression | ledger already synced | related matrix only |
| #3534 | history-and-undo-selection-state | Related | Undo selection state must be proven for the new inverse; exact historical repro stays separate. | history inverse test | already matrixed | related matrix only |
| #3551 | history-and-undo-selection-state | Related | Move-node undo is not touched, but this op must not weaken history transform invariants. | history/collab tests | already matrixed | related matrix only |
| #3857 | clipboard-structural-cut-delete | Improves | Existing block-void cut proof remains relevant; #5992 closure must not regress selected block cut. | existing clipboard contract + browser row | already matrixed | unchanged |
| #3801 | clipboard-structural-cut-delete | Improves | Existing list-cut proof remains relevant; exact richtext browser closure is still not claimed. | existing clipboard contract + browser row | already matrixed | unchanged |
| #4104 | inline-void-and-void-selection | Related | Inline-void copy/cut is DOM/void selection pressure, not solved by child-range delete. Keep it as a regression row if cut code moves. | inline-void cut proof | matrixed + dossiered | related matrix only |
| #4857 | clipboard-fragment-trust-boundary | Improves | Foreign HTML select-all paste remains clipboard import pressure, not #5992 closure. | existing clipboard boundary proof | already matrixed | unchanged |
| #5089 | clipboard-fragment-insertion-shape | Related | Multi-block paste shape may benefit from replace_children, but exact middle-paragraph paste semantics are separate. | paste shape tests | matrixed + dossiered | related matrix only |
| #5630 | select-all-paste-delete-void-tail | Related | Select-all paste/delete around block voids is delete-range pressure, but exact unhang/void-tail repro needs its own browser proof. | select-all paste/delete browser row | matrixed + dossiered | related matrix only |
PR description:
Fixes #5992 line yet.ralph executes this plan, update the PR description only after the
benchmark/browser proof supports the new operation target.| Behavior | Required proof |
|---|---|
| Deleting one selected top-level block | one operation plus correct selection |
| Deleting two selected top-level blocks in 50,000-block document | accepted latency target, one logical replace, correct children |
| Cutting selected top-level blocks | copied fragment correct, deleted model correct, one history item |
| Delete at document start/end | selection lands at valid neighbor or editor start/end |
| Delete full document | preserves required default document policy or explicit replace behavior |
| Delete nested list range | existing list deletion tests remain green or explicitly fall back |
| Delete inline/void range | existing inline/void behavior remains green |
| Undo/redo | inverse restores removed children and selection |
| Path refs / point refs / range refs | refs inside removed range null; refs after range shift by delta |
| Collaboration replay | local and remote replay converge |
| Dirty paths/runtime ids | parent and affected top-level range invalidate, not whole document unless needed |
Minimum rows before any Fixes #5992 claim:
SLATE_CLIPBOARD_BENCH_HUGE_CUT_BLOCKS=50000 SLATE_CLIPBOARD_BENCH_ISSUE_TARGETS=1 bun ./scripts/benchmarks/slate/5945-large-plaintext-paste.mjs
bun test ./packages/slate/test/delete-contract.ts
bun test ./packages/slate/test/clipboard-contract.ts
bun test ./packages/slate/test/operations-contract.ts
PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/stress/generated-editing.test.ts -g "paste-normalize-undo" --project=chromium
Add a browser cut row if the benchmark moves to Fixes #5992, because the
issue says "cut function", not only pure model delete.
Accepted #5992 threshold:
<150ms p50 on the local benchmark lane.<250ms p50 on the same lane.replace_children structural op plus any explicit
selection op only if the operation contract cannot carry newSelection.| Lens | Applicability | Result |
|---|---|---|
performance | applied | Need benchmark target, cohort, and issue-size #5992 gate. |
performance-oracle | applied through plan requirements | Operation payload size, path transforms, snapshot/index cost, and allocation behavior are explicit gates. |
tdd | applied through execution plan | Implementation starts with a failing op contract and benchmark target before changing delete lowering. |
vercel-react-best-practices | skipped for current pass | No React surface yet; re-enable if commit dirtiness changes React subscriptions. |
build-web-apps:shadcn | skipped | No UI. |
react-useeffect | skipped | No effects. |
Triggered: yes. This changes operation/data-model behavior.
Failure scenarios:
replace_children are wrong, causing
selection refs after the deleted range to drift.Proof plan:
Fixes;Hard cuts:
editor.cutFast or app opt-in;replace_fragment payload for small child-range deletes as the
final answer;remove_node loop for #5992 exact closure;Rejected:
delete_fragment: too narrow;replace_fragment as final name: paste-shaped;| Change | Likely objection | Steelman antithesis | Tradeoff | Answer | Evidence | Rejected alternative | Migration answer | Proof | Verdict |
|---|---|---|---|---|---|---|---|---|---|
Add/generalize replace_children operation | "Slate ops are supposed to be small and primitive." | Many small ops are easier to transform and reason about. | Adds one richer structural op. | The issue is exactly that primitive loops become pathological at scale; ProseMirror-style range replacement is the right primitive for composite child changes. | #5992 benchmark, #2288, and current remove_node loop. | keep remove_node loop | transforms still expose tx.text.delete; app code unchanged | op transform/inverse/history/collab tests | keep |
Replace or demote replace_fragment | "You just added it; why churn?" | Keeping a working op avoids churn. | Rename/generalization touches tests and docs. | The proof was right but the name/payload are too paste-shaped for delete/cut. Better to hard-cut before public freeze. | operation source and #5992 payload concern. | root-level replace_fragment | no app API migration if operation stays internal/advanced | operation surface tests | keep |
Do not call it splice_children | "Splice is the exact array primitive." | Implementation vocabulary can be precise. | replace_children is less JS-array-specific. | Slate operation names describe model actions (insert_node, remove_node, set_node, split_node, merge_node), not raw JS APIs. replace_children is clearer for undo/collab payloads. | existing Slate op naming and tx.value.replace naming. | splice_children | no app API migration | type/API review | keep |
| Pass | Status | Evidence added | Plan delta | Open issues | Next owner |
|---|---|---|---|---|---|
| Current-state read and initial score | complete | live source for Node.fragment, delete loop, operations, benchmark; previous paste completion state | selected #5992 range-replace owner | closed by later passes | related-issue-and-ecosystem-hardening |
| Related issue and ecosystem hardening | complete | gitcrawl threads/neighbors; live ledgers; #2288/#1770/#2500/#2195/#2405/#2355 and cut/paste neighbors | expanded issue map and non-claim decisions | none for planning | closure-score |
| Decision brief pressure pass | complete | maintainer objection rows accepted | accepted replace_children, rejected splice_children and final replace_fragment | none for planning | closure-score |
| High-risk deliberate pass | complete | pre-mortem plus proof matrix | yjs/collab and benchmark gates explicit | none for planning | closure-score |
| Closure score | complete | score 0.92 | plan ready for ralph execution | implementation still unstarted | ralph |
Added:
replace_children, not another clipboard or
virtualization pass.replace_fragment is classified as a proof artifact to migrate away from
before release.Dropped:
remove_node loop.Unchanged:
Improves until exact closure proof exists.Resolved:
replace_children.splice_children, delete_fragment, final
replace_fragment.Fixes threshold: <150ms edit-only and <250ms copy-plus-delete on
the 50,000-block two-node benchmark.editor.update.Decision-changing evidence:
replace_children does not beat current 511.47ms edit-only row by a
meaningful margin, the real owner is snapshot/index cost, not operation
count.replace_children internal
and optimize snapshot/index cost before public operation exposure.Do not execute until this ralplan reaches done.
replace_children operation contract tests;replace_children.replace_fragment uses to replace_children
where it is semantically a child-window replacement.Run from /Users/zbeyens/git/slate-v2 during execution:
bun test ./packages/slate/test/delete-contract.ts
bun test ./packages/slate/test/clipboard-contract.ts
bun test ./packages/slate/test/operations-contract.ts
bun --filter slate typecheck
SLATE_CLIPBOARD_BENCH_HUGE_CUT_BLOCKS=50000 SLATE_CLIPBOARD_BENCH_ISSUE_TARGETS=1 bun ./scripts/benchmarks/slate/5945-large-plaintext-paste.mjs
Add browser proof before Fixes #5992:
PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/stress/generated-editing.test.ts -g "paste-normalize-undo" --project=chromium
When ready, report:
replace_children is accepted as the next implementation target;replace_fragment before -> after;This ralplan is done only when:
>= 0.92, no dimension below 0.85;active goal state points to the accepted next owner.Current status: done for ralplan. Next owner: ralph execution of this plan,
starting with the red op-contract/benchmark proof.
Status: complete for the accepted execution lane.
Implemented:
replace_children operation type, validation, inverse, apply, dirty paths,
runtime-index invalidation, and path/point ref transforms.replace_children.replace_fragment to replace_children for
root/block child-window replacements.replace_children.replace_children.Verification:
bun test ./packages/slate/test/operations-contract.ts ./packages/slate/test/delete-contract.ts ./packages/slate/test/collab-history-runtime-contract.ts ./packages/slate/test/clipboard-contract.ts
bun --filter slate typecheck
bun --filter slate-dom typecheck
bun lint:fix
SLATE_CLIPBOARD_BENCH_HUGE_CUT_BLOCKS=50000 SLATE_CLIPBOARD_BENCH_HUGE_CUT_ITERATIONS=3 SLATE_CLIPBOARD_BENCH_ISSUE_TARGETS=1 SLATE_CLIPBOARD_BENCH_ISSUE_ITERATIONS=1 bun ./scripts/benchmarks/slate/5945-large-plaintext-paste.mjs
STRESS_FAMILIES=huge-document-cut,paste-normalize-undo PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/stress/generated-editing.test.ts -g "huge-document-cut|paste-normalize-undo" --project=chromium
Latest #5992 benchmark:
9.95ms8.62ms1171.91msIssue claim:
Improves, not Fixes, until maintainers accept the 50,000
block benchmark plus 5,000-block browser stress row as exact repro coverage.