Skip to content

fix(core): optimize plugin traversals for large documents BLO-1111#2600

Merged
nperez0111 merged 4 commits intomainfrom
fix/performance-improvements
Apr 1, 2026
Merged

fix(core): optimize plugin traversals for large documents BLO-1111#2600
nperez0111 merged 4 commits intomainfrom
fix/performance-improvements

Conversation

@YousefED
Copy link
Copy Markdown
Collaborator

@YousefED YousefED commented Mar 30, 2026

Summary

Fixes typing/echo lag in documents with many blocks (~50k chars) by optimizing several plugins that were performing O(n) full-document traversals on every transaction.

Closes #2595

Rationale

Several plugins (PreviousBlockType, NumberedListIndexingPlugin) traversed the entire document on every keystroke. At ~500+ blocks, this caused noticeable input lag. The fix scopes traversals to only the changed ranges.

Changes

  • PreviousBlockType plugin: Replaced findChildren (full doc) with findChildrenInRange scoped to transaction.changedRange(). Maps new-doc range back to old-doc coordinates for accurate comparison.
  • NumberedListItem IndexingPlugin:
    • Uses DecorationSet.map() + incremental updates instead of rebuilding all decorations
    • Early exit via completedGroups pattern — once a decoration matches past the changed range in a blockGroup, skip remaining siblings
    • Converted recursive calculateListItemIndex to iterative to prevent stack overflow at 10k+ blocks
    • Narrowed DecorationSet.find() range to the numberedListItem node only (not the full blockContainer subtree) to avoid false matches from nested lists

