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 ms

The 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:

  • apply validates by block (above).
  • applyOps keeps 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.
  • project reuses a block's projection while its entry is unchanged.
  • translate builds 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 ms

What 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.