Back to Plate

Plate `nodeId` should use `setNodesBatch` only for live normalization

docs/solutions/performance-issues/2026-03-31-plate-nodeid-should-use-setnodesbatch-only-for-live-normalization.md

53.0.64.7 KB
Original Source

Plate nodeId should use setNodesBatch only for live normalization

Problem

Plate needed the new batched set_node fast path inside its local @platejs/slate package, but nodeId had two different workloads:

  • initial-value normalization, where the plugin already owns editor.children
  • live normalization, where the plugin intentionally uses editor transforms

Those workloads look similar on paper and they are not the same seam. Reusing the public batch API for both would have made initialization noisier and less honest.

Symptoms

  • nodeId already had a good pure initial-value path through transformInitialValue, but the live nodeId.normalize() transform still paid the per-node setNodes cost.
  • The new setNodesBatch API lived in Plate's local packages/slate, not only in the separate .tmp/slate-v2 prototype repo, so the adoption work could happen immediately.
  • Plate's local Slate wrapper does not expose Slate's private dirty-path weak maps, so a direct copy of the upstream prototype would have been half true and half bullshit.

What Didn't Work

  • Replacing the nodeId initial-value transform with editor.tf.setNodesBatch(...). That would manufacture operations, history boundaries, and change notifications during initialization even though the plugin already owns the initial value.
  • Pretending the local @platejs/slate package could safely deep-import Slate private internals for dirty-path updates. The published slate package ships a bundled runtime, not a clean public deep-import surface for those helpers.

Solution

Keep the abstractions honest.

1. Add editor.tf.setNodesBatch(...) to @platejs/slate

The local Slate package now exposes an explicit exact-path batch API. It keeps the one-pass tree rewrite from the upstream prototype and records ordinary set_node operations for history and change detection.

The focused tests live in:

2. Keep history behavior explicit

withHistory now understands setNodesBatch as one logical change:

  • merge into the current undo batch when the current tick is still open
  • start a new batch inside withNewBatch
  • honor withoutSaving

That keeps the batch API fast without pretending it is the same thing as a loop of ordinary apply(...) calls.

3. Use the batch API only for live nodeId.normalize()

The nodeId plugin now collects missing-id updates first and applies them with one editor.tf.setNodesBatch(...) call under withoutSaving.

transformInitialValue stays on the pure returned-value path, controlled by the initialValueIds option.

That split matters:

  • live normalization is a transform problem, so the transform API is the right seam
  • initial normalization is a value-ownership problem, so a pure value transform is the right seam

Why This Works

The speedup comes from rewriting shared ancestors once instead of once per set_node operation.

The semantic win comes from not overusing that API. setNodesBatch is a runtime transform surface. Initial normalization is not.

The local Plate port keeps the fast rewrite, saves history explicitly, and runs a local dirty-path normalization queue for the batch. That keeps the feature production-safe without lying about access to Slate internals.

The local micro-benchmark kept the real performance win on a flat huge-document shape:

BlockssetNodessetNodesBatchSpeedup
100018.56 ms2.63 ms7.05x
5000118.54 ms4.92 ms24.10x

Prevention

  • If a plugin already owns editor.children during initialization, do not route that work back through editor operations just to reuse a runtime API.
  • Keep exact-path batch APIs explicit. They are valuable because they are stricter than broad traversal transforms, not because they hide inside them.
  • When porting upstream transform work into Plate's local Slate wrapper, verify what private Slate machinery is actually reachable from the published package before assuming parity.
  • Keep focused tests around history behavior. Undo bugs are where "fast" optimizations go to die.