Skip to content

perf(versioning): show large version diffs without step-quadratic work - #3176

Draft
YousefED wants to merge 12 commits into
feat/versioning-sidebar-ux-bfrom
perf/version-diff-render
Draft

YousefED wants to merge 12 commits into
feat/versioning-sidebar-ux-bfrom
perf/version-diff-render

Conversation

@YousefED

@YousefED YousefED commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This PR does two things:

  • Bug fix (not about performance): when a version diff showed a re-created block (a type change or a move), UniqueID gave the copy a new id. editor.document then showed an id that is in neither version. See change 1.
  • Performance: showing a version diff of a large document was slow. With 2000 blocks of which 1000 were re-created (for example, indented), it took about 10 seconds. This PR removes the costs that grow faster than the document size.

It is a separate PR on top of #3090, so that it can be reviewed separately.

Why it was slow

The y-prosemirror binding renders the diff as one transaction with about 6 steps for each re-created block (12,000 steps for the example above). Several plugins process each step in a way that is quadratic in the number of steps:

Cost Time (2000 blocks) Grows with
Tiptap's getChangedRanges, called by UniqueID, autolink and AttributionExtension 3.0 s steps²
combineTransactionSteps and mapping.invert() for each node in UniqueID 1.9 s steps², steps × blocks
Applying the 12,000 steps 1.5 s steps × blocks
Rendering the node views 1.6 s blocks
getNodeId: a document walk for each deleted block 1.1 s deleted blocks × blocks

Changes

  1. UniqueID: keep the id of a re-created block. A deleted block and its re-created copy have the same id. The duplicate check counted the deleted block, so it gave the copy a new id when both were new in the same transaction (for example, when a version diff is shown). editor.document then showed an id that is in neither version. The deleted block is now ignored in the duplicate check too, as the existing comment on isMarkedDeleted says. Nick's test that expected the old behavior is updated.
  2. getNodeId: one walk for each document. The ids of all deleted blocks are calculated in one walk and stored for that document. Before, each deleted block walked the document from the start.
  3. getChangedRanges: same result, without the quadratic cost. A new api/getChangedRanges.ts replaces Tiptap's function in UniqueID and autolink. It gives the same ranges as Tiptap's function. It skips runs of steps that cannot move a position, or that only shift it, and the simplify step compares only ranges that can contain each other.
  4. No replay of a single transaction. Tiptap's combineTransactionSteps applied all steps again to a new transform, also when there was only one transaction. A wrapper (api/combineTransactionSteps.ts) now returns that transaction as it is. UniqueID, autolink and getBlocksChangedByTransaction use it. UniqueID also mapped every new node back through all steps. It now does this only for nodes with a duplicated id.
  5. AttributionExtension uses one range. It merged the list of ranges into one range anyway. It now uses getChangedRangeWithAttrs (renamed from getChangedRange), which also covers node-mark steps, so its special case for AddNodeMarkStep is removed.
  6. A test for the binding. versionDiffPerformance.test.ts checks that showing a version uses one step. It is it.fails, because the y-prosemirror binding renders the diff as one step per change.

Results

Time to show a version (jsdom). Half of the blocks are indented, so they are re-created.

500 blocks 2000 blocks
#3090 0.92 s 9.65 s
This PR 0.51 s 2.53 s

With this PR, the plugins take about 0.25 s of the 2.5 s (before: 6.5 s). Most of the remaining time is node-view rendering, which grows linearly.

The same change, when it comes from a collaborator into an open editor, is one step. So it was not slow before (0.5 s for 2000 blocks), and this PR does not change it.

Small transforms (regular editing)

getChangedRanges alone, with steps that insert text. Time per call for Tiptap's function and for the new one:

Steps adjacent document order reverse order random
1 0.9 / 0.8 µs 0.5 / 0.3 µs 0.5 / 0.3 µs 0.5 / 0.3 µs
3 1.8 / 1.5 µs 1.8 / 1.0 µs 1.7 / 1.1 µs 1.9 / 1.3 µs
10 7.7 / 6.3 µs 7.4 / 5.1 µs 8.5 / 6.4 µs 8.2 / 6.1 µs
100 356 / 209 µs 319 / 113 µs 384 / 129 µs 381 / 244 µs
1000 32 / 15 ms 32 / 1.6 ms 33 / 1.7 ms 41 / 23 ms

The new function is faster at every size, so regular editing gets no overhead. It checks each step map directly, and uses segment trees (built only when needed) to skip a long run of maps.

How the getChangedRanges change was tested

  • Characterization tests: 22 cases of Tiptap's function (inserts, deletes, marks, attribute steps, wrap and lift, contained and duplicate ranges). Both implementations run the same cases.
  • Random test: 3000 random transforms with up to 200 steps. They include inserts, deletes (also across blocks), marks, attributes, wrap, lift, split, join, setBlockType and node marks. The test checks that all 8 step types occur (ReplaceStep, ReplaceAroundStep, AddMarkStep, RemoveMarkStep, AttrStep, DocAttrStep, AddNodeMarkStep, RemoveNodeMarkStep). The new function must give exactly the same result as Tiptap's.
  • Both mapping paths: the tests also pass when every skip uses a tree, and when no skip uses one.
  • Specific cases: 300 changes in document order and in reverse order.
  • Mirrored mapping: it falls back to Tiptap's function, and the test checks that the result is the same.
  • Mutation check (not in the PR): I changed the implementation in 18 ways, one at a time. The tests found 15 of them (2 as endless loops). The other 3 did not change the result. For 2 of them, I removed the code that they showed to be unnecessary: a sort tie-break, and a check of the old ranges (the old ranges are the new ranges mapped back in order, so a contained new range always has a contained old range). The third is a conservative bound that only matters for step maps with unsorted ranges, which ProseMirror does not make. I kept it.

