docs/plans/2026-05-23-slate-v2-core-operation-performance-ralplan.md
Start the next lane in packages/slate, not slate-react.
The large-document React/virtualization lane is clean enough for its current claims. The fresh red owner is core operation cost: simple text inserts and selection changes still pay too much transaction, snapshot, root index, dirty metadata, and observation overhead at document scale.
Do not hide this behind virtualization. Virtualization can reduce mounted DOM.
It cannot make tx.text.insert cheap enough if the core write path is doing
document-sized work.
Create the execution lane that makes common Slate core operations scale with the edited path and operation family, not with total document size.
Primary target:
tx.text.insert(...)tx.text.delete(...)tx.selection.set(...)tx.operations.replay(...) for batch-style issue #6038Read surfaces:
docs/plans/2026-05-23-slate-v2-large-document-performance-virtualization-ralplan.mddocs/solutions/performance-issues/2026-04-11-slate-v2-huge-document-typing-needs-selector-fanout-cuts-before-islands.mddocs/solutions/performance-issues/2026-05-01-slate-v2-text-snapshots-should-be-path-stable-for-large-document-typing.mddocs/solutions/developer-experience/2026-04-19-slate-public-single-op-writes-should-use-editor-apply-and-keep-onchange-behind-subscribers.md.tmp/slate-v2/scripts/benchmarks/core/compare/huge-document.mjs.tmp/slate-v2/scripts/benchmarks/core/current/transaction-execution.mjs.tmp/slate-v2/scripts/benchmarks/slate/6038-transaction-execution.mjs.tmp/slate-v2/packages/slate/src/core/public-state.ts.tmp/slate-v2/packages/slate/src/core/apply.ts.tmp/slate-v2/packages/slate/src/editor/insert-text.tsdocs/slate-v2/ledgers/fork-issue-dossier.mddocs/slate-v2/ledgers/issue-coverage-matrix.mddocs/slate-issues/gitcrawl-v2-sync-ledger.mddocs/slate-issues/test-candidate-map/6038-6007.mdFresh command:
cd .tmp/slate-v2
CORE_HUGE_BENCH_LEGACY_REPO=/Users/zbeyens/git/slate CORE_HUGE_BENCH_ITERATIONS=1 CORE_HUGE_BENCH_BLOCKS=1000 CORE_HUGE_BENCH_TYPE_OPS=5 bun run bench:core:huge-document:compare:local
Fresh result:
| Lane | v2 current | legacy | delta |
|---|---|---|---|
| start block type, 5 ops | 21.68ms | 0.42ms | +21.26ms |
| middle block type, 5 ops | 23.34ms | 0.18ms | +23.16ms |
| replace full document with text | 10.52ms | 10.02ms | +0.50ms |
| insert fragment full document | 8.55ms | 7.46ms | +1.09ms |
| select all | 7.18ms | 0.01ms | +7.17ms |
The larger previous lane run with 20 type ops showed the same shape:
86.95ms / 83.78ms v2 typing against 0.72ms / 0.68ms legacy, while
full-document replacement and fragment insertion were close to legacy.
That means the problem is not "all core operations are slow." The problem is the common small-write path.
These are hypotheses for Ralph to verify with profiling before editing.
runEditorTransaction(...) in
.tmp/slate-v2/packages/slate/src/core/public-state.ts builds transaction
root indexes for every root at transaction start.buildSnapshotChange(...) or
getOperationDirtiness(...) for every committed transaction.buildSnapshotChange(...) still uses JSON string comparison for marks and
selection.getSelectionImpactRuntimeIds(...) can return broad null for wide
selections and has a documented full-index scan for expanded non-broad
selections.apply(...) has a non-transaction text fast path, but benchmark text writes
use editor.update((tx) => tx.text.insert(...)), so the fast path may not
cover the primary v2 API.Editor.replace(...) is near legacy, which suggests the full snapshot replace
API is not the current bottleneck.| Issue | Current claim | This plan |
|---|---|---|
#6038 repeated tree updates need batch-aware apply engine | Improves | Preserve. This lane may strengthen the proof if it adds accepted threshold rows, but do not promote to Fixes from planning. |
#2051 leaf rerender breadth | Improves / guardrail | Preserve as React/runtime guardrail. Core dirty metadata must not widen React subscribers. |
#5945 large plaintext paste | Improves | Preserve. This lane must not regress one-logical-operation paste. |
#4056 large copy/paste | Improves | Preserve. This lane may help core commit overhead, but exact browser repro closure stays out of scope. |
#5992 huge cut cost | Improves | Preserve. 50,000-block red threshold remains its own follow-up unless core dirtiness is proven to be the owner. |
No new fixed issue claims. No claim text change in PR references from this planning pass.
The best target is a measured core operation fast lane, not a second editor engine.
Split the commit path by operation family:
selection: selection-only updates should be O(selection path depth).text: insert/remove text should be O(changed text length + path depth).path-stable batch: text plus selection over one root should reuse the
existing runtime index.structural narrow: set/split/merge/move/insert/remove node should update
only the affected path window when possible.replace: full-document replacement is allowed to be O(document).unknown mixed: fall back to current broad dirtiness when the batch cannot be
proven narrow.Ralph should aim for this shape:
editor.update as the public write boundary.The commit should carry enough data for React and history without overpaying:
null only for genuinely unknown or whole-document dirtinessDo not replace precise dirtiness with "all" to win local correctness. That would just move the latency back to React.
Operations remain the durable collaboration/history record.
The optimization may change how the current process builds snapshots and dirty metadata. It must not change:
applyOperations(...) replay behavior| Cohort | Blocks | Target |
|---|---|---|
| normal | 0-500 | Keep behavior unchanged; no extra complexity visible to users. |
| large | 1000-10000 | Text insert and selection set should scale by edited path, not block count. |
| stress | 50000 | Only structural and huge-cut rows may remain expensive; simple text should not grow linearly. |
Repeated unit: top-level block plus its text leaf.
Target budget:
Baseline and closeout:
cd .tmp/slate-v2
CORE_HUGE_BENCH_LEGACY_REPO=/Users/zbeyens/git/slate CORE_HUGE_BENCH_BLOCKS=1000 CORE_HUGE_BENCH_TYPE_OPS=20 bun run bench:core:huge-document:compare:local
CORE_HUGE_BENCH_LEGACY_REPO=/Users/zbeyens/git/slate CORE_HUGE_BENCH_BLOCKS=5000 CORE_HUGE_BENCH_TYPE_OPS=10 bun run bench:core:huge-document:compare:local
bun run bench:core:transaction:local
bun run bench:slate:6038:local
bun run bench:core:normalization:compare:local
bun run bench:core:observation:compare:local
Ralph should add or enable one core profiling row that reports time buckets for:
Without those buckets, this lane can easily "fix" the wrong thing.
Do not invent a hard release threshold in the first edit. Use this sequence:
1000 and 5000 blocks;Owner: packages/slate.
core-time:* profiling is not available in the Node benchmark.Owner: packages/slate/src/core/public-state.ts and
packages/slate/src/core/apply.ts.
editor.update((tx) => tx.text.insert(...)) eligible for the same
narrow behavior as the direct non-transaction text fast path.Owner: commit metadata.
Owner: #6038, slate-history, collab contracts.
bench:slate:6038:local around repeated exact-path updates and
mixed batches.editor.update, tx.operations.replay, and
editor.applyOperations(...), not legacy editor.apply monkeypatching.Required before marking the lane done:
cd .tmp/slate-v2
bun test ./packages/slate/test
bun test ./packages/slate-history/test
bun run bench:core:transaction:local
bun run bench:slate:6038:local
CORE_HUGE_BENCH_LEGACY_REPO=/Users/zbeyens/git/slate CORE_HUGE_BENCH_BLOCKS=1000 CORE_HUGE_BENCH_TYPE_OPS=20 bun run bench:core:huge-document:compare:local
CORE_HUGE_BENCH_LEGACY_REPO=/Users/zbeyens/git/slate CORE_HUGE_BENCH_BLOCKS=5000 CORE_HUGE_BENCH_TYPE_OPS=10 bun run bench:core:huge-document:compare:local
bun lint:fix
bun typecheck:root
bun check
Run browser proof only if the implementation changes React dirtiness, selection bridging, DOM materialization, or benchmark issue claims.
| Lens | Status | Notes |
|---|---|---|
| slate-ralplan | applied | Planning-only; implementation routed to Ralph. |
| major-task | applied | This is architecture and benchmark work, not a local patch. |
| performance | applied | Cohorts, repeated-unit budget, interaction rows, memory/dirty metadata, and threshold policy are explicit. |
| learnings-researcher | applied | Existing core snapshot, React fanout, and operation API learnings were checked before writing. |
| goal workflow | applied | This file is the durable plan artifact. |
| tdd | deferred to Ralph | Execution must add tests before changing the core fast path. |
| clawsweeper | skipped | No issue claim or PR narrative changes in this planning pass; current ledgers were read and preserved. |
| browser proof | deferred | Only required if Ralph changes browser-visible dirtiness, selection, or DOM behavior. |
Overall score: 0.87.
| Criterion | Score | Reason |
|---|---|---|
| Architecture clarity | 0.90 | Owner is narrowed to core transaction/write/publish path. |
| DX | 0.88 | Keeps editor.update / transaction DX, avoids reviving legacy apply wrapping. |
| Performance strategy | 0.90 | Fresh benchmark evidence and profiling buckets are explicit. |
| Regression safety | 0.84 | Good coverage plan, but the exact hot bucket still needs profiling before edits. |
| Issue accounting | 0.88 | Preserves current Improves claims and avoids overclaiming #6038. |
| Execution readiness | 0.85 | Commands and phases are concrete; implementation details still need Ralph profiling. |
Current pass: slate-ralplan-core-operation-performance.
Current pass status: complete.
Lane status: pending.
Next pass: ralph-core-operation-performance-execution.
Next action: run Ralph execution against this plan in .tmp/slate-v2; start
with baseline/profiling, then fix the measured core owner.
Status: complete.
Implementation owner: .tmp/slate-v2/packages/slate/src/core/public-state.ts
plus benchmark scripts.
What landed:
setChildren call.Editor.getChildren /
Editor.getSelection before Editor.getSnapshot unless the row is actually
measuring snapshots.Node and v2 NodeApi.#6038 mixed batch fixture path was corrected after its own move/split
operations shifted the target node.Fresh benchmark evidence:
| Command | Result |
|---|---|
CORE_HUGE_BENCH_LEGACY_REPO=/Users/zbeyens/git/slate CORE_HUGE_BENCH_BLOCKS=1000 CORE_HUGE_BENCH_TYPE_OPS=20 bun run bench:core:huge-document:compare:local | v2 start 2.36ms, middle 1.67ms, replace 1.93ms, fragment 1.18ms, select-all 0.62ms |
CORE_HUGE_BENCH_LEGACY_REPO=/Users/zbeyens/git/slate CORE_HUGE_BENCH_BLOCKS=5000 CORE_HUGE_BENCH_TYPE_OPS=10 bun run bench:core:huge-document:compare:local | v2 start 5.71ms, middle 4.10ms, replace 5.58ms, fragment 8.48ms, select-all 3.02ms |
bun run bench:core:transaction:local | passed; mixed batch separate update 0.25ms, replay 0.10ms |
bun run bench:slate:6038:local | passed; mixed batch separate update 0.24ms, replay 0.10ms |
NORMALIZATION_BENCH_LEGACY_REPO=/Users/zbeyens/git/slate bun run bench:core:normalization:compare:local | passed; read-after-each dropped to 3.59ms; explicit normalize rows beat legacy |
CORE_OBSERVATION_BENCH_LEGACY_REPO=/Users/zbeyens/git/slate bun run bench:core:observation:compare:local | passed; children read 2.82ms; root nodes 6.91ms vs legacy 7.03ms |
Fresh verification:
cd .tmp/slate-v2
bun test ./packages/slate/test/state-tx-public-api-contract.ts ./packages/slate/test/core-benchmark-scripts-contract.ts
bun test ./packages/slate/test/snapshot-contract.ts -t "path-stable"
bun test ./packages/slate/test
bun test ./packages/slate-history/test
bun --filter slate typecheck
bun typecheck:root
bun lint:fix
bun check
All commands passed. Browser proof was not required because this slice changed core transaction/public-state behavior and Node benchmark helpers, not React DOM materialization or browser selection bridging.
Issue accounting remains unchanged:
#6038: still Improves, with stronger local replay evidence.#2051, #5945, #4056, #5992: existing performance guardrail /
Improves claims preserved; no new Fixes claim.Compounded learning: