Skip to content

feat(core): de-duplicate and order extensions by key in ExtensionManager#2872

Open
nperez0111 wants to merge 1 commit into
mainfrom
sub-extensions
Open

feat(core): de-duplicate and order extensions by key in ExtensionManager#2872
nperez0111 wants to merge 1 commit into
mainfrom
sub-extensions

Conversation

@nperez0111

@nperez0111 nperez0111 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

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.

Summary

Rationale

Changes

Impact

Testing

Screenshots/Video

Checklist

  • Code follows the project's coding standards.
  • Unit tests covering the new feature have been added.
  • All existing tests pass.
  • The documentation has been updated to reflect the new feature

Additional Notes

Summary by CodeRabbit

  • Bug Fixes
    • Improved extension ordering so related extensions now run in the expected sequence, even when priorities would otherwise conflict.
    • Fixed duplicate extension handling so only one matching extension is mounted when the same key is registered more than once.
    • Ensured nested extension dependencies are consistently applied and deduplicated across multiple parent extensions.

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>
@vercel

vercel Bot commented Jun 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
blocknote Ready Ready Preview Jun 29, 2026 11:32am
blocknote-website Ready Ready Preview Jun 29, 2026 11:32am

Request Review

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

ExtensionManager gains a blockNoteExtensionDependents map that records, per sub-extension key, which parent extension keys declared it via blockNoteExtensions. addExtension accepts an optional parentKey to populate this map before de-duplication. getTiptapExtensions merges recorded parents into each extension's runsBefore before sorting. A new Vitest suite covers de-duplication and all ordering edge cases.

ExtensionManager dependency-aware ordering

Layer / File(s) Summary
Dependents map, addExtension wiring, and getTiptapExtensions ordering
packages/core/src/editor/managers/ExtensionManager/index.ts
Adds private blockNoteExtensionDependents map; extends addExtension signature with optional parentKey and records dependencies before key-based de-duplication; updates blockNoteExtensions recursion to pass instance.key as parentKey; merges recorded dependents into runsBefore in getTiptapExtensions before calling sortByDependencies.
Tests: de-duplication and ordering
packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts
Adds helpers to mount an editor from extensions and find ProseMirror plugin index by key; tests cover duplicate key de-duplication, blockNoteExtensions skip/register behavior, runsBefore precedence, forced sub-extension ordering, and shared sub-dependency handling.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 Hoppity hop through the extension queue,
Parents and children now know what to do,
The dependents map keeps the order just right,
Sub-extensions run first, stepping into the light,
No duplicate keys shall confuse the sort,
Every plugin in line, right on cue! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is missing the required template content; only a brief summary is filled in and the sections are empty. Fill in Summary, Rationale, Changes, Impact, Testing, Checklist, and any relevant screenshots or notes using the repository template.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: ExtensionManager de-duplicates and orders extensions by key.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sub-extensions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jun 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@blocknote/ariakit

npm i https://pkg.pr.new/@blocknote/ariakit@2872

@blocknote/code-block

npm i https://pkg.pr.new/@blocknote/code-block@2872

@blocknote/core

npm i https://pkg.pr.new/@blocknote/core@2872

@blocknote/mantine

npm i https://pkg.pr.new/@blocknote/mantine@2872

@blocknote/react

npm i https://pkg.pr.new/@blocknote/react@2872

@blocknote/server-util

npm i https://pkg.pr.new/@blocknote/server-util@2872

@blocknote/shadcn

npm i https://pkg.pr.new/@blocknote/shadcn@2872

@blocknote/xl-ai

npm i https://pkg.pr.new/@blocknote/xl-ai@2872

@blocknote/xl-docx-exporter

npm i https://pkg.pr.new/@blocknote/xl-docx-exporter@2872

@blocknote/xl-email-exporter

npm i https://pkg.pr.new/@blocknote/xl-email-exporter@2872

@blocknote/xl-multi-column

npm i https://pkg.pr.new/@blocknote/xl-multi-column@2872

@blocknote/xl-odt-exporter

npm i https://pkg.pr.new/@blocknote/xl-odt-exporter@2872

@blocknote/xl-pdf-exporter

npm i https://pkg.pr.new/@blocknote/xl-pdf-exporter@2872

commit: b2bfd98

@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://TypeCellOS.github.io/BlockNote/pr-preview/pr-2872/

Built to branch gh-pages at 2026-06-29 11:35 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🤖 Prompt for all review comments with 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.

Inline comments:
In `@packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts`:
- Around line 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.

In `@packages/core/src/editor/managers/ExtensionManager/index.ts`:
- Around line 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.
- Around line 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().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6588c705-cf03-44f3-996c-bdb4a321e37b

📥 Commits

Reviewing files that changed from the base of the PR and between 47f5a3b and b2bfd98.

📒 Files selected for processing (2)
  • packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts
  • packages/core/src/editor/managers/ExtensionManager/index.ts

Comment on lines +22 to +28
function pluginIndex(
editor: BlockNoteEditor<any, any, any>,
key: PluginKey,
): number {
return editor.prosemirrorState.plugins.findIndex(
(plugin) => (plugin as any).spec?.key === key,
);

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.

Comment on lines +211 to +217
if (parentKey) {
let dependents = this.blockNoteExtensionDependents.get(instance.key);
if (!dependents) {
dependents = new Set();
this.blockNoteExtensionDependents.set(instance.key, dependents);
}
dependents.add(parentKey);

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.

Comment on lines +364 to +378
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],
};
}),
);

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().

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant