Skip to content

Commit b2bfd98

Browse files
nperez0111claude
andcommitted
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) <noreply@anthropic.com>
1 parent 47f5a3b commit b2bfd98

2 files changed

Lines changed: 301 additions & 3 deletions

File tree

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { Plugin, PluginKey } from "prosemirror-state";
5+
import { describe, expect, it } from "vite-plus/test";
6+
7+
import { createExtension } from "../../BlockNoteExtension.js";
8+
import { BlockNoteEditor } from "../../BlockNoteEditor.js";
9+
10+
function createMountedEditor(
11+
extensions: BlockNoteEditor<any, any, any>["options"]["extensions"],
12+
) {
13+
const editor = BlockNoteEditor.create({ extensions });
14+
editor.mount(document.createElement("div"));
15+
return editor;
16+
}
17+
18+
/**
19+
* Returns the index of the plugin identified by `key` within the editor's
20+
* ProseMirror plugin list. A lower index means it runs/applies earlier.
21+
*/
22+
function pluginIndex(
23+
editor: BlockNoteEditor<any, any, any>,
24+
key: PluginKey,
25+
): number {
26+
return editor.prosemirrorState.plugins.findIndex(
27+
(plugin) => (plugin as any).spec?.key === key,
28+
);
29+
}
30+
31+
describe("ExtensionManager de-duplication by key", () => {
32+
it("registers only the first extension when two share a key", () => {
33+
let mountCount = 0;
34+
35+
const first = createExtension(() => ({
36+
key: "dup",
37+
value: "first",
38+
mount() {
39+
mountCount++;
40+
return () => {};
41+
},
42+
}));
43+
const second = createExtension(() => ({
44+
key: "dup",
45+
value: "second",
46+
mount() {
47+
mountCount++;
48+
return () => {};
49+
},
50+
}));
51+
52+
const editor = createMountedEditor([first(), second()]);
53+
54+
// The first registration wins.
55+
expect(editor.getExtension(first)?.value).toBe("first");
56+
// The second registration was skipped entirely.
57+
expect(editor.getExtension(second)).toBeUndefined();
58+
expect((editor.extensions.get("dup") as any)?.value).toBe("first");
59+
expect(
60+
[...editor.extensions.values()].filter((e) => e.key === "dup").length,
61+
).toBe(1);
62+
// Only the registered extension was mounted.
63+
expect(mountCount).toBe(1);
64+
});
65+
66+
it("does not re-register a dependency declared via blockNoteExtensions when it is already registered", () => {
67+
// Two distinct factories sharing the key "dep".
68+
const depDirect = createExtension(() => ({
69+
key: "dep",
70+
value: "direct",
71+
}));
72+
const depFromParent = createExtension(() => ({
73+
key: "dep",
74+
value: "from-parent",
75+
}));
76+
const parent = createExtension(() => ({
77+
key: "parent",
78+
blockNoteExtensions: [depFromParent()],
79+
}));
80+
81+
// Register the dependency directly first, then a parent that also pulls in
82+
// its own "dep" via blockNoteExtensions.
83+
const editor = createMountedEditor([depDirect(), parent()]);
84+
85+
expect(editor.getExtension(parent)).toBeDefined();
86+
// The directly-registered dependency wins; the one declared by the parent
87+
// is skipped rather than overriding it.
88+
expect(editor.getExtension(depDirect)?.value).toBe("direct");
89+
expect(editor.getExtension(depFromParent)).toBeUndefined();
90+
expect((editor.extensions.get("dep") as any)?.value).toBe("direct");
91+
});
92+
93+
it("registers a dependency declared via blockNoteExtensions when it isn't registered otherwise", () => {
94+
const dep = createExtension(() => ({
95+
key: "lonely-dep",
96+
value: "dep",
97+
}));
98+
const parent = createExtension(() => ({
99+
key: "lonely-parent",
100+
blockNoteExtensions: [dep()],
101+
}));
102+
103+
const editor = createMountedEditor([parent()]);
104+
105+
expect(editor.getExtension(parent)).toBeDefined();
106+
expect(editor.getExtension(dep)?.value).toBe("dep");
107+
});
108+
});
109+
110+
describe("ExtensionManager ordering", () => {
111+
it("orders an extension before another it declares in runsBefore", () => {
112+
const firstKey = new PluginKey("rb-first");
113+
const secondKey = new PluginKey("rb-second");
114+
115+
const first = createExtension(() => ({
116+
key: "rb-first",
117+
runsBefore: ["rb-second"],
118+
prosemirrorPlugins: [new Plugin({ key: firstKey })],
119+
}));
120+
const second = createExtension(() => ({
121+
key: "rb-second",
122+
prosemirrorPlugins: [new Plugin({ key: secondKey })],
123+
}));
124+
125+
// Register in the "wrong" order to prove runsBefore — not array order —
126+
// determines precedence.
127+
const editor = createMountedEditor([second(), first()]);
128+
129+
expect(pluginIndex(editor, firstKey)).toBeLessThan(
130+
pluginIndex(editor, secondKey),
131+
);
132+
});
133+
134+
it("flattens sub-extensions and runs the parent after its blockNoteExtensions dependency", () => {
135+
const subKey = new PluginKey("sub-order");
136+
const parentKey = new PluginKey("parent-order");
137+
138+
const sub = createExtension(() => ({
139+
key: "ordered-sub",
140+
prosemirrorPlugins: [new Plugin({ key: subKey })],
141+
}));
142+
const parent = createExtension(() => ({
143+
key: "ordered-parent",
144+
blockNoteExtensions: [sub()],
145+
prosemirrorPlugins: [new Plugin({ key: parentKey })],
146+
}));
147+
148+
const editor = createMountedEditor([parent()]);
149+
150+
// The sub-extension is flattened into the editor's extensions...
151+
expect(editor.getExtension(sub)).toBeDefined();
152+
expect(editor.getExtension(parent)).toBeDefined();
153+
154+
// ...and because the parent declares the sub as a dependency, the sub runs
155+
// before the parent (even though the parent is registered first).
156+
expect(pluginIndex(editor, subKey)).toBeLessThan(
157+
pluginIndex(editor, parentKey),
158+
);
159+
});
160+
161+
it("forces a blockNoteExtensions dependency before a parent that has a higher base priority", () => {
162+
// The parent declares `runsBefore` on an unrelated extension, which raises
163+
// its priority above the default. Without an explicit dependency edge, the
164+
// higher-priority parent would run before its sub. The dependency must
165+
// override that so the sub still runs first.
166+
const subKey = new PluginKey("forced-sub");
167+
const parentKey = new PluginKey("forced-parent");
168+
const otherKey = new PluginKey("forced-other");
169+
170+
const other = createExtension(() => ({
171+
key: "forced-other",
172+
prosemirrorPlugins: [new Plugin({ key: otherKey })],
173+
}));
174+
const sub = createExtension(() => ({
175+
key: "forced-sub",
176+
prosemirrorPlugins: [new Plugin({ key: subKey })],
177+
}));
178+
const parent = createExtension(() => ({
179+
key: "forced-parent",
180+
runsBefore: ["forced-other"],
181+
blockNoteExtensions: [sub()],
182+
prosemirrorPlugins: [new Plugin({ key: parentKey })],
183+
}));
184+
185+
const editor = createMountedEditor([parent(), other()]);
186+
187+
// The parent runs before the unrelated extension (its declared runsBefore)...
188+
expect(pluginIndex(editor, parentKey)).toBeLessThan(
189+
pluginIndex(editor, otherKey),
190+
);
191+
// ...but its dependency still runs before it.
192+
expect(pluginIndex(editor, subKey)).toBeLessThan(
193+
pluginIndex(editor, parentKey),
194+
);
195+
});
196+
197+
it("runs a shared sub-dependency before both extensions that declare it", () => {
198+
const subKey = new PluginKey("shared-sub");
199+
const parentAKey = new PluginKey("shared-parent-a");
200+
const parentBKey = new PluginKey("shared-parent-b");
201+
const otherKey = new PluginKey("shared-other");
202+
203+
const other = createExtension(() => ({
204+
key: "shared-other",
205+
prosemirrorPlugins: [new Plugin({ key: otherKey })],
206+
}));
207+
// A single sub-extension instance declared by two different parents. It is
208+
// registered once (de-duplicated) and must run before both parents.
209+
const sharedSub = createExtension(() => ({
210+
key: "shared-sub",
211+
prosemirrorPlugins: [new Plugin({ key: subKey })],
212+
}));
213+
const parentA = createExtension(() => ({
214+
key: "shared-parent-a",
215+
blockNoteExtensions: [sharedSub()],
216+
prosemirrorPlugins: [new Plugin({ key: parentAKey })],
217+
}));
218+
// parentB declares the *already-registered* sub (so its registration is
219+
// de-duplicated) and has a higher base priority via runsBefore. The
220+
// dependency must still be recorded on the de-duplicated path so the sub
221+
// runs before parentB too.
222+
const parentB = createExtension(() => ({
223+
key: "shared-parent-b",
224+
runsBefore: ["shared-other"],
225+
blockNoteExtensions: [sharedSub()],
226+
prosemirrorPlugins: [new Plugin({ key: parentBKey })],
227+
}));
228+
229+
const editor = createMountedEditor([parentA(), parentB(), other()]);
230+
231+
// The sub is registered exactly once despite being declared twice.
232+
expect(
233+
[...editor.extensions.values()].filter((e) => e.key === "shared-sub")
234+
.length,
235+
).toBe(1);
236+
237+
// parentB's higher base priority puts it before the unrelated extension...
238+
expect(pluginIndex(editor, parentBKey)).toBeLessThan(
239+
pluginIndex(editor, otherKey),
240+
);
241+
// ...but the shared sub still runs before both parents.
242+
expect(pluginIndex(editor, subKey)).toBeLessThan(
243+
pluginIndex(editor, parentAKey),
244+
);
245+
expect(pluginIndex(editor, subKey)).toBeLessThan(
246+
pluginIndex(editor, parentBKey),
247+
);
248+
});
249+
});

packages/core/src/editor/managers/ExtensionManager/index.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ export class ExtensionManager {
4949
* We need to keep track of all the plugins for each extension, so that we can remove them when the extension is unregistered
5050
*/
5151
private extensionPlugins: Map<Extension, Plugin[]> = new Map();
52+
/**
53+
* Maps an extension key to the set of extension keys that declared it as a
54+
* dependency via `blockNoteExtensions`. A sub-extension is a dependency of
55+
* the extension that declares it, so it must run *before* its parent(s).
56+
*/
57+
private blockNoteExtensionDependents: Map<string, Set<string>> = new Map();
5258

5359
constructor(
5460
private editor: BlockNoteEditor<any, any, any>,
@@ -179,6 +185,12 @@ export class ExtensionManager {
179185
*/
180186
private addExtension(
181187
extension: Extension | ExtensionFactoryInstance,
188+
/**
189+
* When this extension is being added as a dependency declared in another
190+
* extension's `blockNoteExtensions`, this is the key of that declaring
191+
* (parent) extension.
192+
*/
193+
parentKey?: string,
182194
): Extension | undefined {
183195
let instance: Extension;
184196
if (typeof extension === "function") {
@@ -191,6 +203,29 @@ export class ExtensionManager {
191203
return undefined as any;
192204
}
193205

206+
// A sub-extension declared via `blockNoteExtensions` must run before the
207+
// extension that declares it. We record this dependency before the
208+
// de-duplication check below, so that it applies even when multiple
209+
// extensions declare the same sub-extension (and all but the first are
210+
// de-duplicated).
211+
if (parentKey) {
212+
let dependents = this.blockNoteExtensionDependents.get(instance.key);
213+
if (!dependents) {
214+
dependents = new Set();
215+
this.blockNoteExtensionDependents.set(instance.key, dependents);
216+
}
217+
dependents.add(parentKey);
218+
}
219+
220+
// De-duplicate by key: if an extension with the same key is already
221+
// registered, don't register it again. This allows an extension to declare
222+
// a dependency on another extension via `blockNoteExtensions` without
223+
// conflicting when the user (or another extension) registers that same
224+
// extension directly. The first registration wins.
225+
if (this.extensions.some((e) => e.key === instance.key)) {
226+
return undefined as any;
227+
}
228+
194229
// Now that we know that the extension is not disabled, we can add it to the extension factories
195230
if (typeof extension === "function") {
196231
const originalFactory = (instance as any)[originalFactorySymbol] as (
@@ -205,8 +240,8 @@ export class ExtensionManager {
205240
this.extensions.push(instance);
206241

207242
if (instance.blockNoteExtensions) {
208-
for (const extension of instance.blockNoteExtensions) {
209-
this.addExtension(extension);
243+
for (const subExtension of instance.blockNoteExtensions) {
244+
this.addExtension(subExtension, instance.key);
210245
}
211246
}
212247

@@ -326,7 +361,21 @@ export class ExtensionManager {
326361
this.options,
327362
).filter((extension) => !this.disabledExtensions.has(extension.name));
328363

329-
const getPriority = sortByDependencies(this.extensions);
364+
const getPriority = sortByDependencies(
365+
this.extensions.map((extension) => {
366+
// A sub-extension declared via `blockNoteExtensions` must run before the
367+
// extension(s) that declared it, so we merge those parents into its
368+
// `runsBefore`.
369+
const dependents = this.blockNoteExtensionDependents.get(extension.key);
370+
if (!dependents?.size) {
371+
return extension;
372+
}
373+
return {
374+
key: extension.key,
375+
runsBefore: [...(extension.runsBefore ?? []), ...dependents],
376+
};
377+
}),
378+
);
330379

331380
const inputRulesByPriority = new Map<number, InputRule[]>();
332381
for (const extension of this.extensions) {

0 commit comments

Comments
 (0)