Skip to content

Basic mutable batching with minimal overhead.#6050

Draft
nabbydude wants to merge 9 commits into
ianstormtaylor:mainfrom
nabbydude:mutable-batching
Draft

Basic mutable batching with minimal overhead.#6050
nabbydude wants to merge 9 commits into
ianstormtaylor:mainfrom
nabbydude:mutable-batching

Conversation

@nabbydude

@nabbydude nabbydude commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

Description

As described well in linked issue, large groups of tree updates make immutable changes in series which results in many more clone-and-tweak operations than are ever needed.

This PR solves this by providing opt-in stretches of mutability. During a Mutation Batch, only the first edit to a element's children will clone the element (so any pre-batch references remain immutable) and any further edits will just mutate in place. This means that the node tree is never proxied or out-of-date, it will always reflect the true state. (⚠️ Having mutable nodes in the tree can have other issues, see Limitations below)

At the end of a batch there is no need to apply any kind of draft, elements simply remain in place and are henceforth treated as immutable. "Finalization" is effectively free.

We add two methods to the editor object: asMutationBatch and isBatchingMutations that work similarly to withoutNormalizing and isNormalizing respectively.

Issue

resolves #6038

Usage

// before
for (const line of lines) {
  Transforms.splitNodes(e, { always: true })
  e.insertText(line)
}
// 10 lines will result in 20 copies of e.children and every other ancestor of the current selection needing to be created now and garbage collected later

// after
editor.asMutationBatch(() => {
  for (const line of lines) {
    Transforms.splitNodes(e, { always: true })
    e.insertText(line)
  }
})
// there are only two (2) e.children arrays in memory: 1. the state before the batch, and 2. the state after the batch.

Implementation

This is implemented by altering the modify functions that handle making immutable changes to the tree to instead make mutable changes when appropriate. Because batches are encapsulated in a single callback, this means the user API remains basically the same for anyone not using batching or using batching invisibly (within plugins/builtin transforms/etc)

Mutability is tracked with a simple Set, which tracks children arrays that have been created during the batch and thus can be altered. (we track the children arrays themselves because using a set_node op on a node will result in a new node that shares the same children array object with the original)

This adds 1-2 WeakMap lookups to every operation application to check whether the editor is in a batch, but otherwise does not affect any codepaths that don't involve mutation batching.

Uses

Currently used in Editor#insertTextData--which is called when pasting--to speed up the split-and-insert operations per line.

Limitations

Only children array modifications are tracked this way, changing the non-children properties of a node using insert_text, remove_text, or set_node never mutates a node, only its ancestors. This is by design: multiple modifications to the same node in one frame seems like a problem that can be tackled through other means if not avoided altogether.

Mutable elements in the node tree

As warned above elements and children arrays are liable to be mutable during a batch and should be treated as such. There's a few ways this could happen:

  • slate-history tracks operations on Editor#apply and thus--through insert_node and remove_node--tracks node references. This is actually fine because inserted nodes aren't added to the mutable set until after the first time a child is changed, and removed nodes are no longer in the tree to be changed, BUT, if a mutable removed node were added somewhere else later in the same batch it would be still be treated as mutable and thus mutate the operation in history.
    • This is easily worked around in user space by using the intended move_node
    • Could also be guarded by manually removing the node from the mutable set on removal from the tree, but this would have to be done recursively for children also so could hamper performance.
  • Since mutability is tracked by object reference and nodes are mutated in place, a tree that has multiple references to the same branch node will run into issues, but this is not a valid JSON tree and already causes issues in slate-react so it should never happen anyways.
  • Any user that wraps a transform and within it stores an element reference for later use might run into issues, but this would be a fairly unlikely code pattern.
    • This could be further mitigated by only using direct apply or directly accessing the un-overloaded versions of methods within built-in batches but that seems like an antipattern to me.

It wouldn't be too hard to have a function that snapshots/commits a node--removing it from the mutable set mid-batch so it can be treated as immutable once more, (impl note: also traverse children to remove them) but I'll keep the API surface simple until there's demand for it

The more guardrails we add the more performance is sacrificed so I think its important to be judicial. Wrapping Editor#children with a getter, eg, would lead to a lot of de-ops

All-in-all, there's already something to consider when wrapping editor functions: Your operation may or may not result in any number of normalizations, so I dont think this kind of caveat is so unfamiliar to users as to cause issues.

Checks

  • The new code matches the existing patterns and styles.
  • The tests pass with yarn test.
  • The linter passes with yarn lint. (Fix errors with yarn fix.)
  • The relevant examples still work. (Run examples with yarn start.)
  • You've added a changeset if changing functionality. (Add one with yarn changeset add.)

@changeset-bot

changeset-bot Bot commented Apr 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: dd1d5dd

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
slate Minor
slate-dom Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@zbeyens

zbeyens commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

