docs/plans/2026-03-30-slate-set-node-note-for-maintainer.md
set_node on wide sibling arrays: benchmark noteI ran into a large perf cliff while benchmarking Plate against Slate on huge documents.
Plate had a bug on its side: initial nodeId normalization was using a live Slate transform once per missing id. That is now fixed in Plate by walking the initial value directly.
While isolating that, I benchmarked the same repeated exact-path update shape inside slate itself. The result is consistent:
Transforms.setNodes(...) on a flat huge document is dominated by the set_node transform pathmodifyDescendant, especially when the parent array is very wideThis is not a DOM benchmark. It is a pure transform benchmark.
Benchmark file:
packages/slate/test/perf/set-nodes-bench.jsCommands:
bun ./packages/slate/test/perf/set-nodes-bench.js --blocks=5000 --group-size=50 --repeats=5
bun ./packages/slate/test/perf/set-nodes-bench.js --blocks=10000 --group-size=50 --repeats=3
For the same total paragraph count, it compares:
50For each shape, it measures:
Transforms.setNodes(editor, { id }, { at: path }) per exact pathEditor.withoutNormalizing(...)editor.apply({ type: 'set_node', ... }) inside Editor.withoutNormalizing(...)modifyDescendant(...)The timed apply wrapper also splits:
Transforms.transform(...)Editor.normalize(...)The relevant code path looks like this:
packages/slate/src/transforms-node/set-nodes.ts
editor.apply({ type: 'set_node', ... })packages/slate/src/interfaces/transforms/general.ts
set_node case calls modifyDescendant(...)packages/slate/src/utils/modify.ts
modifyDescendant(...) rebuilds ancestor chains with replaceChildren(...)replaceChildren(...) uses array slicing/spreadingThat makes repeated exact-path set_node operations sensitive to parent-array width.
| Case | Flat | Grouped |
|---|---|---|
setNodes per path | 73.35 ms | 22.09 ms |
setNodes inside outer withoutNormalizing | 52.96 ms | n/a |
direct apply(set_node) | 44.62 ms | 9.11 ms |
bare modifyDescendant | 37.01 ms | 2.38 ms |
| one-pass rewrite | 0.15 ms | 0.19 ms |
| Case | Flat | Grouped |
|---|---|---|
setNodes per path | 241.36 ms | 66.19 ms |
setNodes inside outer withoutNormalizing | 169.07 ms | n/a |
direct apply(set_node) | 147.71 ms | 22.16 ms |
bare modifyDescendant | 140.11 ms | 7.25 ms |
| one-pass rewrite | 0.35 ms | 0.61 ms |
apply breakdownAt 10,000 flat paragraphs:
applyTotalMs: 146.12 mstransformMs: 138.00 msdirtyPathsMs: 2.37 msnormalizeMs: 1.76 ms2.56 msAt 10,000 grouped paragraphs:
applyTotalMs: 21.81 mstransformMs: 12.51 msdirtyPathsMs: 3.78 msnormalizeMs: 0.78 msThe strongest signal is shape sensitivity.
The same number of paragraph nodes gets much cheaper as soon as wide top-level sibling arrays become smaller grouped arrays. That strongly suggests the main cost is not generic normalization overhead. It is the repeated immutable rewrite of ancestor chains, especially wide children arrays.
The modifyDescendant(...) numbers are the clearest evidence:
5,000 flat: 37.01 ms5,000 grouped: 2.38 ms10,000 flat: 140.11 ms10,000 grouped: 7.25 msThat is already most of the apply(set_node) cost.
So the current behavior seems to be:
setNodes(...) itself adds some overheadapply(...) adds some overheadset_node transform pathThis does not mean Slate is doing something incorrect.
It does mean there may be an upstream optimization seam for workloads that need to set many exact-path node props on very wide sibling arrays.
The Plate bug is fixed locally by avoiding this pattern during initial-value normalization. But the underlying Slate transform shape still looks expensive enough that it may be worth considering an optimization for exact-path bulk updates.
These are just candidate ideas, not a fully baked proposal:
Add a batched exact-path set_node path.
If the caller already has exact paths, shared ancestors could be rebuilt once instead of once per op.
Add an internal fast path for setNodes when:
at is an exact Pathmatch traversalAdd a lower-level “apply many node property updates” helper. This could preserve Slate semantics while avoiding repeated ancestor cloning for the same parent chain.
normalize is free in all cases, only that it is not the dominant cost in this specific repeated exact-path set_node workload.Does this line up with your understanding of the current transform costs?
If useful, I can also put together a smaller upstream-style benchmark or try a prototype batched exact-path implementation to compare against the current path.