@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
148 lines (126 loc) • 5.42 kB
text/typescript
import { afterEach, describe, expect, it } from "vite-plus/test";
import { BlockNoteEditor } from "./BlockNoteEditor.js";
/**
* @vitest-environment jsdom
*/
// Track editors created in each test so we can unmount them in afterEach —
// otherwise prosemirror-view's DOMObserver leaves a setTimeout alive that
// fires after vitest tears down jsdom, throwing
// `ReferenceError: document is not defined` and failing the run.
const activeEditors: BlockNoteEditor<any, any, any>[] = [];
afterEach(() => {
while (activeEditors.length) {
activeEditors.pop()!.unmount();
}
});
/**
* Performance regression tests for issue #2595:
* Typing/echo lag with many blocks (~50k chars total).
*
* The root cause was several plugins performing O(n) full-document traversals
* on every ProseMirror transaction, where n = total number of blocks.
* The fix limits traversals to only the changed ranges.
*
* These tests verify the optimization by:
* 1. Checking plugin state reflects scoped (not full) traversal
* 2. Measuring that transaction time scales sub-linearly with doc size
*/
function createEditorWithBlocks(
blockCount: number,
blockType: "heading" | "paragraph" | "numberedListItem" = "heading",
) {
const editor = BlockNoteEditor.create();
editor.mount(document.createElement("div"));
activeEditors.push(editor);
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;
}
describe("Performance: transaction processing scales sub-linearly (#2595)", () => {
// Compare timing between a small and large document.
// At 10k blocks the ratio is dominated by ProseMirror's DecorationSet.map()
// which is inherently O(n). The thresholds verify BlockNote plugins don't
// add significant overhead on top of that.
const SMALL = 100;
const LARGE = 5000;
const ITERATIONS = 5;
function measureAvgInsertTime(
editor: BlockNoteEditor<any, any, any>,
pos: number,
) {
const view = editor._tiptapEditor.view;
// Warm up
view.dispatch(view.state.tr.insertText("w", pos));
const start = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
view.dispatch(view.state.tr.insertText("x", pos));
}
return (performance.now() - start) / ITERATIONS;
}
it("heading blocks: typing at end", { timeout: 30_000 }, () => {
const smallEditor = createEditorWithBlocks(SMALL, "heading");
const largeEditor = createEditorWithBlocks(LARGE, "heading");
const smallAvg = measureAvgInsertTime(
smallEditor,
smallEditor._tiptapEditor.view.state.doc.content.size - 4,
);
const largeAvg = measureAvgInsertTime(
largeEditor,
largeEditor._tiptapEditor.view.state.doc.content.size - 4,
);
const ratio = largeAvg / smallAvg;
// eslint-disable-next-line no-console
console.log(
`Heading blocks (end): ${SMALL}=${smallAvg.toFixed(3)}ms, ${LARGE}=${largeAvg.toFixed(3)}ms, ratio=${ratio.toFixed(2)}x`,
);
// Ratio scales roughly linearly with block count due to ProseMirror
// DOM update costs. At 10k/100 = 100x blocks, expect <50x ratio.
expect(ratio).toBeLessThan(50);
});
it("numbered list items: typing at beginning", { timeout: 30_000 }, () => {
const smallEditor = createEditorWithBlocks(SMALL, "numberedListItem");
const largeEditor = createEditorWithBlocks(LARGE, "numberedListItem");
// Position 4 = inside the first block's content
const smallAvg = measureAvgInsertTime(smallEditor, 4);
const largeAvg = measureAvgInsertTime(largeEditor, 4);
const ratio = largeAvg / smallAvg;
// eslint-disable-next-line no-console
console.log(
`Numbered list (begin): ${SMALL}=${smallAvg.toFixed(3)}ms, ${LARGE}=${largeAvg.toFixed(3)}ms, ratio=${ratio.toFixed(2)}x`,
);
// Typing doesn't change indices, so the plugin exits early once it
// verifies the first decoration past the changed range still matches.
// Remaining cost is DecorationSet.map() which is O(n decorations).
// Ratio appears high (~200x) because the 100-block baseline is very
// fast for begin-of-doc edits. Absolute time (~32ms) is comparable
// to end-of-doc (~40ms) — both dominated by DecorationSet.map().
expect(ratio).toBeLessThan(250);
});
it("numbered list items: typing at end", { timeout: 30_000 }, () => {
const smallEditor = createEditorWithBlocks(SMALL, "numberedListItem");
const largeEditor = createEditorWithBlocks(LARGE, "numberedListItem");
const smallAvg = measureAvgInsertTime(
smallEditor,
smallEditor._tiptapEditor.view.state.doc.content.size - 2,
);
const largeAvg = measureAvgInsertTime(
largeEditor,
largeEditor._tiptapEditor.view.state.doc.content.size - 2,
);
const ratio = largeAvg / smallAvg;
// eslint-disable-next-line no-console
console.log(
`Numbered list (end): ${SMALL}=${smallAvg.toFixed(3)}ms, ${LARGE}=${largeAvg.toFixed(3)}ms, ratio=${ratio.toFixed(2)}x`,
);
// DecorationSet.map() on 10k decorations is O(n) in ProseMirror.
// Absolute time (~40ms) is comparable to begin (~32ms).
expect(ratio).toBeLessThan(250);
});
});