-
-
Notifications
You must be signed in to change notification settings - Fork 745
feat(core): de-duplicate and order extensions by key in ExtensionManager #2872
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<any, any, any>["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<any, any, any>, | ||
| 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), | ||
| ); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<Extension, Plugin[]> = 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<string, Set<string>> = new Map(); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| constructor( | ||||||||||||||||||||||||||||||
| private editor: BlockNoteEditor<any, any, any>, | ||||||||||||||||||||||||||||||
|
|
@@ -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); | ||||||||||||||||||||||||||||||
|
Comment on lines
+211
to
+217
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Avoid recording self-dependencies. If a parent accidentally declares a sub-extension with the same key, this records Proposed fix- if (parentKey) {
+ if (parentKey && parentKey !== instance.key) {
let dependents = this.blockNoteExtensionDependents.get(instance.key);
if (!dependents) {
dependents = new Set();
this.blockNoteExtensionDependents.set(instance.key, dependents);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // 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], | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
|
Comment on lines
+364
to
+378
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE='packages/core/src/editor/managers/ExtensionManager/index.ts'
echo '--- outline ---'
ast-grep outline "$FILE" --view expanded || true
echo '--- relevant ranges ---'
sed -n '200,430p' "$FILE" | cat -nRepository: TypeCellOS/BlockNote Length of output: 11138 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE='packages/core/src/editor/managers/ExtensionManager/index.ts'
echo '--- matches ---'
rg -n "sortByDependencies|tiptapExtensions|inputRules|this\.extensions|blockNoteExtensionDependents" "$FILE"
echo '--- selected context ---'
sed -n '220,420p' "$FILE" | cat -nRepository: TypeCellOS/BlockNote Length of output: 11249 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE='packages/core/src/editor/managers/ExtensionManager/index.ts'
python3 - <<'PY'
from pathlib import Path
p = Path("packages/core/src/editor/managers/ExtensionManager/index.ts")
text = p.read_text()
for needle in ["sortByDependencies", "tiptapExtensions", "inputRules", "blockNoteExtensionDependents", "this.extensions"]:
print(f"--- {needle} ---")
for i, line in enumerate(text.splitlines(), 1):
if needle in line:
start = max(1, i-6)
end = min(len(text.splitlines()), i+10)
for j in range(start, end+1):
print(f"{j:4d}: {text.splitlines()[j-1]}")
print()
break
PYRepository: TypeCellOS/BlockNote Length of output: 4594 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE='packages/core/src/editor/managers/ExtensionManager/index.ts'
sed -n '430,500p' "$FILE" | cat -nRepository: TypeCellOS/BlockNote Length of output: 3013 Apply dependency ordering to raw 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const inputRulesByPriority = new Map<number, InputRule[]>(); | ||||||||||||||||||||||||||||||
| for (const extension of this.extensions) { | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Fail fast when the plugin is missing.
findIndexreturns-1; assertions likeexpect(pluginIndex(editor, subKey)).toBeLessThan(...)can pass if the expected earlier plugin was never registered. Make the helper assert presence before returning.Proposed fix
function pluginIndex( editor: BlockNoteEditor<any, any, any>, key: PluginKey, ): number { - return editor.prosemirrorState.plugins.findIndex( + const index = editor.prosemirrorState.plugins.findIndex( (plugin) => (plugin as any).spec?.key === key, ); + expect(index).not.toBe(-1); + return index; }📝 Committable suggestion
🤖 Prompt for AI Agents