Skip to content

Commit df2090f

Browse files
committed
perf(core): resolve block changes from changed range only
getBlocksChangedByTransaction snapshotted the entire document (a nodeToBlock conversion of every block, twice) on every transaction. Since apps read getChanges() on each keystroke, typing lagged in large documents. It now diffs only the range the transaction touched. Extract a shared getChangedRange() helper that, unlike ProseMirror's changedRange(), also covers attribute-only steps (AttrStep) and mark steps, at the same O(steps) cost. PreviousBlockType now uses it too, fixing a latent bug where attribute-only changes (e.g. a heading's level) were silently missed by its ranged diff.
1 parent 1e26f1c commit df2090f

5 files changed

Lines changed: 202 additions & 10 deletions

File tree

packages/core/src/api/getBlocksChangedByTransaction.ts

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import type { BlockSchema } from "../schema/index.js";
1212
import type { InlineContentSchema } from "../schema/inlineContent/types.js";
1313
import type { StyleSchema } from "../schema/styles/types.js";
14+
import { getChangedRange } from "./getChangedRange.js";
1415
import { getNodeId } from "./getBlockInfoFromPos.js";
1516
import { nodeToBlock } from "./nodeConversions/nodeToBlock.js";
1617
import { isNodeBlock } from "./nodeUtil.js";
@@ -20,14 +21,29 @@ import { isNodeBlock } from "./nodeUtil.js";
2021
*
2122
* High-level algorithm used by getBlocksChangedByTransaction:
2223
* 1) Merge appended transactions into one document change.
23-
* 2) Collect a snapshot of blocks before and after (flat map by id, and per-parent child order).
24-
* 3) Emit inserts and deletes by diffing ids between snapshots.
25-
* 4) For ids present in both snapshots:
24+
* 2) Compute the single range of the document that the transaction touched
25+
* (in both the new and old document), so we only inspect blocks that could
26+
* have changed rather than walking the whole document. This matters because
27+
* getChanges() runs per transaction: a full-document snapshot made typing in
28+
* large documents slow, since every keystroke re-converted every block.
29+
* 3) Collect a snapshot of blocks before and after *within that range* (flat map
30+
* by id, and per-parent child order).
31+
* 4) Emit inserts and deletes by diffing ids between snapshots.
32+
* 5) For ids present in both snapshots:
2633
* - If parentId changed, emit a move
2734
* - Else if block changed (ignoring children), emit an update
28-
* 5) Finally, detect same-parent sibling reorders by comparing child order per parent.
35+
* 6) Finally, detect same-parent sibling reorders by comparing child order per parent.
2936
* We use an inlined O(n log n) LIS inside detectReorderedChildren to keep a
3037
* longest already-ordered subsequence and mark only the remaining items as moved.
38+
*
39+
* Why a range is sufficient: `changedRange()` returns the single contiguous span
40+
* from the first to the last changed position in the document. Any block that was
41+
* inserted, deleted, moved, updated, or whose sibling order changed has all of its
42+
* relevant positions (old and new) inside that span. Blocks entirely outside the
43+
* span are byte-for-byte identical and keep their relative order, so they cannot
44+
* produce a change. A moved block's parent always *contains* that block, so the
45+
* parent overlaps the range and is included too (and nodeToBlock converts its full
46+
* subtree regardless of the range).
3147
*/
3248
/**
3349
* Gets the parent block of a node, if it has one.
@@ -144,14 +160,22 @@ type BlockSnapshot<
144160
};
145161

146162
/**
147-
* Collects a snapshot of blocks and per-parent child order in a single traversal.
148-
* Uses "__root__" to represent the root level where parentId is undefined.
163+
* Collects a snapshot of blocks and per-parent child order in a single traversal,
164+
* limited to the block nodes that overlap the given range. Uses "__root__" to
165+
* represent the root level where parentId is undefined.
166+
*
167+
* Traversing only the changed range (instead of the whole document) is what keeps
168+
* this cheap per keystroke: nodeToBlock is only called for blocks that could have
169+
* changed, not for every block in the document.
149170
*/
150171
function collectSnapshot<
151172
BSchema extends BlockSchema,
152173
ISchema extends InlineContentSchema,
153174
SSchema extends StyleSchema,
154-
>(doc: Node): BlockSnapshot<BSchema, ISchema, SSchema> {
175+
>(
176+
doc: Node,
177+
range: { from: number; to: number },
178+
): BlockSnapshot<BSchema, ISchema, SSchema> {
155179
const ROOT_KEY = "__root__";
156180
const byId: Record<
157181
string,
@@ -161,7 +185,13 @@ function collectSnapshot<
161185
}
162186
> = {};
163187
const childrenByParent: Record<string, string[]> = {};
164-
doc.descendants((node, pos) => {
188+
// Clamp to valid document positions; mapped ranges should already be valid, but
189+
// nodesBetween throws on out-of-range positions.
190+
const from = Math.max(0, Math.min(range.from, doc.content.size));
191+
const to = Math.max(from, Math.min(range.to, doc.content.size));
192+
// nodesBetween visits every node overlapping [from, to] in document order,
193+
// including ancestor blocks that contain the range.
194+
doc.nodesBetween(from, to, (node, pos) => {
165195
if (!isNodeBlock(node)) {
166196
return true;
167197
}
@@ -282,11 +312,28 @@ export function getBlocksChangedByTransaction<
282312
...appendedTransactions,
283313
]);
284314

315+
// The range of the new document that the transaction changed. Null means the
316+
// document did not change, so there is nothing to diff.
317+
const newRange = getChangedRange(combinedTransaction);
318+
if (!newRange) {
319+
return [];
320+
}
321+
// Map that range back to old-document coordinates. The -1/+1 biases expand the
322+
// range outwards so that, for pure inserts/deletes (where the new range is a
323+
// collapsed point), the old range still covers the affected span.
324+
const invertedMapping = combinedTransaction.mapping.invert();
325+
const oldRange = {
326+
from: invertedMapping.map(newRange.from, -1),
327+
to: invertedMapping.map(newRange.to, 1),
328+
};
329+
285330
const prevSnap = collectSnapshot<BSchema, ISchema, SSchema>(
286331
combinedTransaction.before,
332+
oldRange,
287333
);
288334
const nextSnap = collectSnapshot<BSchema, ISchema, SSchema>(
289335
combinedTransaction.doc,
336+
newRange,
290337
);
291338

292339
const changes: BlocksChanged<BSchema, ISchema, SSchema> = [];
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { Transform } from "prosemirror-transform";
2+
3+
/**
4+
* Computes the single new-document range spanning everything a transform changed,
5+
* or null when nothing changed.
6+
*
7+
* This is a drop-in replacement for ProseMirror's `Transform.changedRange()` that
8+
* additionally accounts for position-preserving steps whose `StepMap` is empty:
9+
* - `AttrStep` — a single node's attributes changed (emitted by updateBlock for
10+
* prop-only edits, e.g. a heading's `level` or a numbered list item's `index`).
11+
* - `AddMarkStep` / `RemoveMarkStep` — marks changed over a range.
12+
*
13+
* `changedRange()` and tiptap's `getChangedRanges` both miss `AttrStep`: its map
14+
* contributes no ranges, and it exposes `pos` rather than `from`/`to`. Any consumer
15+
* that scopes work to the changed range (rather than walking the whole document)
16+
* would therefore silently ignore prop-only updates. This helper recovers those
17+
* positions directly from the step.
18+
*
19+
* Complexity matches `changedRange()`: O(number of steps). It advances a single
20+
* accumulated range forward through each step map once, injecting empty-map steps'
21+
* positions as it goes, rather than remapping every step's range independently.
22+
*/
23+
export function getChangedRange(
24+
transform: Transform,
25+
): { from: number; to: number } | null {
26+
const { mapping, steps } = transform;
27+
let from = Number.POSITIVE_INFINITY;
28+
let to = Number.NEGATIVE_INFINITY;
29+
30+
for (let i = 0; i < mapping.maps.length; i++) {
31+
const map = mapping.maps[i];
32+
// Advance the accumulated range into this step's coordinate space (identity
33+
// for the first step). Biases keep it from spilling into content inserted at
34+
// its edges, matching ProseMirror's own changedRange().
35+
if (i) {
36+
from = map.map(from, 1);
37+
to = map.map(to, -1);
38+
}
39+
40+
let hadRange = false;
41+
map.forEach((_oldFrom, _oldTo, newFrom, newTo) => {
42+
hadRange = true;
43+
from = Math.min(from, newFrom);
44+
to = Math.max(to, newTo);
45+
});
46+
47+
if (!hadRange) {
48+
// Empty step map: a position-preserving step, so its positions live in the
49+
// current coordinate space and later maps advance them along with the rest.
50+
const step = steps[i] as { pos?: number; from?: number; to?: number };
51+
if (typeof step.pos === "number") {
52+
// AttrStep
53+
from = Math.min(from, step.pos);
54+
to = Math.max(to, step.pos + 1);
55+
} else if (typeof step.from === "number" && typeof step.to === "number") {
56+
// AddMarkStep / RemoveMarkStep
57+
from = Math.min(from, step.from);
58+
to = Math.max(to, step.to);
59+
}
60+
// DocAttrStep (no pos/from/to) changes document-level attributes only and
61+
// affects no nodes, so it is intentionally ignored.
62+
}
63+
}
64+
65+
if (from === Number.POSITIVE_INFINITY) {
66+
return null;
67+
}
68+
return { from, to };
69+
}

packages/core/src/editor/performance.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,55 @@ describe("Performance: transaction processing scales sub-linearly (#2595)", () =
144144
// Absolute time (~40ms) is comparable to begin (~32ms).
145145
expect(ratio).toBeLessThan(250);
146146
});
147+
148+
// getChanges() is lazy: it only runs the (previously full-document) diff when
149+
// an onChange/onBeforeChange subscriber actually calls it. The other tests
150+
// never subscribe, so they don't cover getBlocksChangedByTransaction — the
151+
// path an app hits when it registers editor.onChange(...) and inspects the
152+
// changes on every keystroke. This test locks in that getChanges() only
153+
// inspects the changed range, not the whole document.
154+
it(
155+
"getChanges() in onChange scales sub-linearly",
156+
{ timeout: 30_000 },
157+
() => {
158+
function measureWithGetChanges(
159+
editor: BlockNoteEditor<any, any, any>,
160+
pos: number,
161+
) {
162+
// Force the full diff to run for every transaction, like an app that
163+
// reads the changed blocks on each keystroke.
164+
// eslint-disable-next-line @typescript-eslint/unbound-method -- getChanges is destructured from callback parameter, not a class
165+
const unsubscribe = editor.onChange((_editor, { getChanges }) => {
166+
getChanges();
167+
});
168+
const avg = measureAvgInsertTime(editor, pos);
169+
unsubscribe();
170+
return avg;
171+
}
172+
173+
const smallEditor = createEditorWithBlocks(SMALL, "paragraph");
174+
const largeEditor = createEditorWithBlocks(LARGE, "paragraph");
175+
176+
const smallAvg = measureWithGetChanges(
177+
smallEditor,
178+
smallEditor._tiptapEditor.view.state.doc.content.size - 2,
179+
);
180+
const largeAvg = measureWithGetChanges(
181+
largeEditor,
182+
largeEditor._tiptapEditor.view.state.doc.content.size - 2,
183+
);
184+
const ratio = largeAvg / smallAvg;
185+
186+
// eslint-disable-next-line no-console
187+
console.log(
188+
`getChanges (end): ${SMALL}=${smallAvg.toFixed(3)}ms, ${LARGE}=${largeAvg.toFixed(3)}ms, ratio=${ratio.toFixed(2)}x`,
189+
);
190+
191+
// With the full-document snapshot, getChanges alone was O(blocks) per
192+
// keystroke (two nodeToBlock conversions of the entire document), pushing
193+
// this ratio toward the block-count ratio (~50x). Diffing only the changed
194+
// range keeps it well below the other O(n) ProseMirror DOM costs.
195+
expect(ratio).toBeLessThan(50);
196+
},
197+
);
147198
});