Not in this PR

  • UniqueID could also use one range for all changes (getChangedRangeWithAttrs). That would be simpler and faster, and might be a better solution. But it can change which ids are rewritten, so it needs careful investigation first. There is a TODO at the call site.
  • getChangedRanges could be deprecated, because a list of ranges is costly to compute exactly. Its doc comment says so. Its remaining callers (UniqueID and autolink) might work from one range, and autolink perhaps from input events (space, Enter, paste). For both, what that changes needs investigation first.

@vercel

vercel Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
blocknote Ready Ready Preview Oct 9, 2026 11:58am UTC
blocknote-website Ready Ready Preview Oct 9, 2026 11:58am UTC

Request Review

@coderabbitai

coderabbitai Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@blocknote/ariakit

npm i https://pkg.pr.new/@blocknote/ariakit@3176

@blocknote/code-block

npm i https://pkg.pr.new/@blocknote/code-block@3176

@blocknote/core

npm i https://pkg.pr.new/@blocknote/core@3176

@blocknote/diagram-block

npm i https://pkg.pr.new/@blocknote/diagram-block@3176

@blocknote/mantine

npm i https://pkg.pr.new/@blocknote/mantine@3176

@blocknote/math-block

npm i https://pkg.pr.new/@blocknote/math-block@3176

@blocknote/react

npm i https://pkg.pr.new/@blocknote/react@3176

@blocknote/server-util

npm i https://pkg.pr.new/@blocknote/server-util@3176

@blocknote/shadcn

npm i https://pkg.pr.new/@blocknote/shadcn@3176

@blocknote/xl-ai

npm i https://pkg.pr.new/@blocknote/xl-ai@3176

@blocknote/xl-docx-exporter

npm i https://pkg.pr.new/@blocknote/xl-docx-exporter@3176

@blocknote/xl-email-exporter

npm i https://pkg.pr.new/@blocknote/xl-email-exporter@3176

@blocknote/xl-multi-column

npm i https://pkg.pr.new/@blocknote/xl-multi-column@3176

@blocknote/xl-odt-exporter

npm i https://pkg.pr.new/@blocknote/xl-odt-exporter@3176

@blocknote/xl-pdf-exporter

npm i https://pkg.pr.new/@blocknote/xl-pdf-exporter@3176

@blocknote/xl-typst-exporter

npm i https://pkg.pr.new/@blocknote/xl-typst-exporter@3176

commit: 22838e3

@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://TypeCellOS.github.io/BlockNote/pr-preview/pr-3176/

Built to branch gh-pages at 2026-10-09 12:07 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

A re-created block (a type change or a move) is shown as the deleted
original next to its copy, with the same id. UniqueID ignored the deleted
block when it rewrote ids, but still counted it as a duplicate. So when
both were new in one transaction, as when a version diff is shown, the copy
got a new id, which is in neither version.
getNodeId walked the document from the start for each block marked as
deleted, and callers look up every block. In a version diff with many
re-created blocks, this was quadratic. The ids of all deleted blocks are
now found in one walk and kept for that document.
…attributions

A version diff of 2000 blocks is one transaction with 12,000 steps.
Tiptap's getChangedRanges maps each range through every later step and
back, and compares every range with every other range. A new
getChangedRanges gives the same result: it skips steps that cannot move a
position, or only shift it, and compares only ranges that can contain each
other.

UniqueID and autolink also replayed all steps of a single transaction
into a new transform, and UniqueID mapped every new node back through all
steps. They now use the transaction itself, and UniqueID maps only nodes
with a duplicated id.
…nsforms

The first version queried a segment tree for each step map, so it was
slower than Tiptap's for 5 to 50 steps. It now checks each map directly,
and uses the trees (built only when needed) to skip a long run of maps
of one kind. It is now faster for 1 step and up, in all measured step
orders.
UniqueID and autolink each had their own check for a single
transaction. A wrapper now does it for all three callers, which also
stops getBlocksChangedByTransaction from replaying the steps of a single
transaction.
…hangedRanges

The random test now also makes split, join, setBlockType, RemoveNodeMarkStep
and deletes across blocks, and checks that all 8 step types occur.
The name now says how it differs from ProseMirror's changedRange() and
from getChangedRanges: it also covers attribute-only steps.
AttributionExtension merged the list from getChangedRanges into one
range. getChangedRangeWithAttrs gives that range directly, and it also
covers node-mark steps, so the special case for AddNodeMarkStep is gone.
@YousefED
YousefED force-pushed the perf/version-diff-render branch from f250967 to 22838e3 Compare October 9, 2026 11:56

This branch was successfully deployed

2 active deployments
Preview – blocknote-website — 22838e30 Deployed Oct 9, 2026 by vercel[bot]
Preview – blocknote — 22838e30 Deployed Oct 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant