docs/slate-v2-draft/references/slate-batch-engine.md
Historical/reference doc. The batch-engine retrofit is not a live zero-regression remaining-work lane. For current queue and roadmap truth, see ../master-roadmap.md.
This is the lossless checkpoint for the retrofit batch-engine work in
/Users/zbeyens/git/slate-v2.
Use it for:
Do not use it as the live replacement verdict or the current remaining-work queue.
For that, start with:
Current hard read:
The exact-path set_node rewrite is now in the right architectural shape locally in .tmp/slate-v2:
Editor.withBatch(editor, fn) is the lifecycle boundaryTransforms.applyBatch(editor, ops) is the public batch entry pointeditor.apply(op) stays the only plugin seameditor.children is accessor-backed through packages/slate/src/core/children.tsset_node ops stage into a private draft and materialize immutable snapshots on readTransforms.applyBatch(...) routes through ordinary editor.apply(op) wrappers and still keeps exact-path fast-path performancesetNodesBatch(...) is deletedbatch-safe apply gate and wrapExactSetNodeBatch(...) bridge are deletedpackages/slate/src/core/apply.ts is back to being a thin dispatch layer:
packages/slate/src/core/batching/planner.ts owns batch segmentation rulespackages/slate/src/core/batching/executor.ts owns batched execution and dirty-path strategiespackages/slate/src/core/batching/ is the namespace for batch-only internals, so reviewers do not have to infer which helpers are part of ordinary single-op applyeditor.children reads during a batch now act as a real observation barrier:
editor.apply wrappersThe remaining work is narrower now:
Editor.withBatch(...) still falls back to generic per-op workInsert-family checkpoint:
insert_node batches now use a private insert draft instead of replay-class committed-tree mutationinsert_node + move_node runs now have their own planner segment and cheap dirty-path simulationset_node plus structural tail batches still stay in the same fast-path performance classsplit_node overlay was tested and hard-cut because it made the hot lane worse, not bettersplit_node and merge_node batches no longer leave stale dirty paths behind after normalizemerge_node and split_node dropped out of replay-class latencymove_node dirty-path batching is now in too, which drops the hot flat move lane out of replay-class latency without widening the public API or bypassing editor.applyapplyBatch(...) now has a mixed-batch planner that can keep specialized runs hot inside one outer batch instead of deopting the whole batch immediatelysplit_node / merge_node segment path is gone:
Transforms.applyBatch(...) now stays on the same engine as manual Editor.withBatch(...) for those lanesmove_node now rewrites through one parent clone in interfaces/transforms/general.ts instead of separate remove-then-insert tree rewritesmerge_node batches now have the same kind of private draft tree rewrite that distinct-parent text split_node batches already had:
merge_node no longer batches only dirty paths while still paying per-op tree rewritesTransforms.applyBatch(...) now skips segment planning completely when a batch contains no insert/move families:
set_node + split_node style workloads now go straight through Editor.withBatch(...)split_node and observed split_node + set_selection are valid counterexamplesEditor.withBatch(...) driving the live engine directlymerge_node batches now build dirty-path state directly instead of recomputing editor.getDirtyPaths(op) for every op during flushKeep the current public API and finish the rewrite under it. Do not widen it again.
editor.apply(op) stays single-opEditor.withBatch(editor, fn) stays the explicit transaction boundaryTransforms.applyBatch(editor, ops) stays the public batch entry pointDo not widen public API again. Do not make plugins learn arrays. Do not reintroduce a second plugin seam or a public exact-path helper.
The final engine should look like this:
editor.children exposes immutable snapshots of that draft on readsset_node remains the first optimized op-family sub-pathThis is the real end goal:
Editor.withBatch(...) / Transforms.applyBatch(...)Current local Slate checkpoint in .tmp/slate-v2:
Editor.withBatch(editor, fn) existsTransforms.applyBatch(editor, ops) existssetNodesBatch(...) is deletedset_node batching lives behind applyBatch(...) and Editor.withBatch(...)packages/slate/src/core/apply.tspackages/slate/src/core/children.tspackages/slate/src/core/batching/exact-set-node-children.tseditor.apply wrappers still see each op in orderapplyBatch vs withBatch deltas on text lanes were mostly order/JIT noise without that warmupEditor.withBatch(...)insert_text, remove_text, set_selection, and a mixed text-selection-node batch all match replay semanticseditor.selection, so mixed text-selection-node batches do not freeze the cursor in stale offsetsPath.levels(path) chain, because core text normalization work lives at the ancestor element, not at the text leaf or editor rootinsert_text and remove_text ops still apply through the text batch helper instead of falling straight back to generic Transforms.transform(...)Transforms.mergeNodes(editor, { at }) now fast-path direct previous-sibling merges, which trims normalize-heavy observed text batches without widening the public seammerge_node ops generated during live normalize no longer queue a redundant second observed normalize pass; the skip decision is made before the merge transform runs, while the pre-merge sibling paths still existset_selection affects selectionBefore but is not itself stored in undo operationsset_node now runs through a generated 48-cell family matrixapplyBatch(...) and manual withBatch(...) across 8 declared cellsapplyBatch(...) and manual withBatch(...) across 8 declared manifest cells, plus the explicit merge/new-batch edge caseswithHistory + withReact wrapper stack now runs through a 48-cell replay-oracle matrix with history assertions, instead of pretending the single-wrapper manifests prove compositionpackages/slate-react/test/chunking.spec.ts:
move_node replay equivalence is pinned for both Transforms.applyBatch(...) and manual Editor.withBatch(...)withReact owns chunk-tree movedNodeKeys, which is a real seam separate from historywithDOM and withDOM + withHistory, covering pending selection transforms, pending diff transforms, node-key stability, and both observed-read modesset_node, insert_node, move_node, split_node, merge_node, and a mixed text-selection-node prefix:
applyBatch(...) and manual withBatch(...)editor.apply wrapperspackages/slate/test/utils/batch-matrix-manifest.jspackages/slate/test/batch-matrix-manifest.js now scans the Slate, Slate History, and Slate React test trees for assertBatchMatrixManifest(...) calls and fails if the registry and helper usage driftpackages/slate/test/perf/set-nodes-bench.js now exports its benchmark registry and a required-lane list instead of hiding them behind CLI-only statepackages/slate/test/perf-benchmark-manifest.js now fails if a required perf lane disappears or if the benchmark prototype equivalence check stops matching replayeditor.children = ... inside Editor.withBatch(...) is now treated as a hard batch reset:
editor.operations are droppedwith-batch-direct-assignment.js now declares an explicit 16-cell direct-assignment matrix plus a 4-cell persisted-ref observation subsetwithDOM and withDOM + withHistoryThe hard exact-path design is implemented. The remaining gap is now clear:
set_node tree opsset_node has an optimized op-family executoreditor.children writes during tree transforms run under withInternalBatchWrites(...)ops array in place is fineSlate node references are treated as immutable once published.
We already have repo evidence that shared node graphs blow up Slate assumptions:
If code:
editor.childrenthe old reference must stay unchanged.
Any plan that mutates published node objects in place is wrong.
The whole point of this rewrite is twofold:
set_node in the old setNodesBatch performance classCurrent measured proof from the existing micro-benchmark at 5,000 blocks:
Transforms.applyBatch([...set_node]): 5.84 msTransforms.applyBatch([...set_node]): 4.87 msTransforms.applyBatch([...set_node, insert_node]): 6.49 msTransforms.applyBatch([...set_node, ...move_node]): 123.63 msTransforms.applyBatch([...insert_node]): 13.22 msTransforms.applyBatch([...prepend insert_node]): 5.5 msTransforms.applyBatch([...interleaved insert_node, move_node]): 77.33 mseditor.apply(insert_node) loop inside Editor.withoutNormalizing(...): 2551.51 mseditor.apply(prepend insert_node) loop inside Editor.withoutNormalizing(...): 2617.48 mseditor.apply([...interleaved insert_node, move_node]) loop inside Editor.withoutNormalizing(...): 8898.41 mseditor.apply(set_node) loop inside Editor.withoutNormalizing(...): 62.81 msTransforms.setNodes(...) loop inside Editor.withoutNormalizing(...): 66.24 msObservation-heavy lanes are now measured too, not guessed:
Transforms.applyBatch([...set_node]): 9.1 msEditor.withBatch(...) exact set_node loop: 5.95 msTransforms.applyBatch([...set_node]) with read-after-each observation: 32.1 msEditor.withBatch(...) exact set_node loop with read-after-each observation: 19.05 msTransforms.applyBatch([...set_node, ...move_node]): 166.53 msEditor.withBatch(...) exact set_node + move_node loop: 179.29 msTransforms.applyBatch([...set_node, ...move_node]) with read-after-each observation: 187.94 msEditor.withBatch(...) exact set_node + move_node loop with read-after-each observation: 178.92 msTransforms.applyBatch([...interleaved insert_node, move_node]): 79.26 msEditor.withBatch(...) interleaved insert_node + move_node loop: 79.08 msTransforms.applyBatch([...insert_text]): 85.08 msTransforms.applyBatch([...insert_text]) with read-after-each observation: 82.06 msTransforms.applyBatch([...set_node]) with duplicate target paths: 8.54 msEditor.withBatch(...) duplicate exact set_node loop: 7.12 msTransforms.applyBatch([...set_node]) with wrapper read-after-each observation: 16.97 msEditor.withBatch(...) exact set_node loop with wrapper read-after-each observation: 20.96 msThat split matters:
Transforms.applyBatch(...) and manual Editor.withBatch(...) still stay in the same performance class on both the hot lane and the observation-heavy laneset_node + move_node lane now stays in the same performance class for both entrypoints too; live move dirty-path batching closed the old manual-withBatch replay cliff without deferring tree mutationinsert_node + move_node lane now stays in the same class for both entrypoints too; promoting the insert prefix into one live insert+move dirty-path batch closed the old multi-second manual withBatch cliffinsert_text hot lane is fixed, and the observed lane is back in the same class instead of blowing up under wrapper readsset_node / insert_node / move_node / remove_node workmove_node flushes now remap indexes directly instead of rebuilding a full sibling-order array on every readsplit_node and merge_node segments no longer replay-transform every carried dirty path through every op during applyBatch(...)Transforms.applyBatch([...set_node, ...merge_node]): 1083.61 ms -> 186.9 msTransforms.applyBatch([...set_node, ...split_node]): 1284.46 ms -> 257.59 msTransforms.applyBatch([...merge_node]): 113.2 msTransforms.applyBatch([...split_node]): 177.06 msEditor.withBatch(...) split loops now stay in the same class too once live split ops stage their dirty paths instead of paying the generic per-op tail:
Editor.withBatch([...split_node]): 171.12 msEditor.withBatch([...set_node, ...split_node]): 150.81 ms[0] has parent path [][1] never remaps to [2], and later normalize misses replay-equivalent text mergesset_node + merge_node is a valid batch-only workload but not a plain replay-oracle case:
set_node, which can collapse adjacent text children before the later merge_nodeTransforms.applyBatch(...) equivalence with manual Editor.withBatch(...), not equivalence with plain unbatched replaymerge_node ops in undo historymerge_node is now a real draft family, not just a dirty-path batching trick:
children.ts stages those merges into a private draft exactly the way direct text splits already workedmergeNodeDirtyPaths case under read-after-each and persisted-ref observationsplit_node / merge_node batching matured, the planner-owned independent-parent segment path became a net loss:
Editor.withBatch(...) drive the live engineTransforms.applyBatch(...) back into the same class as manual batching5,000-block checkpoint after the hard cut:
Transforms.applyBatch([...merge_node]): 70.4 msEditor.withBatch([...merge_node]): 70.68 msTransforms.applyBatch([...split_node]): 145.36 msEditor.withBatch([...split_node]): 158.33 msTransforms.applyBatch([...set_node, ...merge_node]): 83.92 msEditor.withBatch([...set_node, ...merge_node]): 73.35 ms5,000-block checkpoint after the direct-text merge draft:
Transforms.applyBatch([...merge_node]): 12.74 msEditor.withBatch([...merge_node]): 13.25 msTransforms.applyBatch([...set_node, ...merge_node]): 24.75 msEditor.withBatch([...set_node, ...merge_node]): 23.15 msTransforms.applyBatch([...move_node, ...merge_node]): 44.78 msEditor.withBatch([...move_node, ...merge_node]): 44.56 msTransforms.applyBatch([...set_node, ...move_node, ...merge_node]): 48.51 msEditor.withBatch([...set_node, ...move_node, ...merge_node]): 46.1 msTransforms.applyBatch([...split_node]): 36.92 msEditor.withBatch([...split_node]): 38.41 msThat changes the remaining target again:
Transforms.applyBatch([...insert_text]): 31.66 msEditor.withBatch([...insert_text]): 29.7 msTransforms.applyBatch([...insert_text]) with read-after-each observation: 44.41 msEditor.withBatch([...insert_text]) with read-after-each observation: 48.15 msapplyBatch(...) sugar overheadTransforms.applyBatch([...split_node]): 35.2 msEditor.withBatch([...split_node]): 40.71 msTransforms.applyBatch([...set_node, ...split_node]): 40.38 msEditor.withBatch([...set_node, ...split_node]): 38.79 msTransforms.applyBatch([...move_node, ...split_node]): 65.14 msEditor.withBatch([...move_node, ...split_node]): 61.87 msTransforms.applyBatch([...set_node, ...move_node, ...split_node]): 65.08 msEditor.withBatch([...set_node, ...move_node, ...split_node]): 69.01 msapplyBatch(...) path; the remaining cost is the live transform itself5,000 blockscore/batch.ts, not guessed from helper timings:
Transforms.applyBatch([...split_node]): 40.82 msEditor.withBatch([...split_node]): 40.62 msrefs: 0.21 msstageDraft: 0.33 msstageDirtyPaths: 0.09 msfinalize: 0.51 msmaterialize: 1.84 msdirtyPathFlush: 2.6 msflushBeforeNormalize: 2.46 msnormalize: 24.91 msThat changes the optimization bar:
applyBatch(...) vs withBatch(...) lanesTransforms.applyBatch([...set_node, ...split_node]): 165.49 msEditor.withBatch([...set_node, ...split_node]): 141.54 msmove_node transform itself, not another batch seam:
5,000-block checkpoint after that move rewrite:
Transforms.applyBatch([...move_node]): 28.84 msTransforms.applyBatch([...set_node, ...move_node]): 48.28 msTransforms.applyBatch([...move_node, ...merge_node]): 83.3 msEditor.withBatch([...move_node, ...merge_node]): 86.21 msTransforms.applyBatch([...move_node, ...split_node]): 174.9 msEditor.withBatch([...move_node, ...split_node]): 152.66 ms5,000-block checkpoint after reverting the bad blanket simulation cut and tightening live independent-parent merge batching:
Transforms.applyBatch([...merge_node]): 71.37 msEditor.withBatch([...merge_node]): 67.62 msTransforms.applyBatch([...split_node]): 129.1 msEditor.withBatch([...split_node]): 131.07 msTransforms.applyBatch([...set_node, ...split_node]): 139.11 msEditor.withBatch([...set_node, ...split_node]): 128.67 msTransforms.applyBatch([...interleaved insert_node, move_node]): 53.85 msEditor.withBatch([...interleaved insert_node, move_node]): 70.68 msset_node + split_node was planner overhead on a batch the planner could not specializeTransforms.applyBatch(...) back into the same class as manual batching5,000-block checkpoint after that cut:
Transforms.applyBatch([...set_node, ...split_node]): 143.28 msEditor.withBatch([...set_node, ...split_node]): 145.67 msTransforms.applyBatch([...move_node, ...split_node]): 172.75 msEditor.withBatch([...move_node, ...split_node]): 176.65 msTransforms.applyBatch([...split_node]): 126.54 msEditor.withBatch([...split_node]): 120 ms5,000-block checkpoint after that cut:
Transforms.applyBatch([...split_node]): 114.68 msEditor.withBatch([...split_node]): 108.06 msTransforms.applyBatch([...set_node, ...split_node]): 126.19 msEditor.withBatch([...set_node, ...split_node]): 112.99 msTransforms.applyBatch([...move_node, ...split_node]): 158.83 msEditor.withBatch([...move_node, ...split_node]): 140.92 msTransforms.applyBatch([...set_node, ...move_node, ...merge_node]): 120 msEditor.withBatch([...set_node, ...move_node, ...merge_node]): 117.53 msTransforms.applyBatch([...set_node, ...move_node, ...split_node]): 153.22 msEditor.withBatch([...set_node, ...move_node, ...split_node]): 149.09 msapplyBatch and manual withBatchreadAfterEach, persistRefplain, rewriteEditor.withoutNormalizing(...): if normalization is suspended for the current transform, editor.children reads must return the current draft without trying to force observed normalization, or batched transforms like back-to-back Transforms.setNodes(...) spin forever on queued dirty paths before the second op even reaches editor.applyTargeted direct instrumentation on the merged-text observation lane made the remaining text cost less mysterious:
Transforms.applyBatch([...insert_text]) with read-after-each observation spent about 3234.12 ms total, including 3169.85 ms inside editor.normalize2510.37 ms total, including 2387.83 ms inside editor.normalizeThat matters because the cost is still normalize-heavy, but the leaf and root dirty paths were dead work. Cutting them buys a real win without widening the engine or lying about semantics.
Current merged-text checkpoint on the same 5,000-block harness:
Transforms.applyBatch([...insert_text]): 85.08 msTransforms.applyBatch([...insert_text]) with read-after-each observation: 82.06 msDo not overreact to short perf samples on that lane:
repeats=3 wandered enough to tell conflicting storiesrepeats=5 is the minimum sane sample before treating merged-text numbers as realThe key fix there was not another text overlay. It was deleting the fake second pass:
insert_text already normalizes its parent paragraph in the first passmerge_nodemerge_node requeues live merge dirty paths, the wrapper read after the top-level text op forces a second observed normalize for no semantic gainThe exact-path one-seam design is still in the old setNodesBatch performance class. The widened insert draft is worth keeping too: both append and prepend-heavy empty-document build-up stay in the low-hundreds of milliseconds instead of multiple seconds, without widening the public API. The mixed-batch planner is worth keeping as well, and the same-parent move carry-over specialization finally proves it: set_node plus move_node is no longer replay-class once the engine stops re-transforming dense carried dirty paths the dumb way.
The next real split win also turned out to be dirty-path bookkeeping, not tree rewriting:
editor.getDirtyPaths(op) semanticssplitNodeThenSetSelectioneditor.getDirtyPaths(op)5,000-block split lane:
Transforms.applyBatch([...split_node]): 110.96 ms -> 91.12 msEditor.withBatch([...split_node]): 176.32 ms -> 100 msTransforms.applyBatch([...set_node, ...split_node]): 129.82 ms -> 109.93 msEditor.withBatch([...set_node, ...split_node]): 125.07 ms -> 98.43 msMerge still had one last cheap cut too:
merge_node ops do not need dirty-path staging just because they happen outside observed normalizeTransforms.applyBatch([...merge_node]): 82.83 ms -> 76.19 msEditor.withBatch([...merge_node]): 79.17 ms -> 64.78 msTransforms.applyBatch([...insert_text]) with read-after-each observation: 101.82 ms -> 84.41 msThe next hotspot was not a tree helper at all. It was planner overreach on triple mixed lanes:
Transforms.applyBatch([...set_node, ...move_node, ...merge_node])Transforms.applyBatch([...set_node, ...move_node, ...split_node])Those shapes were getting segmented into generic-before, move-middle, generic-after runs even though manual Editor.withBatch(...) was already faster on the full batch.
The fix is deliberately boring:
generic, move, or same-parent-moveVerified on the 5,000-block harness with repeats=5:
Transforms.applyBatch([...set_node, ...move_node, ...merge_node]): 338.31 ms -> 99.75 msEditor.withBatch([...set_node, ...move_node, ...merge_node]): 203.87 ms -> 89.69 msTransforms.applyBatch([...set_node, ...move_node, ...split_node]): 199.49 ms -> 117.62 msEditor.withBatch([...set_node, ...move_node, ...split_node]): 140.68 ms -> 110.28 msThe remaining insert tax turned out to be self-inflicted bookkeeping, not tree rewriting:
canStageInsertNodeOperation(...) cloned the entire staged insert op list on every insertO(n²) checkThe next mixed-op cliff was different:
insert_node + move_node did not need another draft layerThe winning cut was to give that mixed pattern its own planner segment and a cheap same-parent dirty-path simulation:
That dropped the empty-doc interleaved lane from multi-second garbage into double-digit milliseconds without changing the public seam or bypassing editor.apply wrappers.
Keep the accessor-backed editor.children and private batch draft model. Generalize it instead of redesigning it again.
Strong take:
editor.children should remain accessor-backed, not a raw mutable fieldapply should write to a private draft root, not the committed snapshoteditor.children during a batch should normalize the live draft in place and return replay-equivalent structureeditor.apply wrappersThis is the cleanest design that satisfies all of the real constraints at once:
editor.apply(op)applyset_node fast path already provenThat means the optimizer strategy should be explicit:
editor.childreneditor.children stops being the canonical mutable store.
Instead:
editor.children getter returns current public tree stateeditor.children should deopt or reset draft state explicitly, not silently corrupt itThis is a getter, yes. The mistake would be making every internal engine read look like a public observation. The batch engine needs a real distinction between internal reads and public observation.
The final draft model has two layers:
set_node is the first oneThe draft should track:
When code reads editor.children during a batch:
If another op happens later:
If the batch hits an op family that is not optimized yet:
setNodesBatch(...)editor.apply(ops[])Immer-style batching is fine as a prototype if needed, but the production target should be a bespoke exact-path draft overlay with predictable semantics.
Current exact-path batching rejects duplicate paths in one batch.
That is not acceptable for the long-term applyBatch(...) contract.
Transforms.applyBatch(editor, ops) must match sequential editor.apply(op) semantics.
So:
editor.operationsThe optimizer can fold duplicate writes internally. The public semantics cannot reject them.
The old markBatchSafeApply(...) / isBatchSafeApply(...) gate and wrapExactSetNodeBatch(...) seam are deleted locally.
That is the right end state for exact-path set_node:
editor.applyStatus: done
Goal:
Implementation units:
packages/slate/test/with-batch.jspackages/slate/test/apply-batch-exact-set-node.jspackages/slate-history/test/apply-batch-exact-set-node.jspackages/slate-react/test/apply-batch.spec.tsxpackages/slate/test/perf/set-nodes-bench.jsTests to add first:
editor.apply wrapper can rewrite "blue" -> "orange" inside Editor.withBatch(...) and Transforms.applyBatch(...)apply(op) and then read editor.childrenBenchmark lanes to freeze:
Transforms.applyBatch([...exact set_node])Editor.withBatch(...) manual editor.apply(set_node) loopset_node engine rewriteStatus: done
Goal:
Delivered:
editor.childrenset_node draft overlayTransforms.applyBatch(...) exact-path fast pathsetNodesBatch(...) removalStatus: done
Goal:
Delivered:
set_node tree ops write to private draft children during a batchset_node plus structural batches promote cleanly into the generic draft rootStatus: in progress
Goal:
Rules:
editor.apply(op)Current order:
set_node — doneinsert_node family — done for monotonic same-parent batchesremove_node, move_node, merge_node, and split_node before choosing the next optimizerinsert_node only if the measured workloads justify more than the current monotonic same-parent cutCurrent measured priority at 5,000 ops:
insert_node on empty-document build-up — still the slowest remaining specialized workload
2757.86 ms281.86 msTransforms.applyBatch([...set_node, ...split_node]): 165.49 msEditor.withBatch([...set_node, ...split_node]): 141.54 msmove_node — fixed enough that it is no longer the urgent fire
1692.25 ms126.46 msmerge_node — in the same “worth measuring, not panicking” bucket
2820.19 ms77.89 mssplit_node — no longer a blocker on either entrypoint
Transforms.applyBatch([...split_node]): 184.3 msEditor.withBatch([...split_node]): 171.12 msremove_node — still not worth a dedicated optimizer
8.79 ms8.09 msStrong take:
withBatch split cliff without another public seaminsert_node planning and cross-segment mixed structural work are better candidates than pure splitsplit_node and merge_node are now second-pass optimization candidates, not blockersremove_node does not earn special treatment yetStatus: pending
Goal:
Required coverage:
editor.children = ... assignment inside a batch must have explicit and tested semanticsStatus: pending
Goal:
Required lanes:
set_nodeset_node plus structural tail opsinsert_nodeExit criteria:
Status: done
Goal:
editor.children observable without changing public API shapeImplementation units:
packages/slate/src/create-editor.tspackages/slate/src/interfaces/editor.tspackages/slate/src/utils/weak-maps.tspackages/slate/src/core/children.tsDesign:
children as an enumerable accessor on the editor instanceCoverage:
editor.children = [...] still worksExit criteria:
Status: done
Goal:
apply own the fast path directlyImplementation units:
packages/slate/src/core/apply.tspackages/slate/src/core/batch.tspackages/slate/src/utils/weak-maps.tspackages/slate/src/core/children.tspackages/slate/src/core/batching/exact-set-node-children.tsDesign:
set_node ops:
editor.operationschildrenThis phase should make both paths use the same draft engine:
Transforms.applyBatch(editor, ops)Editor.withBatch(editor, () => editor.apply(op))Coverage:
Transforms.applyBatch(...) and manual Editor.withBatch(...) loops share exact semanticsPerf target:
5,000 blocksExit criteria:
editor.children readsStatus: done for exact-path set_node
Goal:
Implementation units:
packages/slate/src/core/children.tspackages/slate/src/core/batch.tspackages/slate/src/core/apply.tsDesign:
Coverage:
editor.children sees the updated tree after each opPerf target:
Exit criteria:
editor.apply wrappers no longer require replay fallback just to stay correctset_nodeStatus: done
Goal:
Implementation units:
packages/slate/src/core/batch.tspackages/slate/src/create-editor.tspackages/slate-history/src/with-history.tspackages/slate-dom/src/plugin/with-dom.tspackages/slate-react/src/plugin/with-react.tsDelete or simplify:
Built-ins should keep one seam:
editor.applyCoverage:
Exit criteria:
Status: partly done
Goal:
set_node tree ops during batchingImplementation units:
packages/slate/src/core/children.tspackages/slate/src/core/apply.tspackages/slate/src/core/batch.tspackages/slate/src/interfaces/transforms/general.tspackages/slate/test/with-batch.jspackages/slate/test/apply-batch-generic-tree-ops.jsDesign:
draftChildren root in internal statedraftChildrenset_node overlay is active and a structural op arrives:
draftChildrendraftChildrenCoverage:
insert_node, remove_node, merge_node, split_node, and move_node inside Editor.withBatch(...) preserve read-after-apply semanticsset_node + structural-op batches match replay semanticseditor.operations, dirty paths, and flush scheduling stay correctExit criteria:
Current checkpoint:
set_node tree opsset_node + structural-op batches promote into generic draft state instead of mutating committed childreneditor.children = ... inside a batch currently lands in draft stateStatus: pending
Goal:
Priority order:
insert_node / remove_nodesplit_node / merge_nodemove_nodeDesign rule:
Coverage:
withHistory(...)withReact(...) and withDOM(...)Exit criteria:
Status: pending
Goal:
Rules:
editor.children = ... inside batch must have explicit semanticsCoverage:
editor.children = ... during batcheditor.operations, dirty paths, or pending flush stateExit criteria:
Do not even touch this before Phases 0 through 7 are green and benchmarked.
If revisited later:
This work should be run strict TDD, not “add a benchmark and pray.”
Strong rule: 100% coverage here does not mean “every theoretical permutation forever.” That’s fake precision. It means 100% of the declared matrix is generated, replay-oracled, and green.
Every matrix case must compare batched execution against one canonical oracle:
editor.apply(op)editor.operationsDo not hand-write expected trees for broad matrix coverage. Use replay as the semantic oracle and reserve explicit expected trees for a few named evil cases only.
The matrix must be explicit. “Mixed ops” is not a dimension. It’s hand-waving.
Transforms.applyBatch(editor, ops)Editor.withBatch(editor, () => ops.forEach(editor.apply))editor.apply wrappereditor.apply wrapperwithHistorywithReactwithDOMeditor.children after each opset_nodeinsert_noderemove_nodemove_nodemerge_nodesplit_nodeinsert_textremove_textset_selectioneditor.children = ... during batchapply throws mid-batchTrying to full-cartesian every axis is how you build a heroic, useless test suite. Use tiers.
For each individual op family, generate the full cross-product of the smallest meaningful axes:
This is the closest thing to honest 100% and it is affordable because it stays family-local and uses tiny documents.
For multi-family batches, require pairwise coverage across all axes and full coverage across:
Use generated cases. No ad hoc “one mixed test should be enough” nonsense.
These stay explicit and permanent:
insert_node + move_nodeThe generator does not replace these. These are the scars.
Perf is not part of semantic 100%, but it is part of rewrite acceptance.
Do not keep hand-writing cases forever. Add a small matrix generator helper and let specs declare dimensions.
Required shape:
applyBatchwithBatcheditor.operations equivalenceThe generator must emit deterministic case names so failures are readable and bisectable.
The matrix must be enumerable, not implied.
Required:
This prevents the classic rot:
If a new op family, wrapper mode, observation mode, or planner boundary is added, the manifest must change in the same patch.
Keep broad matrix coverage split by concern instead of growing one monster file.
packages/slate/test/with-batch.jsKeep explicit seam tests here:
packages/slate/test/apply-batch-exact-set-node.jsThis becomes the exhaustive family matrix for exact set_node.
Required generator dimensions:
applyBatch vs manual withBatchpackages/slate/test/apply-batch-generic-tree-ops.jsThis owns the exhaustive family matrices for:
insert_noderemove_nodemove_nodemerge_nodesplit_nodeAnd the pairwise mixed-tree matrix.
packages/slate/test/apply-batch-generic-ops.jsThis owns non-tree and cross-domain mixes:
insert_textremove_textset_selectionpackages/slate-history/test/apply-batch-exact-set-node.jsHistory matrix, not just spot checks.
Required axes:
set_nodewithNewBatch(...) split casepackages/slate-react/test/chunking.spec.tsReact-specific batch coverage, not just one smoke test.
Required axes:
Transforms.applyBatch(...) move batchesEditor.withBatch(...) move batchesmovedNodeKeys drains cleanly after reconcileIf DOM bookkeeping still has unique risk after that, add a dedicated DOM regression file. Do not pretend React coverage covers it.
packages/slate/test/perf/set-nodes-bench.js remains the source of truth.
The perf lane registry is explicit now:
packages/slate/test/perf/set-nodes-bench.jsREQUIRED_BENCHMARK_IDSpackages/slate/test/perf-benchmark-manifest.js fails if a required lane disappearsAdd or preserve lanes for:
Transforms.applyBatch([...set_node])Editor.withBatch(...) manual apply loopinsert_node + move_node laneinsert_noderemove_nodemerge_nodesplit_nodemove_nodeBenchmark rule:
editor.applysite/examples/ts/huge-document.tsx stays the manual/browser repro, not the primary performance oracle.
Semantic acceptance:
Perf acceptance:
10x on flat 5,000Editor.withBatch(...) loop and Transforms.applyBatch(...) stay in the same performance classCompletion rule:
100% matrix coverage until the plan names the matrix, the generator enumerates it, and CI runs the whole declared setPrimary implementation files:
/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/utils/weak-maps.ts/Users/zbeyens/git/slate-v2/packages/slate/src/core/batch.ts/Users/zbeyens/git/slate-v2/packages/slate/src/core/apply.ts/Users/zbeyens/git/slate-v2/packages/slate/src/interfaces/transforms/general.ts/Users/zbeyens/git/slate-v2/packages/slate/src/core/children.ts/Users/zbeyens/git/slate-v2/packages/slate/src/core/batching/exact-set-node-children.tsPrimary tests:
/Users/zbeyens/git/slate-v2/packages/slate/test/with-batch.js/Users/zbeyens/git/slate-v2/packages/slate/test/apply-batch-exact-set-node.js/Users/zbeyens/git/slate-v2/packages/slate/test/apply-batch-generic-tree-ops.js/Users/zbeyens/git/slate-v2/packages/slate/test/apply-batch-generic-ops.js/Users/zbeyens/git/slate-v2/packages/slate/test/children-accessor.js/Users/zbeyens/git/slate-v2/packages/slate-history/test/apply-batch-exact-set-node.js/Users/zbeyens/git/slate-v2/packages/slate-react/test/chunking.spec.ts/Users/zbeyens/git/slate-v2/packages/slate/test/perf/set-nodes-bench.jspackages/slatepackages/slate/test/perf/set-nodes-bench.jssite/examples/ts/huge-document.tsxSome code may assume children is a plain data property.
Mitigation:
createEditor() and direct assignmentObservation-heavy batches may materialize many snapshots and lose some speed.
Mitigation:
Trying to optimize all ops immediately will bury the work.
Mitigation:
Finish the rewrite in two layers:
editor.children exposes immutable snapshotseditor.apply(op)set_node stays optimized