import { describe, expect, it } from "vitest"; import { BlockNoteEditor } from "./BlockNoteEditor.js"; /** * @vitest-environment jsdom */ /** * 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")); 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, 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); }); });