Impact

  • Typing at 10k blocks: ~30-50ms/keystroke (dominated by ProseMirror's DecorationSet.map() which is inherently O(n))
  • No behavioral changes — all existing functionality preserved
  • UniqueID getChangedRangeschangedRange() change was evaluated and not included (validated to have zero measurable impact: ~3.5μs difference)

Testing

  • IndexingPlugin.test.ts (16 tests): basic numbering, structural changes (insert/delete/type-change), nested lists, typing preservation, decoration specs, selection-only transactions
  • PreviousBlockType.test.ts (2 tests): scoped traversal verification, attribute change detection
  • performance.test.ts (3 tests): ratio benchmarks at 10k blocks for headings and numbered lists (begin + end of doc)

Checklist

  • Code follows the project's coding standards.
  • Unit tests covering the new feature have been added.
  • All existing tests pass.
  • The documentation has been updated to reflect the new feature

Summary by CodeRabbit

  • Chores

    • Updated ProseMirror Transform dependency to 1.11.0 across packages.
  • Performance

    • Numbered-list indexing and decoration updates are more efficient and avoid unnecessary recomputation.
    • Block-type tracking and transaction handling now process only affected ranges, improving large-document edit performance.
  • Bug Fixes

    • Selection-only edits and simple typing no longer trigger needless reindexing.
  • Tests

    • New regression and performance tests covering indexing, range-limited updates, and scaling.

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 30, 2026

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

Project Deployment Actions Updated (UTC)
blocknote Ready Ready Preview Apr 1, 2026 3:14pm
blocknote-website Ready Ready Preview Apr 1, 2026 3:14pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 30, 2026

📝 Walkthrough

Walkthrough

Narrowed plugin traversals to transaction-changed ranges, replaced recursive numbered-list indexing with an iterative cached algorithm, bumped several prosemirror-transform versions, and added unit and performance tests validating indexing, previous-block tracking, and transaction performance.

Changes

Cohort / File(s) Summary
Dependency updates
packages/core/package.json, packages/xl-ai/package.json, packages/xl-multi-column/package.json
Bumped prosemirror-transform ^1.10.5 → ^1.11.0; reordered @tiptap/extensions entry in core package (no version change).
Numbered list indexing (logic)
packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts
Replaced recursive index calc with iterative backward/forward scan and cache fast-path; limit decoration recomputation to tr.changedRange(); tightened decoration lookup scope and added group-completion tracking; reuse decorations on selection-only transactions.
Numbered list tests
packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.test.ts
Added comprehensive Vitest/jsdom tests for indexing decorations across inserts, deletes, type switches, nested lists, typing transactions, and selection-only transactions.
PreviousBlockType (logic)
packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts
Switched full-document traversal to range-limited traversal using changedRange() and inverted mapping; compare specific fields (index, level, type, depth) instead of deep JSON equality; early-return when no changed range.
PreviousBlockType tests
packages/core/src/extensions/PreviousBlockType/PreviousBlockType.test.ts
Added tests ensuring scoped traversal, limited tracked blocks for distant edits, and correct single-block tracking for local updates.
Performance tests
packages/core/src/editor/performance.test.ts
New Vitest/jsdom performance suite that measures average insert transaction time across small vs. large documents (100 vs. 10,000 blocks) for headings and list items; asserts sub-linear scaling thresholds.

Sequence Diagram(s)

sequenceDiagram
  participant Editor as Editor (BlockNote)
  participant Tr as Transaction
  participant Plugin as Plugin (Indexing / PreviousBlockType)
  participant Cache as Cache / DecorationSet

  Editor->>Tr: user input / programmatic edit
  Tr->>Plugin: apply(transaction)
  Plugin->>Tr: tr.changedRange()
  alt changedRange exists
    Plugin->>Cache: read cached indices / decorations for range
    Plugin->>Plugin: backward scan predecessors (build chain)
    Plugin->>Plugin: forward assign indices -> update cache
    Plugin->>Cache: build DecorationSet for changed nodes
  else no changedRange
    Plugin->>Cache: reuse previous DecorationSet (no recompute)
  end
  Plugin->>Editor: commit decorations / plugin state
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

I hop through nodes with nimble feet, 🐇
No recursion now — the path is neat,
Caches keep count, ranges stay small,
Typing's snappy, decorations fall,
A joyful hop — performance for all!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The PR description is mostly complete with summary, rationale, changes, impact, testing, and checklist sections that align with the template. Documentation update is noted as pending, which is acceptable.
Linked Issues check ✅ Passed The PR successfully addresses issue #2595 by optimizing PreviousBlockType and NumberedListItem IndexingPlugin traversals to use changed ranges instead of full-document iteration, directly resolving the typing lag complaint.
Out of Scope Changes check ✅ Passed All changes are scoped to performance optimization for issue #2595. Dependency version updates (prosemirror-transform) and reordering of @tiptap/extensions are justified as supporting changes for the core optimizations.
Title check ✅ Passed The title accurately summarizes the main objective: optimizing plugin traversals for large documents to fix performance issues, which aligns with the core changes across PreviousBlockType and IndexingPlugin.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/performance-improvements

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 and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
packages/core/src/editor/performance.test.ts (1)

22-38: Consider cleaning up editors after each test to prevent memory leaks.

The created editors are never destroyed after use. While this may not cause issues in isolated test runs, it's good practice to clean up resources, especially when creating editors with 10k blocks.

♻️ Suggested cleanup pattern
 function createEditorWithBlocks(
   blockCount: number,
   blockType: "heading" | "paragraph" | "numberedListItem" = "heading",
 ) {
   const editor = BlockNoteEditor.create();
   editor.mount(document.createElement("div"));
   const blocks = [];
   for (let i = 0; i < blockCount; i++) {
     blocks.push({
       type: blockType as any,
       content: `Block number ${i} with some text to simulate a real document`,
       ...(blockType === "heading" ? { props: { level: 1 } } : {}),
     });
   }
   editor.replaceBlocks(editor.document, blocks as any);
   return editor;
 }

Then in tests, consider using afterEach or calling editor._tiptapEditor.destroy() when done.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/editor/performance.test.ts` around lines 22 - 38, The tests
create BlockNoteEditor instances via createEditorWithBlocks and never destroy
them, risking memory leaks for large block counts; update the test suite to
destroy the underlying tiptap editor after each test (e.g., call
editor._tiptapEditor.destroy() or similar) — either modify
createEditorWithBlocks to register created editors for cleanup or ensure each
test calls the destroy method, and add an afterEach hook to iterate tracked
editors and call _tiptapEditor.destroy() to guarantee cleanup.
packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts (1)

111-115: Consider adding a brief comment explaining the isFirst logic.

The expression chain.length === 1 ? isFirst || predecessorIndex === undefined : false is correct but dense. A short comment would aid future maintainability.

📝 Suggested comment
+  // isFirst is true only when this is the very first item in a new list:
+  // - chain.length > 1 means we found predecessor list items, so not first
+  // - otherwise, check if we started as first (no predecessor) or found one via cache
   return {
     index,
     isFirst: chain.length === 1 ? isFirst || predecessorIndex === undefined : false,
     hasStart,
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts` around
lines 111 - 115, Add a concise inline comment next to the isFirst computed field
in IndexingPlugin.ts explaining the logic: that when chain.length === 1 we
consider the item first if either the original isFirst flag is true or there is
no predecessorIndex (undefined), otherwise for longer chains isFirst is false;
place the comment adjacent to the expression `chain.length === 1 ? isFirst ||
predecessorIndex === undefined : false` so future readers understand the
special-case for single-item chains and the predecessorIndex check.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.test.ts`:
- Around line 336-340: Replace the dynamic require with a static import: add
"import { Selection } from 'prosemirror-state';" at the top of the test file and
remove the inline "const { Selection } = require('prosemirror-state');" line in
the test; keep the rest of the test logic (the Selection.near(...) call, tr =
view.state.tr.setSelection(...), and view.dispatch(tr)) unchanged so ESLint no
longer flags `@typescript-eslint/no-var-requires`.

---

Nitpick comments:
In `@packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts`:
- Around line 111-115: Add a concise inline comment next to the isFirst computed
field in IndexingPlugin.ts explaining the logic: that when chain.length === 1 we
consider the item first if either the original isFirst flag is true or there is
no predecessorIndex (undefined), otherwise for longer chains isFirst is false;
place the comment adjacent to the expression `chain.length === 1 ? isFirst ||
predecessorIndex === undefined : false` so future readers understand the
special-case for single-item chains and the predecessorIndex check.

In `@packages/core/src/editor/performance.test.ts`:
- Around line 22-38: The tests create BlockNoteEditor instances via
createEditorWithBlocks and never destroy them, risking memory leaks for large
block counts; update the test suite to destroy the underlying tiptap editor
after each test (e.g., call editor._tiptapEditor.destroy() or similar) — either
modify createEditorWithBlocks to register created editors for cleanup or ensure
each test calls the destroy method, and add an afterEach hook to iterate tracked
editors and call _tiptapEditor.destroy() to guarantee cleanup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f24cb5e9-d70c-4613-8407-d47a5bd64f40

📥 Commits

Reviewing files that changed from the base of the PR and between a850078 and beea4f3.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (8)
  • packages/core/package.json
  • packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.test.ts
  • packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts
  • packages/core/src/editor/performance.test.ts
  • packages/core/src/extensions/PreviousBlockType/PreviousBlockType.test.ts
  • packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts
  • packages/xl-ai/package.json
  • packages/xl-multi-column/package.json

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 30, 2026

Open in StackBlitz

@blocknote/ariakit

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

@blocknote/code-block

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

@blocknote/core

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

@blocknote/mantine

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

@blocknote/react

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

@blocknote/server-util

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

@blocknote/shadcn

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

@blocknote/xl-ai

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

@blocknote/xl-docx-exporter

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

@blocknote/xl-email-exporter

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

@blocknote/xl-multi-column

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

@blocknote/xl-odt-exporter

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

@blocknote/xl-pdf-exporter

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

commit: 6d2f367

@YousefED YousefED changed the title fix(core): optimize plugin traversals for large documents fix(core): optimize plugin traversals for large documents BLO-1111 Mar 30, 2026
@YousefED YousefED requested a review from nperez0111 March 30, 2026 08:47
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is heavily stateful now, I'm going to need some time to experiment to see if I can break it or not.

@nperez0111 nperez0111 merged commit 3c38d84 into main Apr 1, 2026
3 of 5 checks passed
@nperez0111 nperez0111 deleted the fix/performance-improvements branch April 1, 2026 15:08
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.

Typing/echo lag with many blocks (~50k chars total); plain long paragraphs are fine

2 participants