From b2bfd9853a5d92c79cc674fbf24db6e640fc3966 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 29 Jun 2026 11:37:33 +0200 Subject: [PATCH] feat(core): de-duplicate and order extensions by key in ExtensionManager De-duplication: when an extension with a given key is already registered, skip registering another one with the same key (the first registration wins). This lets an extension declare a dependency on another via `blockNoteExtensions` without conflicting when that same extension is also registered directly by the user. Ordering: a sub-extension declared via `blockNoteExtensions` is a dependency of the extension that declares it, so it now runs before its parent. The dependency is recorded as the sub is resolved (before the de-duplication check), so it holds even when multiple parents declare the same sub-extension and when a parent has a higher base priority via `runsBefore`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ExtensionManager/ExtensionManager.test.ts | 249 ++++++++++++++++++ .../editor/managers/ExtensionManager/index.ts | 55 +++- 2 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts diff --git a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts new file mode 100644 index 0000000000..4c97e8849c --- /dev/null +++ b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts @@ -0,0 +1,249 @@ +/** + * @vitest-environment jsdom + */ +import { Plugin, PluginKey } from "prosemirror-state"; +import { describe, expect, it } from "vite-plus/test"; + +import { createExtension } from "../../BlockNoteExtension.js"; +import { BlockNoteEditor } from "../../BlockNoteEditor.js"; + +function createMountedEditor( + extensions: BlockNoteEditor["options"]["extensions"], +) { + const editor = BlockNoteEditor.create({ extensions }); + editor.mount(document.createElement("div")); + return editor; +} + +/** + * Returns the index of the plugin identified by `key` within the editor's + * ProseMirror plugin list. A lower index means it runs/applies earlier. + */ +function pluginIndex( + editor: BlockNoteEditor, + key: PluginKey, +): number { + return editor.prosemirrorState.plugins.findIndex( + (plugin) => (plugin as any).spec?.key === key, + ); +} + +describe("ExtensionManager de-duplication by key", () => { + it("registers only the first extension when two share a key", () => { + let mountCount = 0; + + const first = createExtension(() => ({ + key: "dup", + value: "first", + mount() { + mountCount++; + return () => {}; + }, + })); + const second = createExtension(() => ({ + key: "dup", + value: "second", + mount() { + mountCount++; + return () => {}; + }, + })); + + const editor = createMountedEditor([first(), second()]); + + // The first registration wins. + expect(editor.getExtension(first)?.value).toBe("first"); + // The second registration was skipped entirely. + expect(editor.getExtension(second)).toBeUndefined(); + expect((editor.extensions.get("dup") as any)?.value).toBe("first"); + expect( + [...editor.extensions.values()].filter((e) => e.key === "dup").length, + ).toBe(1); + // Only the registered extension was mounted. + expect(mountCount).toBe(1); + }); + + it("does not re-register a dependency declared via blockNoteExtensions when it is already registered", () => { + // Two distinct factories sharing the key "dep". + const depDirect = createExtension(() => ({ + key: "dep", + value: "direct", + })); + const depFromParent = createExtension(() => ({ + key: "dep", + value: "from-parent", + })); + const parent = createExtension(() => ({ + key: "parent", + blockNoteExtensions: [depFromParent()], + })); + + // Register the dependency directly first, then a parent that also pulls in + // its own "dep" via blockNoteExtensions. + const editor = createMountedEditor([depDirect(), parent()]); + + expect(editor.getExtension(parent)).toBeDefined(); + // The directly-registered dependency wins; the one declared by the parent + // is skipped rather than overriding it. + expect(editor.getExtension(depDirect)?.value).toBe("direct"); + expect(editor.getExtension(depFromParent)).toBeUndefined(); + expect((editor.extensions.get("dep") as any)?.value).toBe("direct"); + }); + + it("registers a dependency declared via blockNoteExtensions when it isn't registered otherwise", () => { + const dep = createExtension(() => ({ + key: "lonely-dep", + value: "dep", + })); + const parent = createExtension(() => ({ + key: "lonely-parent", + blockNoteExtensions: [dep()], + })); + + const editor = createMountedEditor([parent()]); + + expect(editor.getExtension(parent)).toBeDefined(); + expect(editor.getExtension(dep)?.value).toBe("dep"); + }); +}); + +describe("ExtensionManager ordering", () => { + it("orders an extension before another it declares in runsBefore", () => { + const firstKey = new PluginKey("rb-first"); + const secondKey = new PluginKey("rb-second"); + + const first = createExtension(() => ({ + key: "rb-first", + runsBefore: ["rb-second"], + prosemirrorPlugins: [new Plugin({ key: firstKey })], + })); + const second = createExtension(() => ({ + key: "rb-second", + prosemirrorPlugins: [new Plugin({ key: secondKey })], + })); + + // Register in the "wrong" order to prove runsBefore — not array order — + // determines precedence. + const editor = createMountedEditor([second(), first()]); + + expect(pluginIndex(editor, firstKey)).toBeLessThan( + pluginIndex(editor, secondKey), + ); + }); + + it("flattens sub-extensions and runs the parent after its blockNoteExtensions dependency", () => { + const subKey = new PluginKey("sub-order"); + const parentKey = new PluginKey("parent-order"); + + const sub = createExtension(() => ({ + key: "ordered-sub", + prosemirrorPlugins: [new Plugin({ key: subKey })], + })); + const parent = createExtension(() => ({ + key: "ordered-parent", + blockNoteExtensions: [sub()], + prosemirrorPlugins: [new Plugin({ key: parentKey })], + })); + + const editor = createMountedEditor([parent()]); + + // The sub-extension is flattened into the editor's extensions... + expect(editor.getExtension(sub)).toBeDefined(); + expect(editor.getExtension(parent)).toBeDefined(); + + // ...and because the parent declares the sub as a dependency, the sub runs + // before the parent (even though the parent is registered first). + expect(pluginIndex(editor, subKey)).toBeLessThan( + pluginIndex(editor, parentKey), + ); + }); + + it("forces a blockNoteExtensions dependency before a parent that has a higher base priority", () => { + // The parent declares `runsBefore` on an unrelated extension, which raises + // its priority above the default. Without an explicit dependency edge, the + // higher-priority parent would run before its sub. The dependency must + // override that so the sub still runs first. + const subKey = new PluginKey("forced-sub"); + const parentKey = new PluginKey("forced-parent"); + const otherKey = new PluginKey("forced-other"); + + const other = createExtension(() => ({ + key: "forced-other", + prosemirrorPlugins: [new Plugin({ key: otherKey })], + })); + const sub = createExtension(() => ({ + key: "forced-sub", + prosemirrorPlugins: [new Plugin({ key: subKey })], + })); + const parent = createExtension(() => ({ + key: "forced-parent", + runsBefore: ["forced-other"], + blockNoteExtensions: [sub()], + prosemirrorPlugins: [new Plugin({ key: parentKey })], + })); + + const editor = createMountedEditor([parent(), other()]); + + // The parent runs before the unrelated extension (its declared runsBefore)... + expect(pluginIndex(editor, parentKey)).toBeLessThan( + pluginIndex(editor, otherKey), + ); + // ...but its dependency still runs before it. + expect(pluginIndex(editor, subKey)).toBeLessThan( + pluginIndex(editor, parentKey), + ); + }); + + it("runs a shared sub-dependency before both extensions that declare it", () => { + const subKey = new PluginKey("shared-sub"); + const parentAKey = new PluginKey("shared-parent-a"); + const parentBKey = new PluginKey("shared-parent-b"); + const otherKey = new PluginKey("shared-other"); + + const other = createExtension(() => ({ + key: "shared-other", + prosemirrorPlugins: [new Plugin({ key: otherKey })], + })); + // A single sub-extension instance declared by two different parents. It is + // registered once (de-duplicated) and must run before both parents. + const sharedSub = createExtension(() => ({ + key: "shared-sub", + prosemirrorPlugins: [new Plugin({ key: subKey })], + })); + const parentA = createExtension(() => ({ + key: "shared-parent-a", + blockNoteExtensions: [sharedSub()], + prosemirrorPlugins: [new Plugin({ key: parentAKey })], + })); + // parentB declares the *already-registered* sub (so its registration is + // de-duplicated) and has a higher base priority via runsBefore. The + // dependency must still be recorded on the de-duplicated path so the sub + // runs before parentB too. + const parentB = createExtension(() => ({ + key: "shared-parent-b", + runsBefore: ["shared-other"], + blockNoteExtensions: [sharedSub()], + prosemirrorPlugins: [new Plugin({ key: parentBKey })], + })); + + const editor = createMountedEditor([parentA(), parentB(), other()]); + + // The sub is registered exactly once despite being declared twice. + expect( + [...editor.extensions.values()].filter((e) => e.key === "shared-sub") + .length, + ).toBe(1); + + // parentB's higher base priority puts it before the unrelated extension... + expect(pluginIndex(editor, parentBKey)).toBeLessThan( + pluginIndex(editor, otherKey), + ); + // ...but the shared sub still runs before both parents. + expect(pluginIndex(editor, subKey)).toBeLessThan( + pluginIndex(editor, parentAKey), + ); + expect(pluginIndex(editor, subKey)).toBeLessThan( + pluginIndex(editor, parentBKey), + ); + }); +}); diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index c49f787f57..17d1b46083 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -49,6 +49,12 @@ export class ExtensionManager { * We need to keep track of all the plugins for each extension, so that we can remove them when the extension is unregistered */ private extensionPlugins: Map = new Map(); + /** + * Maps an extension key to the set of extension keys that declared it as a + * dependency via `blockNoteExtensions`. A sub-extension is a dependency of + * the extension that declares it, so it must run *before* its parent(s). + */ + private blockNoteExtensionDependents: Map> = new Map(); constructor( private editor: BlockNoteEditor, @@ -179,6 +185,12 @@ export class ExtensionManager { */ private addExtension( extension: Extension | ExtensionFactoryInstance, + /** + * When this extension is being added as a dependency declared in another + * extension's `blockNoteExtensions`, this is the key of that declaring + * (parent) extension. + */ + parentKey?: string, ): Extension | undefined { let instance: Extension; if (typeof extension === "function") { @@ -191,6 +203,29 @@ export class ExtensionManager { return undefined as any; } + // A sub-extension declared via `blockNoteExtensions` must run before the + // extension that declares it. We record this dependency before the + // de-duplication check below, so that it applies even when multiple + // extensions declare the same sub-extension (and all but the first are + // de-duplicated). + if (parentKey) { + let dependents = this.blockNoteExtensionDependents.get(instance.key); + if (!dependents) { + dependents = new Set(); + this.blockNoteExtensionDependents.set(instance.key, dependents); + } + dependents.add(parentKey); + } + + // De-duplicate by key: if an extension with the same key is already + // registered, don't register it again. This allows an extension to declare + // a dependency on another extension via `blockNoteExtensions` without + // conflicting when the user (or another extension) registers that same + // extension directly. The first registration wins. + if (this.extensions.some((e) => e.key === instance.key)) { + return undefined as any; + } + // Now that we know that the extension is not disabled, we can add it to the extension factories if (typeof extension === "function") { const originalFactory = (instance as any)[originalFactorySymbol] as ( @@ -205,8 +240,8 @@ export class ExtensionManager { this.extensions.push(instance); if (instance.blockNoteExtensions) { - for (const extension of instance.blockNoteExtensions) { - this.addExtension(extension); + for (const subExtension of instance.blockNoteExtensions) { + this.addExtension(subExtension, instance.key); } } @@ -326,7 +361,21 @@ export class ExtensionManager { this.options, ).filter((extension) => !this.disabledExtensions.has(extension.name)); - const getPriority = sortByDependencies(this.extensions); + const getPriority = 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 + // `runsBefore`. + const dependents = this.blockNoteExtensionDependents.get(extension.key); + if (!dependents?.size) { + return extension; + } + return { + key: extension.key, + runsBefore: [...(extension.runsBefore ?? []), ...dependents], + }; + }), + ); const inputRulesByPriority = new Map(); for (const extension of this.extensions) {