From 3606efe0be10ef2d45985e3dfe103102c5560f10 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 9 Oct 2026 10:45:26 +0200 Subject: [PATCH 01/13] fix(core): keep the shared id of a deleted block and its re-created copy 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. --- .../UniqueID/UniqueID.test.ts | 34 ++++++------------- .../tiptap-extensions/UniqueID/UniqueID.ts | 3 ++ 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.test.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.test.ts index dbb95c9a8c..d4fb1ba0a2 100644 --- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.test.ts +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.test.ts @@ -113,43 +113,29 @@ describe("UniqueID: duplicate id handling", () => { expect(new Set(ids).size).toBe(4); }); - it("preserves the duplicate id of a suggested-deletion block while still rewriting the plain duplicate", () => { + it("keeps the shared id of a suggested-deletion block and its surviving copy", () => { editor = createEditor(); const view = editor._tiptapEditor.view; // Insert two new blocks sharing the id "dup" in a single transaction: a // plain (live) one and a suggested-deletion one (y-attributed-delete mark). - // The plain block's id is rewritten, but the suggested-deletion block MUST - // keep its "dup" id, because in suggestion mode it intentionally shares the - // id with the surviving node. + // This is how a version diff shows a re-created block (a type change or a + // move): the deleted original next to its copy, with the same id. Neither + // id is rewritten, otherwise the shown copy gets a different id. const liveDup = makeBlockContainer(editor, "dup", "Live dup", false); const deletedDup = makeBlockContainer(editor, "dup", "Deleted dup", true); const firstBlock = view.state.doc.firstChild!.firstChild!; const insertPos = firstBlock.nodeSize + 1; - // Insert the live block first, then the suggested-deletion block after it. view.dispatch(view.state.tr.insert(insertPos, [liveDup, deletedDup])); - const ids = getBlockIds(view.state.doc); - - expect(ids).toHaveLength(4); - // The suggested-deletion block keeps "dup". - const dupCount = ids.filter((id) => id === "dup").length; - expect(dupCount).toBe(1); - - // Confirm it is specifically the suggested-deletion node that kept "dup". - let suggestedDeletionId: string | null = null; - view.state.doc.descendants((node) => { - if ( - node.type.name === "blockContainer" && - node.marks.some((m) => m.type.name === "y-attributed-delete") - ) { - suggestedDeletionId = node.attrs.id; - } - return true; - }); - expect(suggestedDeletionId).toBe("dup"); + expect(getBlockIds(view.state.doc)).toEqual([ + "block-a", + "dup", + "dup", + "block-b", + ]); }); it("exposes distinct ids in editor.document even though two ProseMirror nodes share the same id", () => { diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts index 7ab30b78aa..0ada89cb24 100644 --- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts @@ -171,7 +171,10 @@ const UniqueID = Extension.create({ return types.includes(node.type.name); }, ); + // A block marked as deleted shares its id with its surviving + // copy (see `isMarkedDeleted`), so it doesn't count as a duplicate. const newIds = newNodes + .filter(({ node }) => !isMarkedDeleted(node)) .map(({ node }) => node.attrs.id) .filter((id) => id !== null); const duplicatedNewIds = findDuplicates(newIds); From e646400f3895b8b8047bb7122f5b14ecdde567f5 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 9 Oct 2026 10:45:39 +0200 Subject: [PATCH 02/13] perf(core): find the ids of deleted blocks in one walk of the document 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. --- .../core/src/api/getBlockInfoFromPos.test.ts | 31 +++++++++++ packages/core/src/api/getBlockInfoFromPos.ts | 51 ++++++++++++------- 2 files changed, 64 insertions(+), 18 deletions(-) diff --git a/packages/core/src/api/getBlockInfoFromPos.test.ts b/packages/core/src/api/getBlockInfoFromPos.test.ts index 6af6e7b6d8..1ad999de53 100644 --- a/packages/core/src/api/getBlockInfoFromPos.test.ts +++ b/packages/core/src/api/getBlockInfoFromPos.test.ts @@ -147,6 +147,37 @@ describe("getNodeId", () => { expect(getNodeId(blockGroup.child(2), doc)).toBe("0-1"); }); + it("counts same-id nodes in document order, including nested ones", () => { + const schema = getSchema(); + + // A deleted block before its live copy, and a deleted copy nested in + // the live block's children. + const nestedDeleted = makeBlockContainer(schema, "0", "Nested", true); + const liveWithChild = schema.nodes["blockContainer"].createChecked( + { id: "0" }, + [ + schema.nodes["paragraph"].createChecked({}, schema.text("Live")), + schema.nodes["blockGroup"].createChecked({}, nestedDeleted), + ], + ); + const firstDeleted = makeBlockContainer(schema, "0", "First", true); + + const doc = schema.nodes["doc"].createChecked( + {}, + schema.nodes["blockGroup"].createChecked({}, [ + firstDeleted, + liveWithChild, + ]), + ); + + const blockGroup = doc.firstChild!; + expect(getNodeId(blockGroup.child(0), doc)).toBe("0-0"); + expect(getNodeId(blockGroup.child(1), doc)).toBe("0"); + expect(getNodeId(blockGroup.child(1).lastChild!.firstChild!, doc)).toBe( + "0-2", + ); + }); + it("throws when a suggested-deletion node is not found in the provided doc", () => { const schema = getSchema(); diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index 04ed789c98..73869cbc0b 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -48,6 +48,36 @@ export function isSuggestedDeletionNode(node: Node): boolean { return node.marks.some((m) => ["y-attributed-delete"].includes(m.type.name)); } +// Computed in one walk per doc: callers look up every block, and a walk per +// suggested-deletion node made that quadratic in large diffs. +const deletedNodeIdsByDoc = new WeakMap>(); + +/** + * The ids of the suggested-deletion nodes in `doc`: `-`, where + * `index` counts the nodes with the same id before it, in document order. + */ +function getDeletedNodeIds(doc: Node): Map { + let deletedNodeIds = deletedNodeIdsByDoc.get(doc); + if (deletedNodeIds === undefined) { + const ids = new Map(); + const counts = new Map(); + doc.descendants((descNode) => { + const id = descNode.attrs.id; + if (id) { + const index = counts.get(id) ?? 0; + if (isSuggestedDeletionNode(descNode) && !ids.has(descNode)) { + ids.set(descNode, `${id}-${index}`); + } + counts.set(id, index + 1); + } + return true; + }); + deletedNodeIds = ids; + deletedNodeIdsByDoc.set(doc, deletedNodeIds); + } + return deletedNodeIds; +} + export function getNodeId(node: Node, doc: Node): string { const id = node.attrs.id; if (!id) { @@ -60,28 +90,13 @@ export function getNodeId(node: Node, doc: Node): string { * so we need to differentiate them by counting how many nodes with the same ID come before them in the document, and adding that count to the ID. */ if (isSuggestedDeletionNode(node)) { - // walk the doc to find the node and count it's index if others have the same ID, to differentiate them - let index = 0; - let found = false; - doc.descendants((descNode: Node) => { - if (found) { - return false; // stop the walk - } - if (descNode.attrs.id === id) { - if (descNode === node) { - found = true; - return false; // stop the walk - } - index++; - } - return true; // continue the walk - }); - if (!found) { + const deletedId = getDeletedNodeIds(doc).get(node); + if (deletedId === undefined) { throw new Error( `Node ${node.type.name} with ID ${id} not found in document`, ); } - return `${id}-${index}`; + return deletedId; } // TODO handle deleted nodes return id; From 7cc7cbc5fa17f4b6750182e212fcb81aeecc0d91 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 9 Oct 2026 10:45:41 +0200 Subject: [PATCH 03/13] perf(core): remove the step-quadratic work in UniqueID, autolink and 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. --- .../core/src/api/getChangedRanges.test.ts | 343 ++++++++++++++++++ packages/core/src/api/getChangedRanges.ts | 322 ++++++++++++++++ .../Link/helpers/autolink.ts | 11 +- .../tiptap-extensions/UniqueID/UniqueID.ts | 20 +- .../src/y/extensions/AttributionExtension.ts | 2 +- 5 files changed, 685 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/api/getChangedRanges.test.ts create mode 100644 packages/core/src/api/getChangedRanges.ts diff --git a/packages/core/src/api/getChangedRanges.test.ts b/packages/core/src/api/getChangedRanges.test.ts new file mode 100644 index 0000000000..b085410cf7 --- /dev/null +++ b/packages/core/src/api/getChangedRanges.test.ts @@ -0,0 +1,343 @@ +import { getChangedRanges as tiptapGetChangedRanges } from "@tiptap/core"; +import { Node, Schema } from "prosemirror-model"; +import { + AddNodeMarkStep, + Mapping, + Transform, + findWrapping, + liftTarget, +} from "prosemirror-transform"; +import { describe, expect, it } from "vite-plus/test"; + +import { getChangedRanges } from "./getChangedRanges.js"; + +const schema = new Schema({ + nodes: { + doc: { + content: "block+", + attrs: { title: { default: null } }, + marks: "_", + }, + paragraph: { + group: "block", + content: "text*", + attrs: { level: { default: 0 } }, + marks: "_", + }, + blockquote: { group: "block", content: "block+", marks: "_" }, + text: {}, + }, + marks: { strong: {}, comment: {} }, +}); + +function paragraph(text: string) { + return schema.node("paragraph", null, text ? schema.text(text) : []); +} + +/** Paragraphs at 0-5, 5-10 and 10-15, with text at 1-4, 6-9 and 11-14. */ +function threeParagraphs() { + return schema.node("doc", null, [ + paragraph("abc"), + paragraph("def"), + paragraph("ghi"), + ]); +} + +/** `[old from, old to, new from, new to]` of each changed range. */ +function ranges(implementation: typeof getChangedRanges, transform: Transform) { + return implementation(transform).map(({ oldRange, newRange }) => [ + oldRange.from, + oldRange.to, + newRange.from, + newRange.to, + ]); +} + +const cases: [string, (tr: Transform) => void, number[][]][] = [ + ["no steps", () => {}, []], + ["insert text", (tr) => tr.insert(2, schema.text("X")), [[2, 2, 2, 3]]], + ["delete text", (tr) => tr.delete(6, 8), [[6, 8, 6, 6]]], + [ + "replace text", + (tr) => tr.replaceWith(6, 8, schema.text("XY")), + [[6, 8, 6, 8]], + ], + [ + "two inserts in document order", + (tr) => tr.insert(2, schema.text("X")).insert(8, schema.text("Y")), + [ + [2, 2, 2, 3], + [7, 7, 8, 9], + ], + ], + [ + "two inserts in reverse order", + (tr) => tr.insert(7, schema.text("Y")).insert(2, schema.text("X")), + [ + [7, 7, 8, 9], + [2, 2, 2, 3], + ], + ], + [ + "two inserts at the same position", + (tr) => tr.insert(2, schema.text("X")).insert(2, schema.text("Y")), + [[2, 2, 2, 4]], + ], + [ + "insert, then delete inside it", + (tr) => tr.insert(2, schema.text("XYZ")).delete(3, 4), + [[2, 2, 2, 4]], + ], + [ + "insert, then delete around it", + (tr) => tr.insert(7, schema.text("X")).delete(6, 9), + [[6, 8, 6, 6]], + ], + ["delete a paragraph", (tr) => tr.delete(5, 10), [[5, 10, 5, 5]]], + [ + "insert a paragraph", + (tr) => tr.insert(5, paragraph("new")), + [[5, 5, 5, 10]], + ], + [ + "add a mark", + (tr) => tr.addMark(1, 3, schema.marks.strong.create()), + [[1, 3, 1, 3]], + ], + [ + "add two marks to the same range", + (tr) => + tr + .addMark(1, 3, schema.marks.strong.create()) + .addMark(1, 3, schema.marks.comment.create()), + [[1, 3, 1, 3]], + ], + [ + "remove a mark inside an added one", + (tr) => + tr + .addMark(1, 4, schema.marks.strong.create()) + .removeMark(2, 3, schema.marks.strong), + [[1, 4, 1, 4]], + ], + // Steps without a position range are skipped (see `getChangedRange`). + ["set a node attribute", (tr) => tr.setNodeAttribute(5, "level", 1), []], + ["set a doc attribute", (tr) => tr.setDocAttribute("title", "x"), []], + [ + "add a node mark", + (tr) => tr.addNodeMark(5, schema.marks.comment.create()), + [], + ], + [ + "wrap a paragraph", + (tr) => { + const range = tr.doc.resolve(6).blockRange()!; + tr.wrap(range, findWrapping(range, schema.nodes.blockquote)!); + }, + [ + [5, 5, 5, 6], + [10, 10, 11, 12], + ], + ], + [ + "wrap, then lift", + (tr) => { + const wrapRange = tr.doc.resolve(6).blockRange()!; + tr.wrap(wrapRange, findWrapping(wrapRange, schema.nodes.blockquote)!); + const liftRange = tr.doc.resolve(7).blockRange()!; + tr.lift(liftRange, liftTarget(liftRange)!); + }, + [ + [5, 5, 5, 5], + [10, 10, 10, 10], + ], + ], + [ + "large replace, then edit inside it", + (tr) => + tr + .replaceWith(1, 14, schema.text("XXXXXXXXXX")) + .insert(4, schema.text("Y")), + [[1, 14, 1, 12]], + ], + [ + "edit, then large replace around it", + (tr) => tr.insert(7, schema.text("Y")).replaceWith(5, 16, paragraph("Z")), + [[5, 15, 5, 8]], + ], + [ + "mark inside an insert", + (tr) => + tr + .insert(2, schema.text("XYZ")) + .addMark(3, 4, schema.marks.strong.create()), + [[2, 2, 2, 5]], + ], +]; + +describe.each([ + ["Tiptap", tiptapGetChangedRanges], + ["BlockNote", getChangedRanges], +])("getChangedRanges (%s)", (_, implementation) => { + it.each(cases)("%s", (_name, change, expected) => { + const tr = new Transform(threeParagraphs()); + change(tr); + expect(ranges(implementation, tr)).toEqual(expected); + }); +}); + +/** A seeded random number in `[0, n)`. */ +function random(seed: number) { + let state = seed; + return (n: number) => { + state = (state * 16807) % 2147483647; + return state % n; + }; +} + +/** Applies one random valid change to `tr`. */ +function randomChange(tr: Transform, rnd: (n: number) => number) { + const textblocks: { pos: number; node: Node }[] = []; + tr.doc.descendants((node, pos) => { + if (node.isTextblock) { + textblocks.push({ pos, node }); + } + return true; + }); + const { pos, node } = textblocks[rnd(textblocks.length)]; + const textPos = (offset: number) => pos + 1 + offset; + const size = node.content.size; + const text = "xyz".slice(rnd(3)); + const docSize = tr.doc.content.size; + + switch (rnd(10)) { + case 0: + tr.insert(textPos(rnd(size + 1)), schema.text("abc".slice(rnd(3)))); + break; + case 1: { + const from = rnd(size + 1); + tr.delete(textPos(from), textPos(from + rnd(size - from + 1))); + break; + } + case 2: { + const from = rnd(size + 1); + const to = textPos(from + rnd(size - from + 1)); + if (text) { + tr.replaceWith(textPos(from), to, schema.text(text)); + } + break; + } + case 3: + if (tr.doc.childCount > 1) { + const index = rnd(tr.doc.childCount); + let start = 0; + for (let i = 0; i < index; i++) { + start += tr.doc.child(i).nodeSize; + } + tr.delete(start, start + tr.doc.child(index).nodeSize); + } + break; + case 4: { + const index = rnd(tr.doc.childCount + 1); + let start = 0; + for (let i = 0; i < index; i++) { + start += tr.doc.child(i).nodeSize; + } + tr.insert(start, paragraph(text)); + break; + } + case 5: { + const from = rnd(docSize + 1); + const to = from + rnd(docSize - from + 1); + const mark = [schema.marks.strong, schema.marks.comment][rnd(2)]; + if (rnd(2)) { + tr.addMark(from, to, mark.create()); + } else { + tr.removeMark(from, to, mark); + } + break; + } + case 6: + tr.setNodeAttribute(pos, "level", rnd(3)); + break; + case 7: { + const range = tr.doc.resolve(textPos(0)).blockRange(); + const target = range && liftTarget(range); + if (range && target !== null && target !== undefined) { + tr.lift(range, target); + } + break; + } + case 8: { + const range = tr.doc.resolve(textPos(0)).blockRange(); + const wrapping = range && findWrapping(range, schema.nodes.blockquote); + if (range && wrapping) { + tr.wrap(range, wrapping); + } + break; + } + case 9: + if (rnd(2)) { + tr.maybeStep(new AddNodeMarkStep(pos, schema.marks.comment.create())); + } else { + tr.setDocAttribute("title", String(rnd(3))); + } + break; + } +} + +describe("getChangedRanges", () => { + it("returns the same ranges as Tiptap for random changes", () => { + const rnd = random(42); + for (let run = 0; run < 3000; run++) { + const tr = new Transform( + schema.node( + "doc", + null, + Array.from({ length: 1 + rnd(6) }, () => + paragraph("abcdef".slice(rnd(7))), + ), + ), + ); + const stepCount = 1 + rnd(run % 10 === 0 ? 200 : 30); + for (let i = 0; i < stepCount; i++) { + randomChange(tr, rnd); + } + expect(ranges(getChangedRanges, tr)).toEqual( + ranges(tiptapGetChangedRanges, tr), + ); + } + }); + + it("returns the same ranges as Tiptap for many steps in document order and in reverse", () => { + const blocks = Array.from({ length: 300 }, (_, i) => paragraph(`p${i}`)); + for (const reverse of [false, true]) { + const tr = new Transform(schema.node("doc", null, blocks)); + const starts: number[] = []; + tr.doc.forEach((_node, offset) => starts.push(offset)); + for (const start of reverse ? starts.reverse() : starts) { + tr.insert(tr.mapping.map(start + 1), schema.text("!")); + tr.setNodeAttribute(tr.mapping.map(start), "level", 1); + } + expect(ranges(getChangedRanges, tr)).toEqual( + ranges(tiptapGetChangedRanges, tr), + ); + } + }); + + it("returns the same ranges as Tiptap for a mirrored mapping", () => { + const tr = new Transform(threeParagraphs()); + tr.insert(2, schema.text("X")); + tr.step(tr.steps[0].invert(tr.docs[0])); + tr.insert(7, schema.text("Y")); + const mirrored = new Mapping(); + mirrored.appendMap(tr.mapping.maps[0]); + mirrored.appendMap(tr.mapping.maps[1], 0); + mirrored.appendMap(tr.mapping.maps[2]); + Object.assign(tr, { mapping: mirrored }); + + expect(ranges(getChangedRanges, tr)).toEqual( + ranges(tiptapGetChangedRanges, tr), + ); + }); +}); diff --git a/packages/core/src/api/getChangedRanges.ts b/packages/core/src/api/getChangedRanges.ts new file mode 100644 index 0000000000..20fb1970d2 --- /dev/null +++ b/packages/core/src/api/getChangedRanges.ts @@ -0,0 +1,322 @@ +import { + getChangedRanges as getChangedRangesQuadratic, + type ChangedRange, +} from "@tiptap/core"; +import type { Step, StepMap, Transform } from "prosemirror-transform"; + +/** + * Tiptap's `getChangedRanges` (https://github.com/ueberdosis/tiptap/blob/main/packages/core/src/helpers/getChangedRanges.ts), + * with the same result but without its quadratic cost on transforms with many + * steps. Tiptap maps each changed range through every later step and back + * through every step, and compares every range with every other range. A + * version diff of a few thousand blocks has ~10,000 steps, which took seconds. + * + * Here, mapping skips runs of steps that provably leave the position + * unchanged or only shift it (see {@link StepMapIndex}), and the simplify pass + * only compares ranges that can contain each other. For steps in document + * order, both are close to linear. + * + * See also `getChangedRange` (one range covering all changes, including + * attribute-only steps, which this function skips like Tiptap's). + */ +export function getChangedRanges(transform: Transform): ChangedRange[] { + const { mapping, steps } = transform; + const maps = mapping.maps; + // A mirrored mapping (from rebasing steps) recovers positions across step + // pairs, which the skips below don't model. + for (let i = 0; i < maps.length; i++) { + if (mapping.getMirror(i) !== undefined) { + return getChangedRangesQuadratic(transform); + } + } + + const index = new StepMapIndex(maps); + const changes: ChangedRange[] = []; + maps.forEach((stepMap, i) => { + const ranges: { from: number; to: number }[] = []; + stepMap.forEach((from, to) => ranges.push({ from, to })); + // Steps that change no positions (e.g. marks) still change their range. + if (!ranges.length) { + const { from, to } = steps[i] as Step & { from?: number; to?: number }; + if (from === undefined || to === undefined) { + return; + } + ranges.push({ from, to }); + } + + for (const { from, to } of ranges) { + const newStart = index.mapForward(from, -1, i); + const newEnd = index.mapForward(to, 1, i); + changes.push({ + oldRange: { + from: index.mapBackward(newStart, -1), + to: index.mapBackward(newEnd, 1), + }, + newRange: { from: newStart, to: newEnd }, + }); + } + }); + + return simplifyChangedRanges(changes); +} + +/** + * Removes duplicated ranges and ranges that other ranges fully contain, like + * Tiptap's `simplifyChangedRanges`. + */ +function simplifyChangedRanges(changes: ChangedRange[]): ChangedRange[] { + const seen = new Set(); + const unique = changes.filter(({ oldRange, newRange }) => { + const key = `${oldRange.from},${oldRange.to},${newRange.from},${newRange.to}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + if (unique.length === 1) { + return unique; + } + + // Each old range is its new range mapped back with the same mapping, which + // keeps order. So when a new range contains another, its old range does + // too, and comparing the new ranges is enough. + const byNewFrom = unique + .map((_, i) => i) + .sort((a, b) => unique[a].newRange.from - unique[b].newRange.from); + const newTo = new SegmentTree( + byNewFrom.map((i) => unique[i].newRange.to), + "max", + ); + + return unique.filter((change, i) => { + // The last candidate whose new range starts at or before this one. + let lo = 0; + let hi = byNewFrom.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (unique[byNewFrom[mid]].newRange.from <= change.newRange.from) { + lo = mid; + } else { + hi = mid - 1; + } + } + const contained = newTo.someBefore( + lo, + (to) => to >= change.newRange.to, + (k) => byNewFrom[k] !== i, + ); + return !contained; + }); +} + +/** + * Maps positions through a list of step maps like `Mapping#map`, but skips + * runs of maps that can't affect the position. For a step map, a position + * before its first range stays unchanged, and a position after all its + * ranges only shifts by the map's size change. Only positions inside its + * ranges need the map itself. + */ +class StepMapIndex { + private readonly inverted: (StepMap | undefined)[]; + /** Per map: the lowest start of its ranges, before or after the map. */ + private readonly firstStart: SegmentTree; + /** Sum of the size changes of the maps before each index. */ + private readonly shift: number[]; + /** Per map: its last range end in the old doc, minus `shift` at it. */ + private readonly forwardShiftFrom: SegmentTree; + /** Per map: its last range end in the new doc, minus `shift` after it. */ + private readonly backwardShiftFrom: SegmentTree; + + constructor(private readonly maps: readonly StepMap[]) { + this.inverted = new Array(maps.length); + const firstStarts: number[] = []; + const oldEnds: number[] = []; + const newEnds: number[] = []; + this.shift = [0]; + for (const map of maps) { + let first = Infinity; + let oldEnd = -Infinity; + let newEnd = -Infinity; + let sizeChange = 0; + map.forEach((oldStart, oldStop, newStart, newStop) => { + first = Math.min(first, oldStart, newStart); + oldEnd = Math.max(oldEnd, oldStop); + newEnd = Math.max(newEnd, newStop); + sizeChange += newStop - newStart - (oldStop - oldStart); + }); + firstStarts.push(first); + oldEnds.push(oldEnd); + newEnds.push(newEnd); + this.shift.push(this.shift[this.shift.length - 1] + sizeChange); + } + this.firstStart = new SegmentTree(firstStarts, "min"); + this.forwardShiftFrom = new SegmentTree( + oldEnds.map((end, i) => end - this.shift[i]), + "max", + ); + this.backwardShiftFrom = new SegmentTree( + newEnds.map((end, i) => end - this.shift[i + 1]), + "max", + ); + } + + /** Like `mapping.slice(from).map(pos, assoc)`. */ + mapForward(pos: number, assoc: number, from: number): number { + let i = from; + while (i < this.maps.length) { + // Skip the maps before which `pos` lies. + i = this.firstStart.firstFrom(i, (start) => start <= pos); + if (i === this.maps.length) { + break; + } + if (pos > this.forwardShiftFromAt(i)) { + // Skip the maps after which `pos` lies, adding their size changes. + const next = this.forwardShiftFrom.firstFrom( + i, + (end) => end >= pos - this.shift[i], + ); + pos += this.shift[next] - this.shift[i]; + i = next; + } else { + pos = this.maps[i].map(pos, assoc); + i++; + } + } + return pos; + } + + /** Like `mapping.invert().map(pos, assoc)`. */ + mapBackward(pos: number, assoc: number): number { + let i = this.maps.length - 1; + while (i >= 0) { + i = this.firstStart.lastFrom(i, (start) => start <= pos); + if (i === -1) { + break; + } + if (pos > this.backwardShiftFromAt(i)) { + const next = this.backwardShiftFrom.lastFrom( + i, + (end) => end >= pos - this.shift[i + 1], + ); + pos -= this.shift[i + 1] - this.shift[next + 1]; + i = next; + } else { + this.inverted[i] ??= this.maps[i].invert(); + pos = this.inverted[i]!.map(pos, assoc); + i--; + } + } + return pos; + } + + private forwardShiftFromAt(i: number) { + return this.forwardShiftFrom.values[i] + this.shift[i]; + } + + private backwardShiftFromAt(i: number) { + return this.backwardShiftFrom.values[i] + this.shift[i + 1]; + } +} + +/** Finds the nearest value that matches a monotone min/max condition. */ +class SegmentTree { + private readonly size: number; + private readonly tree: number[]; + + constructor( + readonly values: number[], + private readonly kind: "min" | "max", + ) { + this.size = values.length; + this.tree = new Array(4 * Math.max(1, values.length)); + if (values.length) { + this.build(1, 0, values.length - 1); + } + } + + /** + * The first index at or after `from` whose value matches, or `size`. + * `matches` must hold for a subtree's min (or max) whenever it holds for + * any value in it. + */ + firstFrom(from: number, matches: (value: number) => boolean): number { + const found = this.first(1, 0, this.size - 1, from, matches); + return found === -1 ? this.size : found; + } + + /** The last index at or before `to` whose value matches, or -1. */ + lastFrom(to: number, matches: (value: number) => boolean): number { + return this.last(1, 0, this.size - 1, to, matches); + } + + /** + * Whether `found` holds for an index at or before `to` whose value + * matches, checking those indexes from the last one down. + */ + someBefore( + to: number, + matches: (value: number) => boolean, + found: (index: number) => boolean, + ): boolean { + for (let i = this.lastFrom(to, matches); i !== -1;) { + if (found(i)) { + return true; + } + i = i === 0 ? -1 : this.lastFrom(i - 1, matches); + } + return false; + } + + private build(node: number, lo: number, hi: number) { + if (lo === hi) { + this.tree[node] = this.values[lo]; + return; + } + const mid = (lo + hi) >> 1; + this.build(2 * node, lo, mid); + this.build(2 * node + 1, mid + 1, hi); + this.tree[node] = + this.kind === "min" + ? Math.min(this.tree[2 * node], this.tree[2 * node + 1]) + : Math.max(this.tree[2 * node], this.tree[2 * node + 1]); + } + + private first( + node: number, + lo: number, + hi: number, + from: number, + matches: (value: number) => boolean, + ): number { + if (hi < from || !matches(this.tree[node])) { + return -1; + } + if (lo === hi) { + return lo; + } + const mid = (lo + hi) >> 1; + const left = this.first(2 * node, lo, mid, from, matches); + return left !== -1 + ? left + : this.first(2 * node + 1, mid + 1, hi, from, matches); + } + + private last( + node: number, + lo: number, + hi: number, + to: number, + matches: (value: number) => boolean, + ): number { + if (lo > to || !matches(this.tree[node])) { + return -1; + } + if (lo === hi) { + return lo; + } + const mid = (lo + hi) >> 1; + const right = this.last(2 * node + 1, mid + 1, hi, to, matches); + return right !== -1 ? right : this.last(2 * node, lo, mid, to, matches); + } +} diff --git a/packages/core/src/extensions/tiptap-extensions/Link/helpers/autolink.ts b/packages/core/src/extensions/tiptap-extensions/Link/helpers/autolink.ts index eb407f7706..4af9320b5d 100644 --- a/packages/core/src/extensions/tiptap-extensions/Link/helpers/autolink.ts +++ b/packages/core/src/extensions/tiptap-extensions/Link/helpers/autolink.ts @@ -2,11 +2,11 @@ import type { NodeWithPos } from "@tiptap/core"; import { combineTransactionSteps, findChildrenInRange, - getChangedRanges, getMarksBetween, } from "@tiptap/core"; import type { MarkType } from "@tiptap/pm/model"; import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { getChangedRanges } from "../../../../api/getChangedRanges.js"; import type { LinkMatch } from "./linkDetector.js"; import { tokenizeLink } from "./linkDetector.js"; @@ -58,9 +58,12 @@ export function autolink(options: AutolinkOptions): Plugin { } const { tr } = newState; - const transform = combineTransactionSteps(oldState.doc, [ - ...transactions, - ]); + // A single transaction already holds its steps: replaying thousands of + // them (e.g. a version diff) took seconds. + const transform = + transactions.length === 1 + ? transactions[0] + : combineTransactionSteps(oldState.doc, [...transactions]); const changes = getChangedRanges(transform); changes.forEach(({ newRange }) => { diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts index 0ada89cb24..845c6372be 100644 --- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts @@ -2,11 +2,11 @@ import { combineTransactionSteps, Extension, findChildrenInRange, - getChangedRanges, } from "@tiptap/core"; import { uuidv4 } from "lib0/random"; import { Fragment, Node, Slice } from "prosemirror-model"; import { Plugin, PluginKey } from "prosemirror-state"; +import { getChangedRanges } from "../../../api/getChangedRanges.js"; import { isSuggestedDeletionNode } from "../../../api/getBlockInfoFromPos.js"; /** @@ -155,10 +155,12 @@ const UniqueID = Extension.create({ } const { tr } = newState; const { types, generateID } = this.options; - const transform = combineTransactionSteps( - oldState.doc, - transactions as any, - ); + // A single transaction already holds its steps: replaying thousands + // of them (e.g. a version diff) took seconds. + const transform = + transactions.length === 1 + ? transactions[0] + : combineTransactionSteps(oldState.doc, transactions as any); const { mapping } = transform; // get changed ranges based on the old state const changes = getChangedRanges(transform); @@ -224,9 +226,11 @@ const UniqueID = Extension.create({ }); return; } - // check if the node doesn’t exist in the old state - const { deleted } = mapping.invert().mapResult(pos); - const newNode = deleted && duplicatedNewIds.includes(id); + // check if the node doesn’t exist in the old state. Mapping + // through every step is slow, so only for duplicated ids. + const newNode = + duplicatedNewIds.includes(id) && + mapping.invert().mapResult(pos).deleted; // purposefully skip rewriting ids for suggested deletion nodes, to avoid modifying them if (newNode && !isSuggestedDeletionNode(node)) { tr.setNodeMarkup(pos, undefined, { diff --git a/packages/core/src/y/extensions/AttributionExtension.ts b/packages/core/src/y/extensions/AttributionExtension.ts index 4f47e96a8b..2994ad33ee 100644 --- a/packages/core/src/y/extensions/AttributionExtension.ts +++ b/packages/core/src/y/extensions/AttributionExtension.ts @@ -1,6 +1,6 @@ import { AddNodeMarkStep } from "prosemirror-transform"; -import { getChangedRanges } from "@tiptap/core"; import { Plugin, PluginKey, type Transaction } from "prosemirror-state"; +import { getChangedRanges } from "../../api/getChangedRanges.js"; import { createExtension, createStore, From f31288956d3c42cf8e2d4edf7e145f50f3a4b521 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 9 Oct 2026 10:45:44 +0200 Subject: [PATCH 04/13] test(versioning): show a version in one step To be fixed by https://github.com/yjs/y-prosemirror/pull/282 --- .../extensions/versionDiffPerformance.test.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 packages/core/src/y/extensions/versionDiffPerformance.test.ts diff --git a/packages/core/src/y/extensions/versionDiffPerformance.test.ts b/packages/core/src/y/extensions/versionDiffPerformance.test.ts new file mode 100644 index 0000000000..9a9fc26ba7 --- /dev/null +++ b/packages/core/src/y/extensions/versionDiffPerformance.test.ts @@ -0,0 +1,101 @@ +/** + * @vitest-environment jsdom + */ +import * as Y from "@y/y"; +import { Plugin } from "prosemirror-state"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { blocksToYType } from "../utils.js"; +import { withCollaboration } from "./index.js"; +import { createYVersionView } from "./Versioning.js"; + +type Editor = BlockNoteEditor; + +const editors: Editor[] = []; +afterEach(() => { + for (const editor of editors.splice(0)) { + editor.unmount(); + } +}); + +function collaborativeEditor(doc: Y.Doc): Editor { + const editor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment: doc.get("doc"), + user: { name: "Test", color: "#ff0000" }, + }, + }), + ); + const element = document.createElement("div"); + document.body.appendChild(element); + editor.mount(element); + editors.push(editor); + return editor; +} + +/** Records the step count of every document change `editor` dispatches. */ +function recordSteps(editor: Editor): number[] { + const steps: number[] = []; + editor.prosemirrorView!.updateState( + editor.prosemirrorState.reconfigure({ + plugins: [ + ...editor.prosemirrorState.plugins, + new Plugin({ + filterTransaction(tr) { + if (tr.docChanged) { + steps.push(tr.steps.length); + } + return true; + }, + }), + ], + }), + ); + return steps; +} + +describe("Version diff performance", () => { + // Plugins that map through every step (UniqueID, autolink, attributions) + // take quadratic time, so a diff of thousands of blocks took seconds. + it.fails("shows a version in one step", () => { + // To be fixed by https://github.com/yjs/y-prosemirror/pull/282 + const blockCount = 300; + const doc = new Y.Doc({ gc: false }); + const seed = BlockNoteEditor.create(); + seed.replaceBlocks( + seed.document, + Array.from({ length: blockCount }, (_, i) => ({ + id: `b${i}`, + type: "paragraph" as const, + content: `Block ${i}`, + })), + ); + blocksToYType(seed, seed.document, doc.get("doc")); + const before = Y.encodeStateAsUpdateV2(doc); + + // Indenting re-creates a block, so every other block is shown twice: + // deleted at its old place and inserted at its new place. + const user = collaborativeEditor(doc); + for (let i = 1; i < blockCount; i += 2) { + user.setTextCursorPosition(`b${i}`); + user.nestBlock(); + } + const after = Y.encodeStateAsUpdateV2(doc); + + const viewDoc = new Y.Doc(); + Y.applyUpdateV2(viewDoc, after); + const editor = collaborativeEditor(viewDoc); + const steps = recordSteps(editor); + createYVersionView(editor, viewDoc.get("doc")) + .open() + .show({ + content: after, + comparison: { content: before }, + target: { type: "snapshot", id: "after" }, + }); + + expect(steps).toEqual([1]); + }); +}); From 866e533d60c06a3e4158b665cad230af97a0402c Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 9 Oct 2026 11:14:52 +0200 Subject: [PATCH 05/13] perf(core): make getChangedRanges cheaper than Tiptap's for small transforms 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. --- packages/core/src/api/getChangedRanges.ts | 144 ++++++++++++++-------- 1 file changed, 91 insertions(+), 53 deletions(-) diff --git a/packages/core/src/api/getChangedRanges.ts b/packages/core/src/api/getChangedRanges.ts index 20fb1970d2..a9767cb663 100644 --- a/packages/core/src/api/getChangedRanges.ts +++ b/packages/core/src/api/getChangedRanges.ts @@ -116,24 +116,32 @@ function simplifyChangedRanges(changes: ChangedRange[]): ChangedRange[] { * before its first range stays unchanged, and a position after all its * ranges only shifts by the map's size change. Only positions inside its * ranges need the map itself. + * + * Each map is checked directly. Only a long run of such maps is skipped with + * a segment tree, so transforms with few steps don't pay for the trees. */ class StepMapIndex { private readonly inverted: (StepMap | undefined)[]; /** Per map: the lowest start of its ranges, before or after the map. */ - private readonly firstStart: SegmentTree; + private readonly firstStarts: number[] = []; + /** Per map: its last range end in the old doc. */ + private readonly oldEnds: number[] = []; + /** Per map: its last range end in the new doc. */ + private readonly newEnds: number[] = []; /** Sum of the size changes of the maps before each index. */ - private readonly shift: number[]; - /** Per map: its last range end in the old doc, minus `shift` at it. */ - private readonly forwardShiftFrom: SegmentTree; - /** Per map: its last range end in the new doc, minus `shift` after it. */ - private readonly backwardShiftFrom: SegmentTree; + private readonly shift: number[] = [0]; + private trees: + | { + firstStart: SegmentTree; + /** `oldEnds` minus `shift` at each map. */ + forwardShiftFrom: SegmentTree; + /** `newEnds` minus `shift` after each map. */ + backwardShiftFrom: SegmentTree; + } + | undefined; constructor(private readonly maps: readonly StepMap[]) { this.inverted = new Array(maps.length); - const firstStarts: number[] = []; - const oldEnds: number[] = []; - const newEnds: number[] = []; - this.shift = [0]; for (const map of maps) { let first = Infinity; let oldEnd = -Infinity; @@ -145,41 +153,47 @@ class StepMapIndex { newEnd = Math.max(newEnd, newStop); sizeChange += newStop - newStart - (oldStop - oldStart); }); - firstStarts.push(first); - oldEnds.push(oldEnd); - newEnds.push(newEnd); + this.firstStarts.push(first); + this.oldEnds.push(oldEnd); + this.newEnds.push(newEnd); this.shift.push(this.shift[this.shift.length - 1] + sizeChange); } - this.firstStart = new SegmentTree(firstStarts, "min"); - this.forwardShiftFrom = new SegmentTree( - oldEnds.map((end, i) => end - this.shift[i]), - "max", - ); - this.backwardShiftFrom = new SegmentTree( - newEnds.map((end, i) => end - this.shift[i + 1]), - "max", - ); } /** Like `mapping.slice(from).map(pos, assoc)`. */ mapForward(pos: number, assoc: number, from: number): number { + // How many maps in a row `pos` lay before (> 0) or after (< 0). + let skipped = 0; let i = from; while (i < this.maps.length) { - // Skip the maps before which `pos` lies. - i = this.firstStart.firstFrom(i, (start) => start <= pos); - if (i === this.maps.length) { - break; - } - if (pos > this.forwardShiftFromAt(i)) { - // Skip the maps after which `pos` lies, adding their size changes. - const next = this.forwardShiftFrom.firstFrom( - i, - (end) => end >= pos - this.shift[i], - ); - pos += this.shift[next] - this.shift[i]; - i = next; + if (pos < this.firstStarts[i]) { + // `pos` lies before this map. + skipped = Math.max(skipped, 0) + 1; + if (skipped > LONG_RUN) { + i = this.getTrees().firstStart.firstFrom(i, (s) => s <= pos); + skipped = 0; + } else { + i++; + } + } else if (pos > this.oldEnds[i]) { + // `pos` lies after this map, which only shifts it. + skipped = Math.min(skipped, 0) - 1; + if (skipped < -LONG_RUN) { + const base = pos - this.shift[i]; + const next = this.getTrees().forwardShiftFrom.firstFrom( + i, + (end) => end >= base, + ); + pos += this.shift[next] - this.shift[i]; + i = next; + skipped = 0; + } else { + pos += this.shift[i + 1] - this.shift[i]; + i++; + } } else { pos = this.maps[i].map(pos, assoc); + skipped = 0; i++; } } @@ -188,44 +202,68 @@ class StepMapIndex { /** Like `mapping.invert().map(pos, assoc)`. */ mapBackward(pos: number, assoc: number): number { + let skipped = 0; let i = this.maps.length - 1; while (i >= 0) { - i = this.firstStart.lastFrom(i, (start) => start <= pos); - if (i === -1) { - break; - } - if (pos > this.backwardShiftFromAt(i)) { - const next = this.backwardShiftFrom.lastFrom( - i, - (end) => end >= pos - this.shift[i + 1], - ); - pos -= this.shift[i + 1] - this.shift[next + 1]; - i = next; + if (pos < this.firstStarts[i]) { + skipped = Math.max(skipped, 0) + 1; + if (skipped > LONG_RUN) { + i = this.getTrees().firstStart.lastFrom(i, (s) => s <= pos); + skipped = 0; + } else { + i--; + } + } else if (pos > this.newEnds[i]) { + skipped = Math.min(skipped, 0) - 1; + if (skipped < -LONG_RUN) { + const base = pos - this.shift[i + 1]; + const next = this.getTrees().backwardShiftFrom.lastFrom( + i, + (end) => end >= base, + ); + pos -= this.shift[i + 1] - this.shift[next + 1]; + i = next; + skipped = 0; + } else { + pos -= this.shift[i + 1] - this.shift[i]; + i--; + } } else { this.inverted[i] ??= this.maps[i].invert(); pos = this.inverted[i]!.map(pos, assoc); + skipped = 0; i--; } } return pos; } - private forwardShiftFromAt(i: number) { - return this.forwardShiftFrom.values[i] + this.shift[i]; - } - - private backwardShiftFromAt(i: number) { - return this.backwardShiftFrom.values[i] + this.shift[i + 1]; + private getTrees() { + this.trees ??= { + firstStart: new SegmentTree(this.firstStarts, "min"), + forwardShiftFrom: new SegmentTree( + this.oldEnds.map((end, i) => end - this.shift[i]), + "max", + ), + backwardShiftFrom: new SegmentTree( + this.newEnds.map((end, i) => end - this.shift[i + 1]), + "max", + ), + }; + return this.trees; } } +/** Above this many maps in a row that can be skipped, a tree skips the rest. */ +const LONG_RUN = 8; + /** Finds the nearest value that matches a monotone min/max condition. */ class SegmentTree { private readonly size: number; private readonly tree: number[]; constructor( - readonly values: number[], + private readonly values: number[], private readonly kind: "min" | "max", ) { this.size = values.length; From 41a36b461db7eacb6433a4bf96608ae2e1383161 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 9 Oct 2026 11:14:54 +0200 Subject: [PATCH 06/13] refactor(core): use one combineTransactionSteps for all callers 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. --- .../core/src/api/combineTransactionSteps.ts | 21 +++++++++++++++++++ .../src/api/getBlocksChangedByTransaction.ts | 2 +- .../Link/helpers/autolink.ts | 14 +++---------- .../tiptap-extensions/UniqueID/UniqueID.ts | 14 +++---------- 4 files changed, 28 insertions(+), 23 deletions(-) create mode 100644 packages/core/src/api/combineTransactionSteps.ts diff --git a/packages/core/src/api/combineTransactionSteps.ts b/packages/core/src/api/combineTransactionSteps.ts new file mode 100644 index 0000000000..b32ed70cd0 --- /dev/null +++ b/packages/core/src/api/combineTransactionSteps.ts @@ -0,0 +1,21 @@ +import { combineTransactionSteps as replayTransactionSteps } from "@tiptap/core"; +import type { Node } from "prosemirror-model"; +import type { Transaction } from "prosemirror-state"; +import type { Transform } from "prosemirror-transform"; + +/** + * Tiptap's `combineTransactionSteps`, but a single transaction that starts at + * `oldDoc` is returned as it is. Replaying its steps into a new transform + * took seconds for a version diff, which has thousands of steps. + * + * The result can be the transaction itself, so don't add steps to it. + */ +export function combineTransactionSteps( + oldDoc: Node, + transactions: readonly Transaction[], +): Transform { + if (transactions.length === 1 && transactions[0].before === oldDoc) { + return transactions[0]; + } + return replayTransactionSteps(oldDoc, [...transactions]); +} diff --git a/packages/core/src/api/getBlocksChangedByTransaction.ts b/packages/core/src/api/getBlocksChangedByTransaction.ts index 9157a86812..c21487f994 100644 --- a/packages/core/src/api/getBlocksChangedByTransaction.ts +++ b/packages/core/src/api/getBlocksChangedByTransaction.ts @@ -1,4 +1,4 @@ -import { combineTransactionSteps } from "@tiptap/core"; +import { combineTransactionSteps } from "./combineTransactionSteps.js"; import deepEqual from "fast-deep-equal"; import type { Node } from "prosemirror-model"; import type { Transaction } from "prosemirror-state"; diff --git a/packages/core/src/extensions/tiptap-extensions/Link/helpers/autolink.ts b/packages/core/src/extensions/tiptap-extensions/Link/helpers/autolink.ts index 4af9320b5d..4a215034a2 100644 --- a/packages/core/src/extensions/tiptap-extensions/Link/helpers/autolink.ts +++ b/packages/core/src/extensions/tiptap-extensions/Link/helpers/autolink.ts @@ -1,11 +1,8 @@ import type { NodeWithPos } from "@tiptap/core"; -import { - combineTransactionSteps, - findChildrenInRange, - getMarksBetween, -} from "@tiptap/core"; +import { findChildrenInRange, getMarksBetween } from "@tiptap/core"; import type { MarkType } from "@tiptap/pm/model"; import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { combineTransactionSteps } from "../../../../api/combineTransactionSteps.js"; import { getChangedRanges } from "../../../../api/getChangedRanges.js"; import type { LinkMatch } from "./linkDetector.js"; import { tokenizeLink } from "./linkDetector.js"; @@ -58,12 +55,7 @@ export function autolink(options: AutolinkOptions): Plugin { } const { tr } = newState; - // A single transaction already holds its steps: replaying thousands of - // them (e.g. a version diff) took seconds. - const transform = - transactions.length === 1 - ? transactions[0] - : combineTransactionSteps(oldState.doc, [...transactions]); + const transform = combineTransactionSteps(oldState.doc, transactions); const changes = getChangedRanges(transform); changes.forEach(({ newRange }) => { diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts index 845c6372be..2239b0d555 100644 --- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts @@ -1,11 +1,8 @@ -import { - combineTransactionSteps, - Extension, - findChildrenInRange, -} from "@tiptap/core"; +import { Extension, findChildrenInRange } from "@tiptap/core"; import { uuidv4 } from "lib0/random"; import { Fragment, Node, Slice } from "prosemirror-model"; import { Plugin, PluginKey } from "prosemirror-state"; +import { combineTransactionSteps } from "../../../api/combineTransactionSteps.js"; import { getChangedRanges } from "../../../api/getChangedRanges.js"; import { isSuggestedDeletionNode } from "../../../api/getBlockInfoFromPos.js"; @@ -155,12 +152,7 @@ const UniqueID = Extension.create({ } const { tr } = newState; const { types, generateID } = this.options; - // A single transaction already holds its steps: replaying thousands - // of them (e.g. a version diff) took seconds. - const transform = - transactions.length === 1 - ? transactions[0] - : combineTransactionSteps(oldState.doc, transactions as any); + const transform = combineTransactionSteps(oldState.doc, transactions); const { mapping } = transform; // get changed ranges based on the old state const changes = getChangedRanges(transform); From bf1c6f76cb9a872e1c67a3763a6d6a36d47b51a8 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 9 Oct 2026 11:14:56 +0200 Subject: [PATCH 07/13] test(core): cover split, join, block type and node mark steps in getChangedRanges The random test now also makes split, join, setBlockType, RemoveNodeMarkStep and deletes across blocks, and checks that all 8 step types occur. --- .../core/src/api/getChangedRanges.test.ts | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/core/src/api/getChangedRanges.test.ts b/packages/core/src/api/getChangedRanges.test.ts index b085410cf7..bab41c55ce 100644 --- a/packages/core/src/api/getChangedRanges.test.ts +++ b/packages/core/src/api/getChangedRanges.test.ts @@ -1,9 +1,13 @@ import { getChangedRanges as tiptapGetChangedRanges } from "@tiptap/core"; -import { Node, Schema } from "prosemirror-model"; +import { Node, Schema, Slice } from "prosemirror-model"; import { AddNodeMarkStep, Mapping, + RemoveNodeMarkStep, + ReplaceStep, Transform, + canJoin, + canSplit, findWrapping, liftTarget, } from "prosemirror-transform"; @@ -24,6 +28,7 @@ const schema = new Schema({ attrs: { level: { default: 0 } }, marks: "_", }, + heading: { group: "block", content: "text*", marks: "_" }, blockquote: { group: "block", content: "block+", marks: "_" }, text: {}, }, @@ -210,7 +215,7 @@ function randomChange(tr: Transform, rnd: (n: number) => number) { const text = "xyz".slice(rnd(3)); const docSize = tr.doc.content.size; - switch (rnd(10)) { + switch (rnd(15)) { case 0: tr.insert(textPos(rnd(size + 1)), schema.text("abc".slice(rnd(3)))); break; @@ -283,12 +288,41 @@ function randomChange(tr: Transform, rnd: (n: number) => number) { tr.setDocAttribute("title", String(rnd(3))); } break; + case 10: { + const at = textPos(rnd(size + 1)); + if (canSplit(tr.doc, at)) { + tr.split(at); + } + break; + } + case 11: + // What `tr.join` makes, without its throw when the join doesn't fit. + if (canJoin(tr.doc, pos)) { + tr.maybeStep(new ReplaceStep(pos - 1, pos + 1, Slice.empty, true)); + } + break; + case 12: + tr.setBlockType( + pos, + pos + node.nodeSize, + [schema.nodes.paragraph, schema.nodes.heading][rnd(2)], + ); + break; + case 13: + tr.maybeStep(new RemoveNodeMarkStep(pos, schema.marks.comment.create())); + break; + case 14: { + const from = rnd(docSize + 1); + tr.delete(from, from + rnd(docSize - from + 1)); + break; + } } } describe("getChangedRanges", () => { it("returns the same ranges as Tiptap for random changes", () => { const rnd = random(42); + const stepTypes = new Set(); for (let run = 0; run < 3000; run++) { const tr = new Transform( schema.node( @@ -303,10 +337,21 @@ describe("getChangedRanges", () => { for (let i = 0; i < stepCount; i++) { randomChange(tr, rnd); } + tr.steps.forEach((step) => stepTypes.add(step.constructor.name)); expect(ranges(getChangedRanges, tr)).toEqual( ranges(tiptapGetChangedRanges, tr), ); } + expect([...stepTypes].sort()).toEqual([ + "AddMarkStep", + "AddNodeMarkStep", + "AttrStep", + "DocAttrStep", + "RemoveMarkStep", + "RemoveNodeMarkStep", + "ReplaceAroundStep", + "ReplaceStep", + ]); }); it("returns the same ranges as Tiptap for many steps in document order and in reverse", () => { From 3bad02952f72c9e356286df427d4e20a4598cba7 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 9 Oct 2026 11:20:23 +0200 Subject: [PATCH 08/13] test(versioning): say why showing a version takes many steps --- packages/core/src/y/extensions/versionDiffPerformance.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/y/extensions/versionDiffPerformance.test.ts b/packages/core/src/y/extensions/versionDiffPerformance.test.ts index 9a9fc26ba7..5538799aeb 100644 --- a/packages/core/src/y/extensions/versionDiffPerformance.test.ts +++ b/packages/core/src/y/extensions/versionDiffPerformance.test.ts @@ -60,7 +60,7 @@ describe("Version diff performance", () => { // Plugins that map through every step (UniqueID, autolink, attributions) // take quadratic time, so a diff of thousands of blocks took seconds. it.fails("shows a version in one step", () => { - // To be fixed by https://github.com/yjs/y-prosemirror/pull/282 + // Fails: the binding renders the diff as one step per change. const blockCount = 300; const doc = new Y.Doc({ gc: false }); const seed = BlockNoteEditor.create(); From b1e8303b54dbd27dfb26e87e6afbdffbc3c2b7fe Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 9 Oct 2026 11:27:59 +0200 Subject: [PATCH 09/13] refactor(core): rename getChangedRange to getChangedRangeWithAttrs The name now says how it differs from ProseMirror's changedRange() and from getChangedRanges: it also covers attribute-only steps. --- packages/core/src/api/getBlocksChangedByTransaction.test.ts | 2 +- packages/core/src/api/getBlocksChangedByTransaction.ts | 4 ++-- .../api/{getChangedRange.ts => getChangedRangeWithAttrs.ts} | 2 +- packages/core/src/api/getChangedRanges.test.ts | 2 +- .../src/extensions/PreviousBlockType/PreviousBlockType.ts | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) rename packages/core/src/api/{getChangedRange.ts => getChangedRangeWithAttrs.ts} (97%) diff --git a/packages/core/src/api/getBlocksChangedByTransaction.test.ts b/packages/core/src/api/getBlocksChangedByTransaction.test.ts index 828894cf1d..9c5d85d7d4 100644 --- a/packages/core/src/api/getBlocksChangedByTransaction.test.ts +++ b/packages/core/src/api/getBlocksChangedByTransaction.test.ts @@ -656,7 +656,7 @@ describe("getBlocksChangedByTransaction - ranged optimization", () => { throw new Error("expected a block container"); } // Adding a mark produces an AddMarkStep, whose StepMap is empty — the case - // getChangedRange has to recover from the step's own from/to. + // getChangedRangeWithAttrs has to recover from the step's own from/to. tr.addMark( info.blockContent.beforePos + 1, info.blockContent.afterPos - 1, diff --git a/packages/core/src/api/getBlocksChangedByTransaction.ts b/packages/core/src/api/getBlocksChangedByTransaction.ts index c21487f994..c374ccc9fe 100644 --- a/packages/core/src/api/getBlocksChangedByTransaction.ts +++ b/packages/core/src/api/getBlocksChangedByTransaction.ts @@ -11,7 +11,7 @@ import { import type { BlockSchema } from "../schema/index.js"; import type { InlineContentSchema } from "../schema/inlineContent/types.js"; import type { StyleSchema } from "../schema/styles/types.js"; -import { getChangedRange } from "./getChangedRange.js"; +import { getChangedRangeWithAttrs } from "./getChangedRangeWithAttrs.js"; import { getNodeId } from "./getBlockInfoFromPos.js"; import { nodeToBlock } from "./nodeConversions/nodeToBlock.js"; import { isNodeBlock } from "./nodeUtil.js"; @@ -303,7 +303,7 @@ export function getBlocksChangedByTransaction< ]); // Changed range in the new doc; null means nothing changed. - const newRange = getChangedRange(combinedTransaction); + const newRange = getChangedRangeWithAttrs(combinedTransaction); if (!newRange) { return []; } diff --git a/packages/core/src/api/getChangedRange.ts b/packages/core/src/api/getChangedRangeWithAttrs.ts similarity index 97% rename from packages/core/src/api/getChangedRange.ts rename to packages/core/src/api/getChangedRangeWithAttrs.ts index c6f78de8bc..f905d074e8 100644 --- a/packages/core/src/api/getChangedRange.ts +++ b/packages/core/src/api/getChangedRangeWithAttrs.ts @@ -9,7 +9,7 @@ import type { Transform } from "prosemirror-transform"; * * O(steps), like `changedRange()`. Returns null when nothing changed. */ -export function getChangedRange( +export function getChangedRangeWithAttrs( transform: Transform, ): { from: number; to: number } | null { const { mapping, steps } = transform; diff --git a/packages/core/src/api/getChangedRanges.test.ts b/packages/core/src/api/getChangedRanges.test.ts index bab41c55ce..d3b6a5d4d7 100644 --- a/packages/core/src/api/getChangedRanges.test.ts +++ b/packages/core/src/api/getChangedRanges.test.ts @@ -125,7 +125,7 @@ const cases: [string, (tr: Transform) => void, number[][]][] = [ .removeMark(2, 3, schema.marks.strong), [[1, 4, 1, 4]], ], - // Steps without a position range are skipped (see `getChangedRange`). + // Steps without a position range are skipped (see `getChangedRangeWithAttrs`). ["set a node attribute", (tr) => tr.setNodeAttribute(5, "level", 1), []], ["set a doc attribute", (tr) => tr.setDocAttribute("title", "x"), []], [ diff --git a/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts b/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts index 026dc91ea8..ddbab38b35 100644 --- a/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts +++ b/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts @@ -2,7 +2,7 @@ import { findChildrenInRange } from "@tiptap/core"; import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import { getNodeId } from "../../api/getBlockInfoFromPos.js"; -import { getChangedRange } from "../../api/getChangedRange.js"; +import { getChangedRangeWithAttrs } from "../../api/getChangedRangeWithAttrs.js"; import { createExtension } from "../../editor/BlockNoteExtension.js"; const PLUGIN_KEY = new PluginKey(`previous-blocks`); @@ -74,9 +74,9 @@ export const PreviousBlockTypeExtension = createExtension(() => { } // Only check nodes in the changed range, not the whole document. - // getChangedRange() also covers attribute-only steps (AttrStep), so a + // getChangedRangeWithAttrs() also covers attribute-only steps (AttrStep), so a // block whose `level`/`index` changes with no content edit is caught. - const newRange = getChangedRange(transaction); + const newRange = getChangedRangeWithAttrs(transaction); if (!newRange) { return prev; } From 2ab613df1c2efce3569eb567e03a6d4044803a52 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 9 Oct 2026 11:28:07 +0200 Subject: [PATCH 10/13] refactor(core): load attribution authors from one changed range 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. --- .../src/y/extensions/AttributionExtension.ts | 34 ++++--------------- 1 file changed, 7 insertions(+), 27 deletions(-) diff --git a/packages/core/src/y/extensions/AttributionExtension.ts b/packages/core/src/y/extensions/AttributionExtension.ts index 2994ad33ee..6d6143577e 100644 --- a/packages/core/src/y/extensions/AttributionExtension.ts +++ b/packages/core/src/y/extensions/AttributionExtension.ts @@ -1,6 +1,5 @@ -import { AddNodeMarkStep } from "prosemirror-transform"; import { Plugin, PluginKey, type Transaction } from "prosemirror-state"; -import { getChangedRanges } from "../../api/getChangedRanges.js"; +import { getChangedRangeWithAttrs } from "../../api/getChangedRangeWithAttrs.js"; import { createExtension, createStore, @@ -132,34 +131,15 @@ export const AttributionExtension = createExtension( const store = createStore(undefined); - // Load the authors of the attribution marks in `tr`'s changed ranges, so + // Load the authors of the attribution marks in `tr`'s changed range, so // their colors/usernames resolve (colors then flow to marks via `syncRootVars`). - // `getChangedRanges` covers mark-only steps too — which suggestion mode adds - // over existing text and `tr.changedRange()` would miss. + // The range covers mark-only and node-mark steps too — which suggestion + // mode adds over existing text and `tr.changedRange()` would miss. const loadChangedUsers = (tr: Transaction) => { - const ranges = getChangedRanges(tr); - // Most changes are local (often several steps in one small span), so scan a - // single range spanning all of them rather than each range individually. - let from = Infinity; - let to = -Infinity; - for (const { newRange } of ranges) { - from = Math.min(from, newRange.from); - to = Math.max(to, newRange.to); - } - + const range = getChangedRangeWithAttrs(tr); const ids = new Set(); - // AddNodeMarkStep has an empty position map, so getChangedRanges cannot - // locate its node. Load its authors directly from the added mark. - for (const step of tr.steps) { - if ( - step instanceof AddNodeMarkStep && - step.mark.type.name in ATTRIBUTION_MARK_TYPES - ) { - getAttributionUserIds(step.mark).forEach((id) => ids.add(id)); - } - } - if (ranges.length > 0) { - tr.doc.nodesBetween(from, to, (node) => { + if (range) { + tr.doc.nodesBetween(range.from, range.to, (node) => { for (const mark of node.marks) { if ( ATTRIBUTION_MARK_TYPES[ From 343ea53fc6b545777a44718495a962cb2623ed31 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 9 Oct 2026 11:28:09 +0200 Subject: [PATCH 11/13] docs(core): note that getChangedRanges could be deprecated --- packages/core/src/api/getChangedRanges.ts | 6 +++++- .../src/extensions/tiptap-extensions/UniqueID/UniqueID.ts | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/core/src/api/getChangedRanges.ts b/packages/core/src/api/getChangedRanges.ts index a9767cb663..8e8439459f 100644 --- a/packages/core/src/api/getChangedRanges.ts +++ b/packages/core/src/api/getChangedRanges.ts @@ -16,8 +16,12 @@ import type { Step, StepMap, Transform } from "prosemirror-transform"; * only compares ranges that can contain each other. For steps in document * order, both are close to linear. * - * See also `getChangedRange` (one range covering all changes, including + * See also `getChangedRangeWithAttrs` (one range covering all changes, including * attribute-only steps, which this function skips like Tiptap's). + * + * Consider deprecating this function: a list of ranges is costly to compute + * exactly, which is what makes this code complex. Its remaining callers + * (UniqueID, autolink) could work from one range or from input events. */ export function getChangedRanges(transform: Transform): ChangedRange[] { const { mapping, steps } = transform; diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts index 2239b0d555..4e42431907 100644 --- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts @@ -155,6 +155,9 @@ const UniqueID = Extension.create({ const transform = combineTransactionSteps(oldState.doc, transactions); const { mapping } = transform; // get changed ranges based on the old state + // TODO: one range for all changes (`getChangedRangeWithAttrs`) would + // be simpler and also find duplicates across separate changes, but it + // changes which ids are rewritten. const changes = getChangedRanges(transform); changes.forEach(({ newRange }) => { From d0464236ad39b8be4e44bab00ae438599323ffe1 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 9 Oct 2026 12:03:35 +0200 Subject: [PATCH 12/13] docs(core): say that one changed range needs investigation first --- packages/core/src/api/getChangedRanges.ts | 3 ++- .../src/extensions/tiptap-extensions/UniqueID/UniqueID.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/core/src/api/getChangedRanges.ts b/packages/core/src/api/getChangedRanges.ts index 8e8439459f..5e8bc09e42 100644 --- a/packages/core/src/api/getChangedRanges.ts +++ b/packages/core/src/api/getChangedRanges.ts @@ -21,7 +21,8 @@ import type { Step, StepMap, Transform } from "prosemirror-transform"; * * Consider deprecating this function: a list of ranges is costly to compute * exactly, which is what makes this code complex. Its remaining callers - * (UniqueID, autolink) could work from one range or from input events. + * (UniqueID, autolink) might work from one range or from input events, but + * what that changes in each of them needs a careful look first. */ export function getChangedRanges(transform: Transform): ChangedRange[] { const { mapping, steps } = transform; diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts index 4e42431907..7c57b10b75 100644 --- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts @@ -156,8 +156,8 @@ const UniqueID = Extension.create({ const { mapping } = transform; // get changed ranges based on the old state // TODO: one range for all changes (`getChangedRangeWithAttrs`) would - // be simpler and also find duplicates across separate changes, but it - // changes which ids are rewritten. + // be simpler and faster, and might be a better solution, but needs a + // careful look at which ids it would rewrite. const changes = getChangedRanges(transform); changes.forEach(({ newRange }) => { From 3b523fd09a2aad1fea899436da45aa3ad9c01620 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 9 Oct 2026 14:30:26 +0200 Subject: [PATCH 13/13] refactor(core): enforce read-only combined transactions, and drop dead checks combineTransactionSteps can return the live transaction, so it now returns only what callers read, and getChangedRanges and getChangedRangeWithAttrs accept just that. UniqueID's later check for suggested deletions was unreachable (they return earlier). The step-count test asserts the one transaction the plugins rely on, instead of failing. --- packages/core/src/api/combineTransactionSteps.ts | 5 +++-- packages/core/src/api/getChangedRangeWithAttrs.ts | 2 +- packages/core/src/api/getChangedRanges.test.ts | 5 ++++- packages/core/src/api/getChangedRanges.ts | 7 +++++-- .../extensions/tiptap-extensions/UniqueID/UniqueID.ts | 4 +--- .../src/y/extensions/versionDiffPerformance.test.ts | 10 +++++----- 6 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/core/src/api/combineTransactionSteps.ts b/packages/core/src/api/combineTransactionSteps.ts index b32ed70cd0..e27f7ced36 100644 --- a/packages/core/src/api/combineTransactionSteps.ts +++ b/packages/core/src/api/combineTransactionSteps.ts @@ -8,12 +8,13 @@ import type { Transform } from "prosemirror-transform"; * `oldDoc` is returned as it is. Replaying its steps into a new transform * took seconds for a version diff, which has thousands of steps. * - * The result can be the transaction itself, so don't add steps to it. + * The result can be the transaction itself, so it only gives what callers + * read: adding steps to it would change that transaction. */ export function combineTransactionSteps( oldDoc: Node, transactions: readonly Transaction[], -): Transform { +): Pick { if (transactions.length === 1 && transactions[0].before === oldDoc) { return transactions[0]; } diff --git a/packages/core/src/api/getChangedRangeWithAttrs.ts b/packages/core/src/api/getChangedRangeWithAttrs.ts index f905d074e8..ebd21a52a3 100644 --- a/packages/core/src/api/getChangedRangeWithAttrs.ts +++ b/packages/core/src/api/getChangedRangeWithAttrs.ts @@ -10,7 +10,7 @@ import type { Transform } from "prosemirror-transform"; * O(steps), like `changedRange()`. Returns null when nothing changed. */ export function getChangedRangeWithAttrs( - transform: Transform, + transform: Pick, ): { from: number; to: number } | null { const { mapping, steps } = transform; let from = Number.POSITIVE_INFINITY; diff --git a/packages/core/src/api/getChangedRanges.test.ts b/packages/core/src/api/getChangedRanges.test.ts index d3b6a5d4d7..6c8ec6357e 100644 --- a/packages/core/src/api/getChangedRanges.test.ts +++ b/packages/core/src/api/getChangedRanges.test.ts @@ -49,7 +49,10 @@ function threeParagraphs() { } /** `[old from, old to, new from, new to]` of each changed range. */ -function ranges(implementation: typeof getChangedRanges, transform: Transform) { +function ranges( + implementation: (transform: Transform) => ReturnType, + transform: Transform, +) { return implementation(transform).map(({ oldRange, newRange }) => [ oldRange.from, oldRange.to, diff --git a/packages/core/src/api/getChangedRanges.ts b/packages/core/src/api/getChangedRanges.ts index 5e8bc09e42..90aa72cc7e 100644 --- a/packages/core/src/api/getChangedRanges.ts +++ b/packages/core/src/api/getChangedRanges.ts @@ -24,14 +24,17 @@ import type { Step, StepMap, Transform } from "prosemirror-transform"; * (UniqueID, autolink) might work from one range or from input events, but * what that changes in each of them needs a careful look first. */ -export function getChangedRanges(transform: Transform): ChangedRange[] { +export function getChangedRanges( + transform: Pick, +): ChangedRange[] { const { mapping, steps } = transform; const maps = mapping.maps; // A mirrored mapping (from rebasing steps) recovers positions across step // pairs, which the skips below don't model. for (let i = 0; i < maps.length; i++) { if (mapping.getMirror(i) !== undefined) { - return getChangedRangesQuadratic(transform); + // Tiptap's function only reads `mapping` and `steps`. + return getChangedRangesQuadratic({ mapping, steps } as Transform); } } diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts index 7c57b10b75..e6daff5ca7 100644 --- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts @@ -4,7 +4,6 @@ import { Fragment, Node, Slice } from "prosemirror-model"; import { Plugin, PluginKey } from "prosemirror-state"; import { combineTransactionSteps } from "../../../api/combineTransactionSteps.js"; import { getChangedRanges } from "../../../api/getChangedRanges.js"; -import { isSuggestedDeletionNode } from "../../../api/getBlockInfoFromPos.js"; /** * Code from Tiptap UniqueID extension (https://tiptap.dev/api/extensions/unique-id) @@ -226,8 +225,7 @@ const UniqueID = Extension.create({ const newNode = duplicatedNewIds.includes(id) && mapping.invert().mapResult(pos).deleted; - // purposefully skip rewriting ids for suggested deletion nodes, to avoid modifying them - if (newNode && !isSuggestedDeletionNode(node)) { + if (newNode) { tr.setNodeMarkup(pos, undefined, { ...node.attrs, id: generateID(), diff --git a/packages/core/src/y/extensions/versionDiffPerformance.test.ts b/packages/core/src/y/extensions/versionDiffPerformance.test.ts index 5538799aeb..2079f5566e 100644 --- a/packages/core/src/y/extensions/versionDiffPerformance.test.ts +++ b/packages/core/src/y/extensions/versionDiffPerformance.test.ts @@ -57,10 +57,10 @@ function recordSteps(editor: Editor): number[] { } describe("Version diff performance", () => { - // Plugins that map through every step (UniqueID, autolink, attributions) - // take quadratic time, so a diff of thousands of blocks took seconds. - it.fails("shows a version in one step", () => { - // Fails: the binding renders the diff as one step per change. + // The binding renders a diff as one transaction, with a step per change. + // Plugins then handle it once, without replaying its steps + // (`combineTransactionSteps`), so a large diff stays fast. + it("shows a version in one transaction", () => { const blockCount = 300; const doc = new Y.Doc({ gc: false }); const seed = BlockNoteEditor.create(); @@ -96,6 +96,6 @@ describe("Version diff performance", () => { target: { type: "snapshot", id: "after" }, }); - expect(steps).toEqual([1]); + expect(steps).toHaveLength(1); }); });