From de1f82a0279470f9663ea00b235f6a78964c2147 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 4 Nov 2025 16:29:25 +0100 Subject: [PATCH 1/7] fix(unique-id): do not attempt to append to y-sync plugin transactions This is because of a bug that we've had for a while, but I was never able to trace down. - We set an initial block id for the initial content (always "initialBlockId" when collaboration is detected) - `y-sync` replaces the document with what it "sees" in the `Y.XmlFragment` we gave it - This causes a transaction to replace the initial content (replacing it with an empty document with a `blockContainer` containing an id of `null`) - The unique id plugin sees this & attempts to correct the missing id, issuing a new transaction - This means there is now a write to the `Y.XmlFragment`\ This write can happen independently to the provider actually synchronizing the content. Meaning, that the `Y.XmlFragment` and `tr.doc` are out of sync when `y-prosemirror` attempts to [restore the selection](https://github.com/yjs/y-prosemirror/blob/ef35266d660c3cd76a491fde243b0c6bee25d585/src/plugins/sync-plugin.js#L634). This is ultimately because `y-prosemirror` is listening to the Y.Doc _after_ it already is accepting the change. Which is totally valid, but ProseMirror doesn't offer a great way to keep these values in-sync. The fix here is to just stop the unique-id extension from attempting to amend any `y-prosemirror` transactions. Ultimately, the remote editor should have already written the correct ID (since to the remote editor, it was the local editor - therefore, the unique-id extension should have run). --- packages/core/src/extensions/UniqueID/UniqueID.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/core/src/extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/UniqueID/UniqueID.ts index 23f6591256..0cce20f38a 100644 --- a/packages/core/src/extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/UniqueID/UniqueID.ts @@ -130,10 +130,18 @@ const UniqueID = Extension.create({ addProseMirrorPlugins() { let dragSourceElement: any = null; let transformPasted = false; + let isFirstCollaborationTransaction = true; return [ new Plugin({ key: new PluginKey("uniqueID"), appendTransaction: (transactions, oldState, newState) => { + if ( + isFirstCollaborationTransaction && + transactions.some((tr) => tr.getMeta("y-sync$")) + ) { + isFirstCollaborationTransaction = false; + return; + } const docChanges = transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc); From efa0ab81b52277326cbbfdaeda46a8472fcb2ec0 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 5 Nov 2025 10:19:20 +0100 Subject: [PATCH 2/7] fix: reinstate `createAndFill` hack, to solve unique id issue --- packages/core/src/editor/BlockNoteEditor.ts | 19 +++++++++++++++++++ .../fork-yjs-snap-editor-forked.json | 4 ++-- .../__snapshots__/fork-yjs-snap-editor.json | 4 ++-- .../__snapshots__/fork-yjs-snap-forked.html | 2 +- .../__snapshots__/fork-yjs-snap.html | 2 +- .../core/src/extensions/UniqueID/UniqueID.ts | 8 -------- 6 files changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 9a5451763e..7149f2697b 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -917,6 +917,25 @@ export class BlockNoteEditor< ); } + // When y-prosemirror creates an empty document, the `blockContainer` node is created with an `id` of `null`. + // This causes the unique id extension to generate a new id for the initial block, which is not what we want + // Since it will be randomly generated & cause there to be more updates to the ydoc + // This is a hack to make it so that anytime `schema.doc.createAndFill` is called, the initial block id is already set to "initialBlockId" + let cache: Node | undefined = undefined; + const oldCreateAndFill = this.pmSchema.nodes.doc.createAndFill; + this.pmSchema.nodes.doc.createAndFill = (...args: any) => { + if (cache) { + return cache; + } + const ret = oldCreateAndFill.apply(this.pmSchema.nodes.doc, args)!; + + // create a copy that we can mutate (otherwise, assigning attrs is not safe and corrupts the pm state) + const jsonNode = ret.toJSON(); + jsonNode.content[0].content[0].attrs.id = "initialBlockId"; + + cache = Node.fromJSON(this.pmSchema, jsonNode); + return cache; + }; this.pmSchema.cached.blockNoteEditor = this; // Initialize managers diff --git a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor-forked.json b/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor-forked.json index 704076c85a..40b57a8845 100644 --- a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor-forked.json +++ b/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor-forked.json @@ -8,7 +8,7 @@ "type": "text", }, ], - "id": "3", + "id": "1", "props": { "backgroundColor": "default", "textAlignment": "left", @@ -19,7 +19,7 @@ { "children": [], "content": [], - "id": "4", + "id": "initialBlockId", "props": { "backgroundColor": "default", "textAlignment": "left", diff --git a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor.json b/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor.json index dd12eb46bd..ce138a982c 100644 --- a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor.json +++ b/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor.json @@ -8,7 +8,7 @@ "type": "text", }, ], - "id": "1", + "id": "0", "props": { "backgroundColor": "default", "textAlignment": "left", @@ -19,7 +19,7 @@ { "children": [], "content": [], - "id": "2", + "id": "initialBlockId", "props": { "backgroundColor": "default", "textAlignment": "left", diff --git a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-forked.html b/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-forked.html index 6442ffd900..e05b1b0347 100644 --- a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-forked.html +++ b/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-forked.html @@ -1 +1 @@ -Hello World \ No newline at end of file +Hello World \ No newline at end of file diff --git a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap.html b/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap.html index b9adef68d0..a29d5007f9 100644 --- a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap.html +++ b/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap.html @@ -1 +1 @@ -Hello \ No newline at end of file +Hello \ No newline at end of file diff --git a/packages/core/src/extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/UniqueID/UniqueID.ts index 0cce20f38a..23f6591256 100644 --- a/packages/core/src/extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/UniqueID/UniqueID.ts @@ -130,18 +130,10 @@ const UniqueID = Extension.create({ addProseMirrorPlugins() { let dragSourceElement: any = null; let transformPasted = false; - let isFirstCollaborationTransaction = true; return [ new Plugin({ key: new PluginKey("uniqueID"), appendTransaction: (transactions, oldState, newState) => { - if ( - isFirstCollaborationTransaction && - transactions.some((tr) => tr.getMeta("y-sync$")) - ) { - isFirstCollaborationTransaction = false; - return; - } const docChanges = transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc); From ecf59904029ee7206696a4098fb2324c4857d321 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 5 Nov 2025 10:39:46 +0100 Subject: [PATCH 3/7] test: update snaps --- .../test/commands/__snapshots__/removeBlocks.test.ts.snap | 6 +++--- .../clipboard/paste/__snapshots__/text/html/pasteTable.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/xl-multi-column/src/test/commands/__snapshots__/removeBlocks.test.ts.snap b/packages/xl-multi-column/src/test/commands/__snapshots__/removeBlocks.test.ts.snap index f6902f31bc..b7fa66715a 100644 --- a/packages/xl-multi-column/src/test/commands/__snapshots__/removeBlocks.test.ts.snap +++ b/packages/xl-multi-column/src/test/commands/__snapshots__/removeBlocks.test.ts.snap @@ -219,7 +219,7 @@ exports[`Test removeBlocks > Remove all columns in columnList 1`] = ` { "children": [], "content": [], - "id": "1", + "id": "initialBlockId", "props": { "backgroundColor": "default", "textAlignment": "left", @@ -240,7 +240,7 @@ exports[`Test removeBlocks > Remove all columns in columnList 1`] = ` { "children": [], "content": [], - "id": "3", + "id": "initialBlockId", "props": { "backgroundColor": "default", "textAlignment": "left", @@ -250,7 +250,7 @@ exports[`Test removeBlocks > Remove all columns in columnList 1`] = ` }, ], "content": undefined, - "id": "2", + "id": "1", "props": { "width": 1, }, diff --git a/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteTable.json b/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteTable.json index 871e788cbf..535fdc2c2d 100644 --- a/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteTable.json +++ b/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteTable.json @@ -105,7 +105,7 @@ ], "type": "tableContent", }, - "id": "2", + "id": "initialBlockId", "props": { "textColor": "default", }, From a3539685f5a294d09f9bacecbe4d472cd2fc1583 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 5 Nov 2025 13:04:18 +0100 Subject: [PATCH 4/7] fix: do a full copy --- packages/core/src/editor/BlockNoteEditor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 7149f2697b..2292cf2fb9 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -930,7 +930,7 @@ export class BlockNoteEditor< const ret = oldCreateAndFill.apply(this.pmSchema.nodes.doc, args)!; // create a copy that we can mutate (otherwise, assigning attrs is not safe and corrupts the pm state) - const jsonNode = ret.toJSON(); + const jsonNode = JSON.parse(JSON.stringify(ret.toJSON())); jsonNode.content[0].content[0].attrs.id = "initialBlockId"; cache = Node.fromJSON(this.pmSchema, jsonNode); From 137cbc0ff999948fe226ad6f8946255ef4546cc8 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 5 Nov 2025 13:12:06 +0100 Subject: [PATCH 5/7] test: put test cases back --- .../__snapshots__/fork-yjs-snap-editor-forked.json | 4 ++-- .../Collaboration/__snapshots__/fork-yjs-snap-editor.json | 2 +- .../Collaboration/__snapshots__/fork-yjs-snap-forked.html | 2 +- .../extensions/Collaboration/__snapshots__/fork-yjs-snap.html | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor-forked.json b/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor-forked.json index 40b57a8845..786e727b7e 100644 --- a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor-forked.json +++ b/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor-forked.json @@ -8,7 +8,7 @@ "type": "text", }, ], - "id": "1", + "id": "2", "props": { "backgroundColor": "default", "textAlignment": "left", @@ -19,7 +19,7 @@ { "children": [], "content": [], - "id": "initialBlockId", + "id": "3", "props": { "backgroundColor": "default", "textAlignment": "left", diff --git a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor.json b/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor.json index ce138a982c..e7580c5b7b 100644 --- a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor.json +++ b/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor.json @@ -19,7 +19,7 @@ { "children": [], "content": [], - "id": "initialBlockId", + "id": "1", "props": { "backgroundColor": "default", "textAlignment": "left", diff --git a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-forked.html b/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-forked.html index e05b1b0347..8957bbb259 100644 --- a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-forked.html +++ b/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-forked.html @@ -1 +1 @@ -Hello World \ No newline at end of file +Hello World \ No newline at end of file diff --git a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap.html b/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap.html index a29d5007f9..063ddebeac 100644 --- a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap.html +++ b/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap.html @@ -1 +1 @@ -Hello \ No newline at end of file +Hello \ No newline at end of file From 6ce83dd1eb31b1445b50207f2932fab0909adfbf Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 5 Nov 2025 13:12:36 +0100 Subject: [PATCH 6/7] test: put test cases back --- .../test/commands/__snapshots__/removeBlocks.test.ts.snap | 6 +++--- .../clipboard/paste/__snapshots__/text/html/pasteTable.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/xl-multi-column/src/test/commands/__snapshots__/removeBlocks.test.ts.snap b/packages/xl-multi-column/src/test/commands/__snapshots__/removeBlocks.test.ts.snap index b7fa66715a..f6902f31bc 100644 --- a/packages/xl-multi-column/src/test/commands/__snapshots__/removeBlocks.test.ts.snap +++ b/packages/xl-multi-column/src/test/commands/__snapshots__/removeBlocks.test.ts.snap @@ -219,7 +219,7 @@ exports[`Test removeBlocks > Remove all columns in columnList 1`] = ` { "children": [], "content": [], - "id": "initialBlockId", + "id": "1", "props": { "backgroundColor": "default", "textAlignment": "left", @@ -240,7 +240,7 @@ exports[`Test removeBlocks > Remove all columns in columnList 1`] = ` { "children": [], "content": [], - "id": "initialBlockId", + "id": "3", "props": { "backgroundColor": "default", "textAlignment": "left", @@ -250,7 +250,7 @@ exports[`Test removeBlocks > Remove all columns in columnList 1`] = ` }, ], "content": undefined, - "id": "1", + "id": "2", "props": { "width": 1, }, diff --git a/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteTable.json b/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteTable.json index 535fdc2c2d..871e788cbf 100644 --- a/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteTable.json +++ b/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteTable.json @@ -105,7 +105,7 @@ ], "type": "tableContent", }, - "id": "initialBlockId", + "id": "2", "props": { "textColor": "default", }, From c77b5785b0e3b983749873a419a5c078d8358ce0 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 6 Nov 2025 11:56:59 +0100 Subject: [PATCH 7/7] test: add a test case showing the inital block id behavior --- .../core/src/editor/BlockNoteEditor.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/core/src/editor/BlockNoteEditor.test.ts b/packages/core/src/editor/BlockNoteEditor.test.ts index 0f73ac2ea3..db62dafb76 100644 --- a/packages/core/src/editor/BlockNoteEditor.test.ts +++ b/packages/core/src/editor/BlockNoteEditor.test.ts @@ -5,6 +5,7 @@ import { } from "../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "./BlockNoteEditor.js"; import { BlockNoteExtension } from "./BlockNoteExtension.js"; +import * as Y from "yjs"; /** * @vitest-environment jsdom @@ -146,3 +147,67 @@ it("onCreate event", () => { }); expect(created).toBe(true); }); + +it("sets an initial block id when using Y.js", async () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("doc"); + let transactionCount = 0; + const editor = BlockNoteEditor.create({ + collaboration: { + fragment, + user: { name: "Hello", color: "#FFFFFF" }, + provider: null, + }, + _tiptapOptions: { + onTransaction: () => { + transactionCount++; + }, + }, + }); + + editor.mount(document.createElement("div")); + + expect(editor.prosemirrorState.doc.toJSON()).toMatchInlineSnapshot(` + { + "content": [ + { + "content": [ + { + "attrs": { + "id": "initialBlockId", + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "blockGroup", + }, + ], + "type": "doc", + } + `); + expect(transactionCount).toBe(1); + // The fragment should not be modified yet, since the editor's content is only the initial content + expect(fragment.toJSON()).toMatchInlineSnapshot(`""`); + + editor.replaceBlocks(editor.document, [ + { + type: "paragraph", + content: [{ text: "Hello", styles: {}, type: "text" }], + }, + ]); + expect(transactionCount).toBe(2); + // Only after a real modification is made, will the fragment be updated + expect(fragment.toJSON()).toMatchInlineSnapshot( + `"Hello"`, + ); +});