Thanks for putting this together. This PR was useful, and I pulled a couple of ideas from it into #6039.

The main thing I took from this is that the simple child-array rewrite helpers are a real win. Replacing spread-heavy [...slice, value, ...slice] style updates with slice() + splice / direct assignment materially improves the normal non-batched path. On my branch, flat exact set_node dropped from the ~52ms range to ~12ms after that helper-level change. I also removed the Fenwick tree from my insert batch helper after re-testing; the simpler copy+splice version kept the structural batch benchmark fast enough and is much easier to review.

I also added benchmark lanes based on this PR’s motivating case:

  • paste-style splitNodes + insertText
  • deep exact set_node
  • append/prepend insert_node
  • interleaved insert_node + move_node
  • text-op batching

This PR is very strong for flat repeated set_node and modestly helps the paste workload.

On my machine:

But this PR does not solve the broader structural batching problem:

So I think this PR proves two things:

  1. There is a small helper-level optimization worth landing.
  2. I’m not convinced public mutable batching is the right long-term batch API.

The part I’m still uncomfortable with is the semantic contract. whileMutablyBatching makes previously observed element/children references volatile during the callback. That is a big behavioral footgun for Slate plugins because editor.apply wrappers, history, React/DOM integration, and userland code can all observe nodes between operations. My worry is that documenting “don’t store references during a batch” may not be enough of a boundary for core Slate.

A couple concrete code concerns:

  • There are no dedicated regression tests for the new mutability semantics.
  • merge_node appears to build newNode once, add newNode.children to the mutable set, then immediately overwrite newNode with another prev.children.concat(...), which loses the tracked array. That looks accidental.
  • The batch wrapper does not create a real transaction boundary around normalization/flushing; it is more “copy fewer arrays while normal apply runs” than a batch engine.
  • The public name/API exposes implementation strategy (MutablyBatching) rather than caller intent.

My preference would be:

  • extract the helper-level copy/splice improvements into a small PR
  • keep paste as a benchmark/regression case
  • do not add public whileMutablyBatching
  • keep Editor.withBatch / Transforms.applyBatch as the durable API shape
  • land exact set_node and structural insert/move batching only where benchmarks justify the complexity

@nabbydude

Copy link
Copy Markdown
Contributor Author

using a modified-to-fit version of #6039's packages\slate\test\perf\set-nodes-bench.js and the command listed in that PR's first post I got these results on my machine:

id label main #6050 (this PR) #6039
setnodes-flat Transforms.setNodes per path 181.73 91.92* 5904.13
setnodes-flat-no-normalize Transforms.setNodes per path inside Editor.withoutNormalizing 125.17 23.97* 40.76
apply-flat-no-normalize editor.apply(set_node) per path inside Editor.withoutNormalizing 92.35 16* 26.01
apply-batch-flat applyBatch exact-path set_node batch N/A 2.52 9.75
apply-batch-flat-mixed-insert-tail applyBatch exact-path set_node batch plus tail insert N/A 3.61 12.33
apply-insert-empty-no-normalize editor.apply(insert_node) batch on empty document inside Editor.withoutNormalizing 3828.56 3786.72* 3804.85
apply-batch-insert-empty applyBatch insert_node batch on empty document N/A 2563.78 19.72
setnodes-grouped Transforms.setNodes per path on grouped document 23.06 20.21* 163.94
apply-grouped-no-normalize editor.apply(set_node) per path inside Editor.withoutNormalizing on grouped document 11.19 5.41* 13.56
apply-batch-grouped applyBatch exact-path set_node batch on grouped document N/A 2.09 10.16

(*control group speedups are from a change to avoid spread operations in modify functions as mentioned by LLM above, which could land with or without batches)

Code the same but for withBatch swapped for whileMutablyBatching and applyBatch shimmed with this:

const applyBatchShim = (editor, ops) => {
  batchDirtyPaths(
    editor,
    () => {
      Editor.whileMutablyBatching(editor, () => {
        for (const op of ops) {
          editor.apply(op)
        }
      })
    },
    () => {
      let dirtyPaths = []
      for (const op of ops) {
        if (Path.operationCanTransformPath(op)) {
          dirtyPaths = dirtyPaths
            .map(p => Path.transform(p, op))
            .filter(p => p !== null)
        }
        dirtyPaths.push(...editor.getDirtyPaths(op))
      }
      updateDirtyPaths(editor, dirtyPaths)
    }
  )
}

@nabbydude

Copy link
Copy Markdown
Contributor Author

renamed whileMutablyBatching to asMutationBatch and isMutablyBatching to isBatchingMutations

@nabbydude nabbydude marked this pull request as draft June 26, 2026 14:53
@nabbydude

Copy link
Copy Markdown
Contributor Author

drafting until I have time to review the API and write some proper docs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Perf: repeated tree updates need a batch-aware apply engine

2 participants