From 1080b3870c17db7f101623dc2e089a9fd231e15c Mon Sep 17 00:00:00 2001 From: adarshsm <24850536+adarshsm@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:36:51 +0530 Subject: [PATCH] fix(core): install sub-extension plugins when registering at runtime `replaceExtension` (behind `registerExtension`) collected ProseMirror plugins only from the instances `addExtension` returned. `addExtension` also registers the extensions declared in `blockNoteExtensions`, but it returns just the parent, so their plugins were never installed. `CollaborationExtension` keeps all of its plugins (YSync, YCursor, YUndo, ...) in sub-extensions, so registering it on an existing editor installed nothing and edits never reached the Y.XmlFragment. Collect every extension added in the call and order them by the same dependency priority used when the editor is created. The order matters: y-prosemirror's cursor plugin reads the sync plugin's state in `init`, so registration throws if YCursor ends up before YSync. Closes #3175 --- .../ExtensionManager/ExtensionManager.test.ts | 27 +++++++++++++ .../editor/managers/ExtensionManager/index.ts | 38 +++++++++++++------ .../core/src/yjs/extensions/index.test.ts | 37 ++++++++++++++++++ 3 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/yjs/extensions/index.test.ts diff --git a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts index 5e0edadae5..2ec705ab42 100644 --- a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts +++ b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts @@ -334,3 +334,30 @@ describe("ExtensionManager ordering", () => { ); }); }); + +describe("ExtensionManager runtime registration", () => { + it("installs the plugins of blockNoteExtensions declared by an extension registered at runtime", () => { + const subKey = new PluginKey("runtime-sub"); + const parentKey = new PluginKey("runtime-parent"); + + const sub = createExtension(() => ({ + key: "runtime-sub", + prosemirrorPlugins: [new Plugin({ key: subKey })], + })); + const parent = createExtension(() => ({ + key: "runtime-parent", + blockNoteExtensions: [sub()], + prosemirrorPlugins: [new Plugin({ key: parentKey })], + })); + + const editor = createMountedEditor([]); + editor.registerExtension(parent()); + + expect(editor.getExtension(sub)).toBeDefined(); + expect(pluginIndex(editor, subKey)).not.toBe(-1); + // Same order as when the extension is passed to the editor up front. + expect(pluginIndex(editor, subKey)).toBeLessThan( + pluginIndex(editor, parentKey), + ); + }); +}); diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index 5a3929e8cf..2af8020eeb 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -326,9 +326,15 @@ export class ExtensionManager { .concat(toRegister) .filter(Boolean) as (Extension | ExtensionFactoryInstance)[]; - const registeredExtensions = newExtensions - .map((ext) => this.addExtension(ext)) - .filter(Boolean) as Extension[]; + const extensionsBefore = new Set(this.extensions); + newExtensions.forEach((ext) => this.addExtension(ext)); + // `addExtension` also registers the sub-extensions declared in + // `blockNoteExtensions`, so take everything it added, in the same order + // their plugins get when the editor is created. + const getPriority = this.getExtensionPriority(); + const registeredExtensions = this.extensions + .filter((extension) => !extensionsBefore.has(extension)) + .sort((a, b) => getPriority(b.key) - getPriority(a.key)); const pluginsToAdd: Plugin[] = []; for (const extension of registeredExtensions) { @@ -402,16 +408,11 @@ export class ExtensionManager { } /** - * Get all the extensions that are registered to the editor + * Returns a function that gives the priority of a registered extension + * (higher runs first), based on `runsBefore` and `blockNoteExtensions`. */ - public getTiptapExtensions(): AnyTiptapExtension[] { - // Start with the default tiptap extensions - const tiptapExtensions = getDefaultTiptapExtensions( - this.editor, - this.options, - ).filter((extension) => !this.disabledExtensions.has(extension.name)); - - const getPriority = sortByDependencies( + private getExtensionPriority() { + return sortByDependencies( this.extensions.map((extension) => { // A sub-extension declared via `blockNoteExtensions` must run before the // extension(s) that declared it, so we merge those parents into its @@ -426,6 +427,19 @@ export class ExtensionManager { }; }), ); + } + + /** + * Get all the extensions that are registered to the editor + */ + public getTiptapExtensions(): AnyTiptapExtension[] { + // Start with the default tiptap extensions + const tiptapExtensions = getDefaultTiptapExtensions( + this.editor, + this.options, + ).filter((extension) => !this.disabledExtensions.has(extension.name)); + + const getPriority = this.getExtensionPriority(); const inputRulesByPriority = new Map(); for (const extension of this.extensions) { diff --git a/packages/core/src/yjs/extensions/index.test.ts b/packages/core/src/yjs/extensions/index.test.ts new file mode 100644 index 0000000000..9e59f220df --- /dev/null +++ b/packages/core/src/yjs/extensions/index.test.ts @@ -0,0 +1,37 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from "vite-plus/test"; +import { Awareness } from "y-protocols/awareness"; +import * as Y from "yjs"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { CollaborationExtension } from "./index.js"; + +describe("CollaborationExtension", () => { + it("syncs edits when registered on an existing editor", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("doc"); + const editor = BlockNoteEditor.create({ + disableExtensions: ["history"], + initialContent: [{ type: "paragraph", id: "initialBlockId" }], + }); + editor.mount(document.createElement("div")); + + editor.registerExtension( + CollaborationExtension({ + fragment, + user: { name: "Test User", color: "#FF0000" }, + provider: { awareness: new Awareness(doc) }, + }), + ); + editor.replaceBlocks(editor.document, [ + { type: "paragraph", content: "hello" }, + ]); + + expect(fragment.toJSON()).toContain("hello"); + + editor.unmount(); + doc.destroy(); + }); +});