packages/core/src/extensions/PreviousBlockType/PreviousBlockType.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,25 @@ describe("PreviousBlockType: scoped traversal", () => {
9696
).length;
9797
expect(trackedBlockCount).toBeLessThan(10);
9898
});
99+
100+
it("detects attribute-only changes (heading level) that emit an AttrStep", () => {
101+
const editor = createEditorWithBlocks(100, "heading");
102+
103+
// Changing only a heading's level (type stays "heading") emits an AttrStep,
104+
// whose StepMap is empty — so ProseMirror's changedRange() returns null and
105+
// the change would be missed unless the scoped range also covers AttrSteps.
106+
const firstBlock = editor.document[0];
107+
editor.updateBlock(firstBlock, { props: { level: 2 } } as any);
108+
109+
const state = getPreviousBlockTypePluginState(editor);
110+
111+
expect(state.updatedBlocks.size).toBe(1);
112+
expect(state.updatedBlocks.has(firstBlock.id)).toBe(true);
113+
114+
// Still scoped to the changed range, not all 100 blocks.
115+
const trackedBlockCount = Object.keys(
116+
state.currentTransactionOldBlockAttrs,
117+
).length;
118+
expect(trackedBlockCount).toBeLessThan(10);
119+
});
99120
});

packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { findChildrenInRange } from "@tiptap/core";
22
import { Plugin, PluginKey } from "prosemirror-state";
33
import { Decoration, DecorationSet } from "prosemirror-view";
44
import { getNodeId } from "../../api/getBlockInfoFromPos.js";
5+
import { getChangedRange } from "../../api/getChangedRange.js";
56
import { createExtension } from "../../editor/BlockNoteExtension.js";
67

78
const PLUGIN_KEY = new PluginKey(`previous-blocks`);
@@ -73,8 +74,11 @@ export const PreviousBlockTypeExtension = createExtension(() => {
7374
}
7475

7576
// Only check nodes affected by the transaction, not the entire document.
76-
// changedRange() is O(steps) unlike tiptap's getChangedRanges which is O(steps²).
77-
const newRange = transaction.changedRange();
77+
// getChangedRange() is O(steps), unlike tiptap's getChangedRanges which
78+
// is O(steps²). Unlike ProseMirror's changedRange() it also covers
79+
// attribute-only steps (AttrStep), so a block whose `level`/`index`
80+
// changes without any content edit is still picked up here.
81+
const newRange = getChangedRange(transaction);
7882
if (!newRange) {
7983
return prev;
8084
}

0 commit comments

Comments
 (0)