Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
);
Comment on lines +22 to +28

Copy link
Copy Markdown
Contributor

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.

findIndex returns -1; assertions like expect(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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function pluginIndex(
editor: BlockNoteEditor<any, any, any>,
key: PluginKey,
): number {
return editor.prosemirrorState.plugins.findIndex(
(plugin) => (plugin as any).spec?.key === key,
);
function pluginIndex(
editor: BlockNoteEditor<any, any, any>,
key: PluginKey,
): number {
const index = editor.prosemirrorState.plugins.findIndex(
(plugin) => (plugin as any).spec?.key === key,
);
expect(index).not.toBe(-1);
return index;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts`
around lines 22 - 28, The pluginIndex helper currently returns -1 when a plugin
is missing, which can let ordering assertions pass even if registration failed.
Update pluginIndex in ExtensionManager.test.ts to fail fast by asserting that
the requested PluginKey exists in editor.prosemirrorState.plugins before
returning the index, so tests using pluginIndex(editor, subKey) cannot silently
succeed on absent plugins.

}

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),
);
});
});
55 changes: 52 additions & 3 deletions packages/core/src/editor/managers/ExtensionManager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand Down Expand Up @@ -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") {
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 instance.key -> parentKey where both keys are equal, and getTiptapExtensions later feeds a self-edge into the dependency sorter.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (parentKey) {
let dependents = this.blockNoteExtensionDependents.get(instance.key);
if (!dependents) {
dependents = new Set();
this.blockNoteExtensionDependents.set(instance.key, dependents);
}
dependents.add(parentKey);
if (parentKey && parentKey !== instance.key) {
let dependents = this.blockNoteExtensionDependents.get(instance.key);
if (!dependents) {
dependents = new Set();
this.blockNoteExtensionDependents.set(instance.key, dependents);
}
dependents.add(parentKey);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/editor/managers/ExtensionManager/index.ts` around lines 211
- 217, Avoid recording self-dependencies in ExtensionManager when registering
dependents: in the block that adds parentKey to blockNoteExtensionDependents for
instance.key, skip the add if parentKey equals instance.key. Update the logic
around getTiptapExtensions/dependent tracking so a parent extension cannot
create a self-edge that later reaches the dependency sorter.

}

// 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 (
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 -n

Repository: 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 -n

Repository: 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
PY

Repository: 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 -n

Repository: TypeCellOS/BlockNote

Length of output: 3013


Apply dependency ordering to raw tiptapExtensions too. They’re still appended in this.extensions order, and addExtension() inserts a parent before its blockNoteExtensions child. A parent/sub-extension pair that both expose Tiptap extensions can still run parent-first even though wrappers and input rules use the dependency sort.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/editor/managers/ExtensionManager/index.ts` around lines 364
- 378, The dependency sort in ExtensionManager’s getPriority only applies to
BlockNote extensions, so raw tiptapExtensions can still be emitted in the wrong
order. Update the ordering logic in ExtensionManager/index.ts so the same
dependency-based priority used for wrappers/input rules also determines the
final tiptapExtensions sequence, preserving child-before-parent behavior for
parent/sub-extension pairs exposed through addExtension().


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