Rich text / foldkit-richtext
Performance
pnpm bench measures the shapes an editor meets (bench/operations.bench.ts).
A transaction accumulates changes per block and copies each affected container
once, so N edits in one paragraph are O(N) rather than N copies of the same
array. Measured back to back on the same machine (mean), old per-operation
copying against the current accumulation:
before after
paste 50k characters into one run 0.021 0.023 ms
type one character into a 20k-character run 0.024 0.025 ms
toggle a mark over a 400-run selection 2.07 1.80 ms
toggle a mark over a 2000-run selection 16.99 9.59 ms
toggle a mark over 200 runs / 200 blocks 1.47 1.48 ms
delete a range spanning 100 paragraphs 3.06 3.27 ms
split a block inside a 400-run paragraph 1.14 1.35 ms
paste one paragraph into a 200-block document 1.13 1.22 msThe large formatting case is the one the change targets: it halves, and it now scales linearly (5× the runs costs 5.3× the time, where it used to cost 8.2×). Differences under a few percent in the other rows are within this machine's run-to-run noise, not a claim either way.
apply validates its input once per block, not once per call. Blocks and
documents are immutable and an edit copies only the blocks it touches, so a
block or document that has passed validation is remembered, and the document's
index is built once per document. Typing into a 4,000-paragraph document
spent about 10 ms of each apply decoding the whole document; it now validates
the one block the last edit replaced. The input is still refused exactly as the
EditorState schema refuses it.
The collaborative path (bench/replicated.bench.ts) types one character into
the middle of a document of n paragraphs, as examples/pages does:
RichText.run, then Replicated.translate, applyOps, project and
resolve. The means below were measured back to back on one machine, before
and after four changes:
applyvalidates by block (above).applyOpskeeps a layered index of which blocks hold each insert, instead of scanning every block to find a character or to check an id is free.projectreuses a block's projection while its entry is unchanged.translatebuilds its shadow only for the blocks the edit touches.
paragraphs before after
100 1.08 ms 0.17 ms
1,000 8.70 ms 1.91 ms
4,000 49.2 ms 9.61 msWhat remains grows with the document because each new document gets its own
indexes: apply's block and run paths, and the node index that translate and
selection checks read. project also rebuilds its run table and walks every
entry to reuse it.
Two costs remain. A structural operation still rebuilds the document index, though
a contiguous run of joins is now batched: deleting a range across B paragraphs
emits B joins but apply consumes them as one structural edit, so the block array
is copied once and the index rebuilt once instead of B times. That case measured
3.06 ms before the batching and 1.77 ms after, on this machine in one session
(where runs vary by roughly a fifth), and it stays in the benchmark as its check.
Normalization also walks the whole dirty set — including run identities, which
cannot match a block — so a transaction that touches every run of a block does
O(dirty) lookups in the merge pass. Neither is measured as a problem at these
sizes; the benchmark exists so a claim about them can be checked rather than
asserted.