docs/performance/plate-vs-slate-benchmarks.md
Plate needed a fair huge-document benchmark against Slate, not another vibes-based perf argument.
The missing piece was a harness that separated editor construction cost from DOM mount cost, isolated nodeId, and let us rerun the same workload without rewriting ad hoc scripts every time.
nodeId, or simple plugin count.http://127.0.0.1:<port> against a Next dev server running on localhost
can leave the page effectively unhydrated because dev resources are blocked
cross-origin by default.10,000-block construction + mount + input run exceeded the runner's default 180000 ms timeout.protocolTimeout to the app benchmark timeout,
so the browser could kill a valid long run before the harness timeout diddocument.location.search, causing
workload-specific hydration mismatches under runner-driven URLsrenderElement fallback still resolved path for every node
even though RenderElementProps.path was already optionalhundreds-blocks-demo was a useful side-by-side demo, but not a benchmark harness.React.Profiler did not, so measured runs never hit phase === 'mount'.input.checked = false plus a synthetic change event did not reliably update React state in the runner. The exported config exposed that the “stress” lane was still chunked.Build a reusable benchmark lane around the huge-document workload and make it scriptable.
apps/www/src/app/dev/editor-perf/page.tsx compares four controlled scenarios:
nodeId: falsenodeIdIt mounts exactly one huge editor at a time. That matters. Rendering four huge editors at once turns the benchmark page into the main bottleneck and poisons the comparison.
The page now runs three different lanes:
Profiler timing on a keyed remountTransforms.insertText plus double-RAF settleThe key fix for mount was remounting the profiler itself:
<React.Profiler
key={`${activeScenarioId}:${mountVersion}`}
id={activeScenarioId}
onRender={onRender}
>
<ScenarioEditor ... />
</React.Profiler>
Without that key, repeated runs looked like updates instead of mounts and the benchmark happily returned zeros.
The page exports structured JSON in a hidden <pre> and the repo now has:
apps/www/scripts/run-editor-perf.mtsapps/www/package.json script: perf:editorThe runner applies page controls, runs selected benchmarks, and writes JSON to disk. The checkbox fix was to use a real click when controlled state needs flipping:
if (input.checked !== checked) {
input.click();
}
The next runner fix was about the benchmark-start race. The runner now captures the JSON export before clicking, then:
That closes the “not started yet == finished” hole.
The next harness improvement was equally practical:
Core mount stage selector plus Run active core mount stage--core-mount-case <id>That turned core-mount from a full-zoo rerun into a usable seam tool for
5,000-block investigation.
The next scaling lesson was about preset shape, not just code:
5,000 blocks is a real comparison jobThe next runner fix was more fundamental:
windowhttp://localhost:3000/dev/editor-perf, unless Next allowedDevOrigins
explicitly allows another hostThat killed two fake problems at once:
127.0.0.1 vs localhost origin mismatch in Next
devThe next runner fix was lower-level but just as important:
protocolTimeout must be larger than the app-level benchmark timeout and the
longest preset job timeoutPromise.race does not cancel page.evaluate(...)Execution context was destroyed and crash the processThe runner now:
Runtime.callFunctionOn timed out as recoverablepage.evaluate(...) rejections after the timeout branch already
wonThe next page fix was about URL-driven state correctness:
/dev/editor-perf now initializes from a server-safe default configThat removes the huge-mixed-block vs workload-specific hydration mismatch
that showed up in Next dev logs when the runner switched scenario URLs.
The next plain-core fix was smaller but high leverage:
pipeRenderElement(...) no longer forces useNodePath(...) before calling a
custom renderElement fallbackRenderElementProps.path is optional already, so this lookup was pure tax in
fallback-heavy lanes like huge-blockquoteThat one-line cut materially moved the lane it targeted:
core-blockquote-5k-chunk:
334.58 ms425.99 ms346.75 ms301.66 msThe exact numbers will drift by run, but the direction did not: removing the fallback path lookup pulled Plate core out of the red on that lane.
/dev/editor-perf is the right harness for editor-core and plugin-overhead
questions. It is the wrong place to diagnose table-selection cost.
The repo now also has:
apps/www/src/app/dev/table-perf/page.tsxapps/www/scripts/run-table-perf.mtsapps/www/package.json script: perf:tableThat harness measures three different table workloads:
The first honest result was that selection, not mount, is the red lane:
20x20, select 5x5: 55.50 ms40x40, select 10x10: 224.51 ms60x60, select 15x15: 454.39 msTwo “probably this” ideas got tested and reverted:
useSelectedCells() inside useTableSelectionDom()Neither produced a real win.
The kept cut was lower and much less glamorous:
getTableGridByRange(...) no longer routes plain unmerged tables through the
merge-aware grid query pathThat moved the actual lane:
60x60, select 15x15:
454.39 ms -> 419.67 ms40x40, select 10x10:
224.51 ms -> 192.24 msSo the lesson is simple: do not chase table perf from the React shell first if the query layer is still doing merge-specific work on non-merged tables.
The follow-up version adds three more lenses:
prebuiltMount: create and initialize the editor first, then measure only the React/provider/render mountinitDissection: split Plate into constructOnly, initOnly, createWithValue, and pure normalizeNodeId(value)coreMount: split the prebuilt Plate mount into provider only, PlateSlate only, useEditableProps only, Editable with static props, minimal editable mount, and full PlateContentEditable path again into pipeRenderElement only, pipeRenderLeaf + pipeRenderText only, and all render pipes togetherThat lets the harness answer two different questions cleanly:
nodeIdnodeId cost comes from traversal versus runtime editor operationsEditable mount with useEditableProps outputsThese runs were enough to answer the important questions:
1,000 blocks, chunked5,000 blocks, chunked5,000 blocks, no chunking, no content-visibility1,000 blocks, chunked, prebuiltMount + initDissection
5,000 blocks, chunked, prebuiltMount + initDissection10,000 blocks, chunked, initDissection5,000 blocks, chunked, store fan-out at 250 and 1,000 subscribersKey results:
0.0003 ms0.38-0.42 ms0.65 ms5,000 chunked:
255.57 ms616.24 ms (2.41x Slate)nodeId: 1678.65 ms (2.72x Plate core)636.40 ms (1.03x Plate core)5,000 no chunking:
274.59 ms612.61 msnodeId: 1583.68 ms623.44 ms5,000 no chunking:
39-40 ms1,000 blocks:
66.18 ms125.06 ms5,000 blocks:
271.74 ms650.34 msinitDissection:
initOnly:
1,000: 0.81 ms5,000: 3.93 ms10,000: 7.73 msinitOnly:
1,000: 1.10 ms5,000: 5.29 ms10,000: 10.83 msinitOnly:
1,000: 0.90 ms5,000: 4.73 ms10,000: 8.73 msnormalizeNodeId(value):
1,000: 1.21 ms5,000: 6.10 ms10,000: 12.03 ms1,000: 10005,000: 500010,000: 10000normalizeInitialValue: null now truly disables initial normalization:
1,000: 0.78 ms, 0 ids assigned5,000: 3.66 ms, 0 ids assigned10,000: 8.19 ms, 0 ids assigned5,000 chunked:
602-638 ms250 subscribers:
useEditorState: 0.71 ms updateuseEditorValue: 0.68 ms updateuseEditorSelector: 1.11 ms updateusePluginOption: 1.83 ms update and 2 commits3.77 ms update and 2 commits1,000 subscribers:
useEditorState: 2.85 ms updateuseEditorValue: 3.52 ms updateuseEditorSelector: 4.04 ms updateusePluginOption: 4.61 ms update and 2 commits13.95 ms update and 2 commitscoreMount:
1,000 blocks:
0.19 msPlateSlate only: 0.37 msuseEditableProps only: 0.46 msEditable static props: 52.02 msEditable + element pipe: 92.89 msEditable + fallback element pipe: 57.45 msEditable + plugin element (precomputed path): 96.43 msEditable + leaf/text pipes: 63.91 msEditable + render pipes: 114.54 ms112.69 msPlateContent: 115.48 ms5,000 blocks:
0.26 msPlateSlate only: 0.69 msuseEditableProps only: 0.49 msEditable static props: 273.26 msEditable + element pipe: 473.55 msEditable + fallback element pipe: 278.45 msEditable + plugin element (precomputed path): 502.07 msEditable + leaf/text pipes: 358.38 msEditable + render pipes: 573.85 ms568.57 msPlateContent: 546.49 msEditable:
pipeRenderElement only:
1,000: +40.86 ms5,000: +200.29 ms1,000: +5.43 ms5,000: +5.19 ms1,000: +44.40 ms5,000: +228.81 mspipeRenderLeaf + pipeRenderText only:
1,000: +11.89 ms5,000: +85.11 ms1,000: +62.51 ms5,000: +300.59 msThe first code fix landed in packages/core/src/react/utils/pipeRenderElement.tsx.
The important false lead was this:
renderElement path sounded plausiblep pluginEditable, so it was never the main taxThe added benchmark cases made that obvious:
Editable + fallback element pipeEditable + plugin element (precomputed path)That showed:
useNodePath is not the main storyThe landed fast path now skips the heavier pluginRenderElement wrapper when the matched element plugin is plain:
render.noderender.asaboveNodes / belowNodes wrappersIt still preserves plugin class injection, BelowRootNodes, and edit-only behavior.
Guard tests live in packages/core/src/react/utils/pipeRenderElement.spec.tsx.
Measured improvement versus the previous iteration:
1,000 blocks:
Editable + element pipe: 118.15 ms -> 92.89 ms (-21.4%)117.42 ms -> 112.69 ms (-4.0%)5,000 blocks:
Editable + element pipe: 528.44 ms -> 473.55 ms (-10.4%)590.65 ms -> 568.57 ms (-3.7%)PlateContent: 581.84 ms -> 546.49 ms (-6.1%)The next split decomposed the remaining paragraph path into:
ElementProvider per nodeElementProvider + PlateElementgetRenderNodeProps + PlateElement, but no providerThat made the answer much less hand-wavy.
1,000 blocks, pre-fix:
Editable: 49.04 ms67.77 ms (+18.74 ms)PlateElement: 87.67 ms (+38.64 ms)getRenderNodeProps, no provider: 76.25 ms (+27.21 ms)97.14 ms (+48.10 ms)5,000 blocks, pre-fix:
Editable: 257.36 ms369.55 ms (+112.19 ms)PlateElement: 367.14 ms (+109.78 ms)getRenderNodeProps, no provider: 341.27 ms (+83.91 ms)460.26 ms (+202.90 ms)That means:
ElementProvider is a real scaling termPlateElement itself is basically not the problem in this lanegetRenderNodeProps is also expensive, but smaller than the provider taxThe second code fix also landed in packages/core/src/react/utils/pipeRenderElement.tsx.
The same plain-element fast path from iteration 1 no longer mounts
ElementProvider.
That is safe in this path because:
getRenderNodeProps already ran before any provider existedPlateElement does not read element contextBelowRootNodes only reads editor contextrender.node, render.as, and global node wrappersMeasured improvement versus the previous iteration:
1,000 blocks:
Editable + element pipe: 97.14 ms -> 82.42 ms (-15.1%)Editable + render pipes: 117.15 ms -> 93.51 ms (-20.2%)115.38 ms -> 92.79 ms (-19.6%)PlateContent: 116.36 ms -> 94.19 ms (-19.1%)5,000 blocks:
Editable + element pipe: 460.26 ms -> 347.39 ms (-24.5%)Editable + render pipes: 539.51 ms -> 432.90 ms (-19.8%)530.54 ms -> 427.49 ms (-19.4%)PlateContent: 533.34 ms -> 425.71 ms (-20.2%)The benchmark now contains its own control:
pipeRenderElement lane lands at 347.39 mspluginRenderElement lane with precomputed paths still lands at 476.45 msSame plugin, same document, same path-lookup story. The important difference is
that the direct pluginRenderElement lane still pays the old per-node provider
tax while the new fast path does not.
The next split stopped treating PlateElement like one opaque blob and measured
three isolated no-provider lanes:
Editable plus a local wrapper with no data-block-iddata-block-id, but no useEditorMounted()PlateElementThat answered the narrower question: was the remaining isolated PlateElement
tax actually the wrapper markup, the block-id check, or the plate-store
subscription used to gate data-block-id until mount?
1,000 blocks, pre-fix:
Editable: 49.55 ms56.13 ms53.10 msPlateElement, no provider: 64.39 ms5,000 blocks, pre-fix:
Editable: 266.79 ms274.98 ms275.60 msPlateElement, no provider: 325.70 msThat means:
data-block-id check is cheapPlateElement tax was mostly the unconditional
useEditorMounted() subscription on every nodeThe third code fix landed in packages/core/src/react/components/plate-nodes.tsx
with focused coverage in
packages/core/src/react/components/plate-nodes.spec.tsx.
PlateElement now computes whether a node can ever emit data-block-id
before touching the plate store:
useEditorMounted()Guard coverage now proves:
Measured confirmation versus the pre-fix isolated PlateElement lane:
1,000 blocks:
Editable + PlateElement, no provider: 64.39 ms -> 57.47 ms
(-10.7%)5,000 blocks:
Editable + PlateElement, no provider: 325.70 ms -> 308.82 ms
(-5.2%)The broader element path barely moved in the same rerun:
5,000 blocks:
Editable + element pipe: 347.39 ms -> 346.00 msThat is the useful takeaway:
PlateElement subscription was real and worth removingnodeId seam is still getRenderNodeProps plus the
older direct pluginRenderElement pathThe next nodeId pass instrumented the raw init path directly instead of
talking about “editor ops” as a blob.
The benchmark wrapped editor.api.node and editor.tf.setNodes during
tf.init(...) and measured both call counts and total time.
That showed the real bomb immediately:
editor.api.node(path) was almost irrelevant
5,000 blocks: 5000 calls, 1.50 ms10,000 blocks: 10000 calls, 3.07 mseditor.tf.setNodes(...) was almost the whole old init path
5,000 blocks: 5000 calls, 431.05 ms out of 441.45 ms10,000 blocks: 10000 calls, 1631.13 ms out of 1651.86 msThat means the issue was not “nodeId traversal is inherently slow.” The issue was using a live Slate transform once per missing id during initial load, where the editor already owned the whole value.
The fourth code fix landed in
packages/core/src/lib/plugins/node-id/NodeIdPlugin.ts with focused coverage
in packages/core/src/lib/plugins/node-id/NodeIdPlugin.spec.tsx.
normalizeInitialValue now walks editor.children directly and assigns ids in
place using editor-aware block checks.
Important boundary:
tf.nodeId.normalize() stays intact for live editor-operation pathssetNodes(...)normalizeInitialValue: null remains a real hard disableMeasured confirmation versus the instrumented pre-fix path:
1,000 blocks:
initOnly: 29.51 ms -> 1.10 ms5,000 blocks:
initOnly: 441.45 ms -> 5.29 ms10,000 blocks:
initOnly: 1651.86 ms -> 10.83 msThe stronger proof is structural, not just “faster”:
editor.api.node calls drop to 0setNodes calls drop to 0normalizeNodeId(value) lane instead
of the old live-transform cliffThe new --core-mount-case runner support made the next live seam much less
fuzzy.
Targeted 5,000-block reruns showed:
0.33 msPlateSlate only: 0.80 msuseEditableProps only: 0.66 msEditable static props: 262.65 msEditable + element pipe: 326.81 msEditable + leaf/text pipes: 357.46 msEditable + render pipes: 417.14 ms411.26 msPlateContent: 407.71 msThat means the live cliff was still the render layer, not provider setup,
useEditableProps, or PlateContent.
The same targeted reruns also killed the last broad useNodePath suspicion:
307.26 ms311.89 ms324.01 msSo live path lookup was only worth about 12 ms in that lane.
Core-minimal had no leaf plugins, no text plugins, and no injected node props,
but Plate still paid getRenderNodeProps(...) on every leaf and text node.
The fix in pipeRenderLeaf.tsx and pipeRenderText.tsx was simple:
PlateLeaf /
PlateText directly without getRenderNodePropsFocused tests now guard:
renderLeaf / renderTextleafProps behaviortextProps behaviorTargeted 5,000-block reruns after that fix:
Editable + leaf/text pipes: 357.46 ms -> 298.81 msEditable + render pipes: 417.14 ms -> 360.40 ms411.26 ms -> 357.80 msPlateContent: 407.71 ms -> 368.43 msThe next targeted probe compared:
257.56 ms268.14 msPlateElement, no provider: 304.26 msThat means the remaining plain-element tax was no longer mounted state or
block-id logic. It was mostly the internal PlateElement wrapper path itself.
The fix stayed inside pipeRenderElement.tsx:
then the plain fast path renders the element body directly instead of paying the
full PlateElement component path.
Targeted 5,000-block reruns after that fix:
Editable static props: 256.31 msEditable + element pipe: 285.86 msEditable + render pipes: 323.18 ms317.98 msPlateContent: 316.73 msThe next targeted probe split the remaining leaf/text seam with a new benchmark case:
Editable static props: 266.40 msEditable + leaf/text pipes: 297.48 msEditable + leaf/text plain renderers: 250.64 msThat case still runs through pipeRenderLeaf(...) and pipeRenderText(...),
but it passes plain <span> renderers in. So the comparison isolates the
default leaf/text wrapper path itself.
The important implication:
46.84 ms leaf/text tax in the no-plugin lane was not a store
issuePlateContentPlateLeaf / PlateText zero-work wrapper pathThis slice also exposed a benchmark harness bug first:
ReferenceError: leafTextBenchmarkMode is not definedBenchmarkEditableMount used the new prop but never destructured
itThe production fix stayed in pipeRenderLeaf.tsx and pipeRenderText.tsx:
then render a plain <span {...attributes}> directly.
The guarded cases stay on the old path:
renderLeaf / renderTextleafProps / textPropsFocused tests now also assert that the zero-work default leaf/text path still
lands on a plain SPAN.
Targeted 5,000-block reruns after that fix:
Editable + leaf/text pipes: 297.48 ms -> 248.76 msEditable + render pipes: 323.18 ms -> 309.80 ms317.98 ms -> 301.21 msPlateContent: 316.73 ms -> 298.19 msThe strongest proof is not just “smaller number”:
Editable + leaf/text pipes lane lands on top of the earlier plain
renderer probe (248.76 ms vs 250.64 ms)The next targeted probe moved to the richer pluginRenderElement(...) path.
At 5,000 blocks:
pluginRenderElement with precomputed paths: 487.64 msuseElement():
456.26 msThat is a real signal. ElementContent was paying per-node element-store
consumer cost just to read the same element object that it already had in
props.
The seam was narrow:
pluginRenderElement mounts ElementProviderElementContent immediately called useElement()element.attributesprops.element already contains that same elementSo the fix did not try to remove ElementProvider. That would be a different,
broader change. The first cut only removed the pointless self-read inside the
provider-backed path.
Production fix:
ElementProvideruseElement() inside ElementContentprops.element.attributes directly insteadGuard test:
render.node components still receive element context through
the providerTargeted 5,000-block reruns after the production fix:
pluginRenderElement: 487.64 ms -> 436.41 msBest proof:
pluginRenderElement lane converges
toward the earlier no-useElement probeThe next split proved the remaining provider-heavy tax was not “Jotai
somewhere.” It was the effect path inside ElementProvider.
The benchmark only changed one thing: gate FirstBlockEffect from the incoming
path prop instead of calling usePath() inside every provider.
At 5,000 blocks:
Editable + element provider only: 426.01 msEditable + provider + prop effect: 383.13 ms-42.89 msEditable + plugin element (precomputed path): 510.69 msEditable + plugin element + prop effect: 479.40 ms-31.29 msThat is the exact shape you want before touching production code: same provider, same store hydration, same children, less pointless work.
Production fix:
elementStore and useElementStorecreateAtomStorecreateAtomProvider('element', elementStore.atom)ElementProviderFirstBlockEffect from path props instead of subscribing to path
from the per-node storeThe production numbers carried through:
426.01 ms -> 368.10 ms510.69 ms -> 457.39 msBest proof:
366.85 ms368.10 msThat convergence says the probe was isolating the real seam, not inventing an artificial fast path.
After the provider path-gating cut, the richer paragraph-plugin path was still slower than the manual “fast node props” probe.
The decisive split at 5,000 blocks:
Editable + PlateElement + plugin ctx: 328.39 msgetRenderNodeProps lane:
Editable + render-node props, no provider: 341.87 ms+13.49 msEditable + plugin element, fast node props: 415.92 msEditable + plugin element (precomputed path): 444.36 ms+28.44 msThat made the next seam precise:
getRenderNodeProps(...) still had a real plain-plugin taxProduction fix:
node.propsdangerouslyAllowAttributesgetPluginNodeProps(...) path entirelyGuard coverage:
Measured carry-through at 5,000 blocks:
341.87 ms -> 334.35 ms444.36 ms -> 422.40 msResidual gap after the cut:
5.96 ms6.48 msThat is the right shape. The old getRenderNodeProps(...) tax is mostly gone,
and the remaining rich-plugin cost has moved downstream again.
The next rich-path split stayed on the same direct pluginRenderElement(...)
lane and asked a narrower question:
getEditorPlugin(editor, plugin) on every node?The first tempting cut was the wrong one:
BelowRootNodes wrapper when there are no root childrenThat looked neat and benchmarked worse:
pluginRenderElement lane:
504.70 ms -> 516.94 msSo that patch was reverted. It was not the seam.
The next cut went after repeated plugin-context allocation instead:
pluginContext = getEditorPlugin(editor, plugin) once per
pluginRenderElement(...) closuregetRenderNodeProps(...)Guard coverage:
Measured carry-through at 5,000 blocks:
pluginRenderElement with precomputed paths:
504.70 ms -> 484.80 ms-19.90 ms-3.94%Interpretation:
useElement() cut, but still worth takingThe harness answers the right question in the right order.
Construction numbers tell us zustand-x and plugin-store creation are real, but small. They do not explain a jump from hundreds of milliseconds to well over a second.
Mount numbers tell us where the real damage is:
nodeId is the dominant multiplier on top of thatThat shifts the next investigation away from “maybe it’s just plugin count” and toward:
nodeId initial-value processingThe no-chunk stress lane also shows that turning chunking off hurts typing for everyone roughly equally. That means this benchmark's main framework delta is mount/setup, not keystroke latency.
The deeper split sharpens the take:
nodeId. Prebuilt mount is already roughly 1.9x Slate at 1,000 blocks and 2.39x Slate at 5,000.nodeId does not materially change prebuilt mount. That means nodeId is not a React mount problem.nodeId blow-up was almost entirely init-time work when ids were missing.nodeId init cost back to the baseline nodeId-off path.normalizeNodeId(value) stays cheap compared with the old live init path. That proved the expensive part was not traversal itself; it was the runtime editor-operation path in tf.nodeId.normalize().setNodes(...) consumed almost the entire old init budget, while editor.api.node(...) was basically noise.editor.children.1,000: 1.10 ms5,000: 5.29 ms10,000: 10.83 msnormalizeInitialValue: null is now a true disable path in practice, not just in the comment.setNodes(...) question:
5,000 paragraphs:
setNodes per path: 73.35 msapply(set_node): 44.62 msmodifyDescendant: 37.01 ms5,000 paragraphs under 100 parent sections:
setNodes per path: 22.09 msapply(set_node): 9.11 msmodifyDescendant: 2.38 ms10,000 paragraphs:
setNodes per path: 241.36 msapply(set_node): 147.71 msmodifyDescendant: 140.11 ms10,000 paragraphs:
setNodes per path: 66.19 msapply(set_node): 22.16 msmodifyDescendant: 7.25 msapply breakdown:
transformMs dominatesnormalizeMs, dirty-path work, and ref transforms stay tiny0.35-0.61 mssetNodes(...) should not default to a normalization investigation; benchmark flat vs grouped shapes firstzustand-x / jotai-x suspicion more cleanly:
1,000 null-rendering subscribers barely moves mount timeusePluginOption is the heaviest single-hook update lane here and the only one that consistently causes two commits on its ownuseEditorValue scales worse than useEditorState once subscriber count gets largeuseEditorSelector is more expensive than plain tracked editor reads, but it still does not explain the mount cliff by itselfjotai-x suspicion:
0.2 msPlateSlate plus useSlateProps stays under 1 msuseEditableProps by itself also stays under 1 msnodeId cliff is now narrower than "useEditableProps":
Editable with direct static props lands essentially on top of Slate-level prebuilt mountEditable, so the first lazy-fallback theory is deadpipeRenderLeaf + pipeRenderText add a smaller but still meaningful second chunk1,000 and 5,000useNodePath.ElementProvider was a large part of the paragraph-plugin cliffgetRenderNodeProps is the other large partElementProvider, the normal pipeRenderElement lane collapses toward the no-provider getRenderNodeProps lanepluginRenderElement lane stays slow, which means that older provider-heavy path is still present and measurablePlateElement itself:
PlateElement still carried a real isolated mount costuseEditorMounted() subscription on every node, even when nodes had no idsPlateElement is no longer the main remaining target eitherpluginRenderElement was still paying for a needless useElement()
read inside ElementContent51 ms at 5,000PlateContent effect stack. It is the mount-time work inside the plain paragraph plugin render path itself.pipeDecorate is effectively irrelevant because it is undefined. The data does not support spending the next fix round there first.PlateContent itself is not the villain. Its extra effects and wrappers add basically noise on top of minimal editable mount.5,000-block stack is much tighter:
Editable: 255.22 msEditable + element pipe: 285.86 msEditable + leaf/text pipes: 248.76 msEditable + render pipes: 309.80 ms301.21 msPlateContent: 298.19 msElementProvider itself in richer plugin paths, which is still a large
chunk of the direct pluginRenderElement lanegetRenderNodeProps(...) work in richer non-plain plugin
paths once the needless hook read is goneElementProvider gap:
362.83 ms vs 255.18 ms406.54 ms vs 335.54 ms354.60 ms) and made richer lanes worse:
444.65 msuseElement: 437.32 msMap lookup” theoryElementProvider tax is dominated by per-node Jotai store
creation, hydration, and subscription work, not scope lookupdocs/plans/editor-perf-5000-element-store-experiment.jsonjotai-x glue” question:
5,000 blocks:
244.15 ms303.59 msElementProvider: 343.04 ms5,000 blocks:
326.01 ms366.67 msElementProvider: 408.47 ms+59.44 ms over plain contextjotai-x glue: +39.45 ms+40.66 ms over plain contextjotai-x glue: +41.80 msjotai-x still adds a real second tax, but it is not the whole storyHydrateAtoms or
useSyncStore rewrites unless the provider count problem is already
reduced elsewheredocs/plans/editor-perf-5000-jotai-provider-split.jsonzustand-x instead” question:
5,000 blocks:
254.92 ms308.17 mszustand-x store: 310.81 msElementProvider: 351.46 ms5,000 blocks:
349.40 ms372.01 mszustand-x store: 400.08 msElementProvider: 412.93 mszustand-x is basically tied with raw Jotai in the provider-only lanezustand-x is clearly worse in the richer lane by about 28 msjotai-x remains a measurable extra tax on top of raw Jotai, but a
Jotai-to-Zustand swap is not the first move worth makingdocs/plans/editor-perf-5000-store-tech-split.jsonjotai-x fix: replace the cloned-Map scope
registry in createAtomProvider with a linked scope chain
jotai-x glue over raw Jotai got worse:
+39.45 ms+60.90 ms+41.80 ms+36.56 msMap cloning may look suspicious, but it is not the whole jotai-x
storydocs/plans/editor-perf-5000-jotaix-linked-scope-experiment.jsonjotai-x fix went after the smaller helper tax that the provider
split had already exposed:
HydrateAtoms still hydrates { ...initialValues, ...props } during renderuseSyncStore now batches sync writes through one writer atom instead of
paying one useSetAtom hook and one useEffect per atomHydrateAtoms also tells useSyncStore to skip the redundant first
controlled sync when hydration already wrote the same prop values5,000 blocks on a fresh dev server:
305.17 ms -> 298.88 ms316.34 ms -> 319.73 ms328.60 ms -> 314.22 msElementProvider: 346.86 ms -> 338.06 ms369.92 ms -> 364.54 ms374.37 ms -> 373.64 ms383.54 ms -> 380.85 msElementProvider: 407.14 ms -> 404.70 msdocs/plans/editor-perf-5000-jotaix-sync-batch.jsonjotai-x fix was uglier because it was so stupid:
createAtomProvider(...) was calling createStore() eagerly inside
useState(...)const [storeState, setStoreState] = React.useState<JotaiStore>(() =>
createStore(),
);
http://localhost:3001/dev/editor-perf showed:
pluginRenderElement lane:
484.80 ms -> 453.39 ms-31.41 ms (-6.48%).tmp/editor-perf-5000-plugin-render-element-lazy-store-seq.jsondocs/plans/editor-perf-5000-store-tech-split.json:
351.46 ms353.56 ms.tmp/editor-perf-5000-element-provider-lazy-store-seq.jsonjotai-xuseElement() and usePath()?5,000-block blockquote cases:
editable-element-plugin-precomputed-no-element-hookeditable-element-plugin-render-node-hookseditable-element-plugin-render-node-hooks-plain-contexteditable-element-plugin-render-node-hooks-jotai-providerjotai-x hydration variants434.65 ms485.81 ms317.58 ms367.46 ms384.95 ms402.25 msdocs/plans/editor-perf-5000-hook-consumer-context-summary.jsonuseElement() / usePath() hot path
riding through the per-node atom storeElementProvider, useElementStore(), elementStore, and
useElementSelector() intact for compatibilityElementProvideruseElement() and usePath() read that context first instead of
always going through useAtomStoreValue(...)472.92 ms490.41 ms442.69 msdocs/plans/editor-perf-5000-hook-consumer-context-after.json51.16 ms17.49 msuseElementSelector(...) instead of useElement() / usePath()?5,000-block blockquote lanes for:
entry = nullElementProvider was still relying on the generic
createAtomProvider(...) hydration path for element, entry, and
path, even though it already owned those live propsElementProvider own that
path directly:
ElementProvider, elementStore, useElementStore(), and
useElementSelector() intact.tmp/editable-element-plugin-render-node-selector-5000-blockquote-before.json
459.72 ms.tmp/editable-element-plugin-render-node-selector-5000-blockquote-after.json
469.84 msuseElementSelector(...) stopped building an extra
selectAtom(...) + useStoreAtomValue(...) layeruseEntryValue(...) directly on the resolved store.tmp/editable-element-plugin-render-node-selector-5000-blockquote-after-direct-entry.json
449.81 ms.tmp/editable-element-plugin-render-node-selector-plain-context-5000-blockquote-after.json
326.85 ms.tmp/editable-element-plugin-render-node-selector-jotai-provider-5000-blockquote-after.json
383.86 ms459.72 ms -> 449.81 ms+122.96 ms over plain context+65.95 ms over raw JotaiuseElement() / usePath() reads anymoreElementProvider was still wrapping every node in a redundant
ElementStoreProvider layer even though it already owned the per-node
store directly.tmp/editor-perf-5000-selector-provider-after-provider-cut.json
384.33 ms449.81 ms -> 384.33 ms384.33 ms vs 383.86 msuseElementSelector(...) with
a custom per-provider entry subscription.tmp/editor-perf-5000-selector-provider-after-entry-store-cut.json
397.81 msElementProviderElementProvider stopped creating a Jotai store on the default hot pathelement, entry, and
pathuseElementSelector(...) subscribes to that runtime store directlyuseElementStore() still works through a lazy compatibility bridge that
only materializes a Jotai store when a caller asks for it.tmp/editor-perf-5000-selector-provider-after-runtime-store.json
385.05 ms.tmp/editor-perf-5000-element-provider-only-after-runtime-store.json
317.90 ms384.33 ms -> 385.05 ms368.10 ms -> 317.90 msrender.as
fast path was real or just benchmark theater:
5,000-block chunked lanes on a blockquote-only
document with BasicBlocksPlugin loaded:
render.as provider-backed shapepipeRenderElement(...) lane on a basic-plugin editorEditable + render.as, provider: 418.07 msEditable + render.as, no provider: 281.89 msEditable + element pipe (render.as): 289.28 ms136.19 ms more than the
providerless lower bound at 5,000 blocks7.39 ms above the lower bound, so
the fast path is doing the intended work in practicerender.as-only surfaces.tmp/editor-perf-5000-render-as-provider.json.tmp/editor-perf-5000-render-as-no-provider.json.tmp/editor-perf-5000-render-as-pipe.jsondocs/plans/editor-perf-5000-render-as-summary.jsonrender.as-only mark tax actually lives:
5,000-block chunked lanes on a bold-only
document:
PlateLeaf onlygetRenderNodeProps(...) + PlateLeafpipeRenderLeaf(...) only with the full basic mark stackpipeRenderLeaf(...) only with BoldPlugin alonepipeRenderText(...) onlyEditable + bold direct renderers: 231.64 msEditable + bold PlateLeaf direct: 266.93 msEditable + bold leaf node props: 301.58 msEditable + bold leaf pipe: 381.96 msEditable + bold leaf pipe (bold only): 366.73 msEditable + bold text pipe: 246.96 msEditable + bold leaf/text pipes: 375.66 mspipeRenderText(...) lands
close to the direct lower boundPlateLeaf and getRenderNodeProps(...) both cost real time, but they
are not the whole wallpipeRenderLeaf(...) / pluginRenderLeaf(...) path itself.tmp/editor-perf-5000-bold-direct-renderers.json.tmp/editor-perf-5000-bold-plugin-leaf-direct.json.tmp/editor-perf-5000-bold-plateleaf-direct.json.tmp/editor-perf-5000-bold-leaf-node-props.json.tmp/editor-perf-5000-bold-leaf-pipe.json.tmp/editor-perf-5000-bold-leaf-pipe-bold-only.json.tmp/editor-perf-5000-bold-leaf-pipe-plain-outer.json.tmp/editor-perf-5000-bold-text-pipe.json.tmp/editor-perf-5000-bold-pipe.jsondocs/plans/editor-perf-5000-bold-summary.jsonpipeRenderLeaf(...), not the inner active bold plugin
path:
5,000-block bold-only lanes:
pluginRenderLeaf(bold) with plain text renderingpipeRenderLeaf(...) with a plain outer <span> instead of the default
outer getRenderNodeProps(...) + PlateLeafEditable + bold plugin leaf direct: 305.87 msEditable + bold leaf pipe (plain outer): 321.65 msEditable + bold leaf pipe (bold only): 371.89 ms15.78 ms50.24 ms, which is where the
real remaining waste livedrenderLeafProp, no inject-node-props work, and no
leafProps, return the plain outer <span> fallback instead of the
outer getRenderNodeProps(...) + PlateLeaf pathEditable + bold leaf pipe (bold only): 371.89 ms -> 331.42 msEditable + bold leaf/text pipes: 375.66 ms -> 331.14 ms40-45 ms from the real bold-mark lanes at 5,000
blocks9.77 ms above
the plain-outer lower bound, so the wrapper theater is mostly gone.tmp/editor-perf-5000-bold-plugin-leaf-direct.json.tmp/editor-perf-5000-bold-leaf-pipe-plain-outer.jsondocs/plans/editor-perf-5000-bold-leaf-wrapper-summary.jsonpipeRenderText(...) still always paid the outer PlateText path even
when there was no inject-node-props work, no textProps, and no custom
renderTextrenderTextProp, no inject-node-props work, and no
textProps, return the plain outer <span> fallback instead of the
outer getRenderNodeProps(...) + PlateText pathEditable + bold text pipe: 249.52 msEditable + bold leaf/text pipes: 258.22 ms14.21 ms of real tax left in the
current treeBoldPlugin rerun moved the activated delta from
+30.06 ms to +26.01 ms.tmp/editor-perf-5000-bold-text-pipe-after-text-fast-path.json.tmp/editor-perf-5000-bold-pipe-after-text-fast-path.json.tmp/editor-perf-layer1-bold-only-after-text-fast-path.jsonrender.as mark plugins like BoldPlugin were still paying
getRenderNodeProps(...) plus PlateLeaf / PlateText inside
pluginRenderLeaf(...) and pluginRenderText(...) even when the plugin
had no node props, no dangerous attributes, and no selection-affinity
behaviorrender.as element for those simple mark cases instead of routing
through getRenderNodeProps(...) and PlateLeaf / PlateTextEditable + bold plugin leaf direct: 250.65 msBoldPlugin rerun:
+6.21 ms+16.21 ms.tmp/editor-perf-5000-bold-plugin-leaf-direct-plain-inner.json.tmp/editor-perf-layer1-bold-only-plain-inner.jsonpipeRenderLeaf(...) / pipeRenderText(...)Editable + bold leaf/text pipes case improved to
267.17 msBoldPlugin rerun did not improve cleanly enough
to justify the DOM-shape change:
+10.33 ms+19.94 ms.tmp/editor-perf-5000-bold-pipe-hoisted-outer.json.tmp/editor-perf-layer1-bold-only-hoisted-outer.jsonrender.as marks:
ItalicPluginUnderlinePluginhuge-italichuge-underline5k one-off ItalicPlugin rerun:
+10.86 ms+11.04 ms5k one-off UnderlinePlugin rerun:
+22.29 ms+16.06 msBoldPluginlayer-1-core-plugins preset still hung after the heading job
on localhost:3001 in this session, so these sibling-mark numbers are
directional one-off probes, not a fresh frozen batch summary yet.tmp/editor-perf-layer1-italic-only.json.tmp/editor-perf-layer1-underline-only.jsonpipeRenderText(...), not in
underline-specific code:
render.as text plugins from the generic text-plugin pathisDecoration: false marks now render inline inside the outer text
pipe without paying a per-plugin hook/function stack5k one-off reruns:
BoldPlugin: inactive +6.19 ms, activated +15.00 msItalicPlugin: inactive -0.51 ms, activated +14.11 msUnderlinePlugin: inactive +6.49 ms, activated +17.10 ms5k Layer 1 batch on the real Plate dev server at
http://localhost:3011/dev/editor-perf:
BlockquotePlugin: inactive -19.69 ms, activated -14.49 msHeadingPlugin: inactive -4.66 ms, activated -17.33 msBoldPlugin: inactive +4.46 ms, activated +13.67 msItalicPlugin: inactive +3.17 ms, activated +15.71 msUnderlinePlugin: inactive +7.36 ms, activated +19.44 mslocalhost:3001 assumption, not a runner seamdocs/plans/editor-perf-layer1-core-plugins-summary.json.tmp/editor-perf-layer1-bold-only-after-simple-text-fast-path.json.tmp/editor-perf-layer1-italic-only-after-simple-text-fast-path.json.tmp/editor-perf-layer1-underline-only-after-simple-text-fast-path.jsonUnderlinePlugin itself, the right move was to dissect the
underline lane the same way bold had been dissected:
5k chunked huge-underline runs:
Editable + underline direct renderers: 254.70 msEditable + underline plugin leaf direct: 251.01 msEditable + underline leaf/text pipes: 267.41 mspluginRenderLeaf(...)12.71 ms above
the direct <u> lower bound and 16.40 ms above the isolated
pluginRenderLeaf(underline) lane.tmp/editor-perf-5000-underline-direct-renderers.json.tmp/editor-perf-5000-underline-plugin-leaf-direct.json.tmp/editor-perf-5000-underline-pipe.jsondocs/plans/editor-perf-5000-underline-dissection-summary.json5k Layer 1 CodePlugin rerun:
-4.29 ms+154.06 ms5k chunked huge-code dissection on the correct core-mount
benchmark:
Editable + code direct renderers: 257.29 msEditable + code plugin leaf direct: 367.82 msEditable + code leaf/text pipes: 392.68 msCodePlugin itself is the next real core seam110.54 ms above the direct
<code> lower bound24.85 mspipeRenderText(...) cleanup.tmp/editor-perf-layer1-code-only.json.tmp/editor-perf-5000-code-direct-renderers-core-mount.json.tmp/editor-perf-5000-code-plugin-leaf-direct-core-mount.json.tmp/editor-perf-5000-code-leaf-text-pipe-core-mount.jsondocs/plans/editor-perf-5000-code-dissection-summary.jsongetRenderNodeProps(...) for the simple render.as
branch:
pluginRenderLeaf(...) now has a dedicated simple hard-affinity fast
path that renders PlateLeaf directly for plain render.as markshard edge behavior while cutting the plugin-context
composition work5k chunked huge-code reruns:
Editable + code PlateLeaf direct: 337.39 msEditable + code plugin leaf direct: 334.55 msEditable + code plugin leaf direct: 367.82 msgetRenderNodeProps(...) tax is gone; the plugin path is now
effectively at the PlateLeaf floor77.26 ms above the direct <code> lower boundpluginRenderLeaf(...); it is whether the hard-edge DOM shape is worth a
deeper redesign at all.tmp/editor-perf-5000-code-plateleaf-direct-core-mount.json.tmp/editor-perf-5000-code-plugin-leaf-direct-core-mount-after-hard-affinity-fast-path.jsondocs/plans/editor-perf-5000-code-hard-affinity-fast-path-summary.json5,000-block chunked numbers before the fix:
Editable + static Editable: 266.71 msEditable + element pipe: 298.72 msEditable + leaf/text pipes: 250.30 msEditable + render pipes: 334.79 msEditable + bare plain fast path: 251.26 msEditable + fast pipe (no belowRootNodes): 298.34 ms47.46 ms between the live
element pipe and the bare lower boundBelowRootNodes was not the answerreadOnly and path work before it knew it needed those
hooksuseNodePath() when block-id or directional
affinity behavior actually needs PlateElementEditable + element pipe: 298.72 ms -> 255.89 msEditable + render pipes: 334.79 ms -> 244.56 msMinimal editable: 325.28 ms -> 242.18 msFull PlateContent: 326.59 ms -> 239.89 ms5,000 chunked:
247.65 ms246.05 msnodeId: 317.83 ms237.07 ms.tmp/editor-perf-5000-editable-element-pipe-post-fast-branch.json.tmp/editor-perf-5000-render-pipes-post-element-fast-branch.json.tmp/editor-perf-5000-minimal-editable-post-element-fast-branch.json.tmp/editor-perf-5000-plate-content-post-element-fast-branch.json.tmp/editor-perf-5000-chunk-post-element-fast-branch.jsondocs/plans/editor-perf-5000-element-fast-branch-summary.jsonnodeId split showed the remaining mount gap was the mounted
block-id gate itself:
5,000-block probes before the fix:
Editable + bench element, no block-id: 263.50 msEditable + bench element, no mounted sub: 264.64 msEditable + bench mounted block-id: 281.33 msEditable + bench element + node attrs: 285.04 msEditable + element pipe, seeded: 284.40 msuseEditorMounted() subscription3.08 ms of the real seeded
production lane, which pinned the remaining nodeId mount tax on that
one hookdata-block-id attribute until
after mount; it was not doing meaningful editor workdata-block-id immediatelyPlateElement no longer fans out per-node subscriptions just to decide
whether to print that attributeEditable + element pipe, seeded: 284.40 ms -> 259.49 ms5,000 chunked:
254.36 ms227.80 msnodeId: 246.85 ms236.66 ms.tmp/editor-perf-5000-nodeid-mounted-block-element-seeded.json.tmp/editor-perf-5000-nodeid-element-pipe-post-mounted-gate-removal.json.tmp/editor-perf-5000-chunk-post-mounted-gate-removal.jsondocs/plans/editor-perf-5000-nodeid-mounted-gate-summary.jsonReact.Profiler when the thing you want is true mount cost.core-mount is for render-path work. init-dissection is for nodeId and initial-value work. Reusing the wrong lane by habit is how perf work turns into theater.Editable, and hook-consumed render plumbing all get blamed together.editor.children, do not default to live per-node Slate transforms. The nodeId init bug was exactly that mistake..tmp/slate-v2 with both flat and grouped document shapes before blaming React or store glue. Shape-sensitive collapse is a strong sign that immutable ancestor-array cloning is the real cost.Editable path again. Static Editable, element-only pipe, leaf/text-only pipes, and full render-pipe stack together are enough to tell whether the first fix should land in element wrappers or leaf/text wrappers.10,000 blocks cannot finish the full suite inside 180000 ms, that is already a scaling result.--fanout-subscribers or it would be reusable in theory and annoying in practice.--core-mount-case turned
core-mount from a periodic snapshot tool into an actual fix loop.leafTextBenchmarkMode bug would have looked
like a flaky perf harness if the page error had not been checked directly.pluginRenderElement slice showed that one
pointless useElement() read was worth about 51 ms on its own.jotai-x. That split tells you whether the main wall is your glue or
the underlying “one provider/store per node” architecture.jotai-x benchmark only became trustworthy once it stopped picking
up a stray package-local React 19 graph from
packages/jotai-x/node_modules.selectAtom(...) wrapper for it. The dedicated jotai-x benchmark proved
that plain value reads deserve a direct useAtomValue(...) fast path.selectAtom(...) derived atom every render anyway, it is still wasting work.
Memoize the derived atom too or stop pretending the selector is memoized.5,000-block lane showed
zustand-x was basically tied with raw Jotai in provider-only work and worse
in the richer path, so store swapping is not the first-order fix.jotai-x change only improves one lane while making the simpler lane
worse, revert it. The linked-scope registry experiment looked elegant and did
not earn a stable benchmark win.jotai-x tax all by itself.render.as, benchmark that seam separately before
assuming the remaining rich-plugin tax still applies. The blockquote split
showed the real pipeRenderElement(...) lane can collapse almost all the way
to the providerless lower bound once the provider-heavy branch is skipped.useReadOnly() and useNodePath() moved
behind the slower branch that actually needed them.nodeId split
showed that one mounted-store subscription can still cost about 25 ms at
5,000 blocks for no meaningful runtime value.pluginRenderElement provider/context cost after
the path-gating cutgetRenderNodeProps(...) work in richer
plugin paths that really do have node.props, allowed attrs, or injected
node propsuseEditableProps memo or handler wiring costtf.nodeId.normalize() only if a real non-init hot path appearsuseElement, useElementSelector) only after the main mount tree is no longer a black boxdocs/plans/editor-perf-1000-chunk.jsondocs/plans/editor-perf-5000-chunk.jsondocs/plans/editor-perf-5000-nochunk.jsondocs/plans/editor-perf-1000-dissection.jsondocs/plans/editor-perf-5000-dissection.jsondocs/plans/editor-perf-10000-init-dissection.jsondocs/plans/editor-perf-5000-fanout-250.jsondocs/plans/editor-perf-5000-fanout-1000.jsondocs/plans/editor-perf-1000-core-mount.jsondocs/plans/editor-perf-5000-core-mount.jsondocs/plans/editor-perf-5000-core-mount-targeted-summary.json.tmp/editor-perf-5000-plugin-render-element-plugin-context.json.tmp/editor-perf-5000-plugin-render-element-precomputed-wrappers.jsondocs/plans/editor-perf-5000-jotai-provider-split.jsondocs/plans/editor-perf-5000-store-tech-split.jsondocs/plans/editor-perf-5000-jotaix-linked-scope-experiment.jsondocs/plans/editor-perf-5000-element-fast-branch-summary.json/Users/zbeyens/git/slate-v2/packages/slate/test/perf/set-nodes-bench.js