Skip to content

feat: nested blocks POC#2697

Open
nperez0111 wants to merge 6 commits intomainfrom
feat/nested-blocks
Open

feat: nested blocks POC#2697
nperez0111 wants to merge 6 commits intomainfrom
feat/nested-blocks

Conversation

@nperez0111
Copy link
Copy Markdown
Contributor

@nperez0111 nperez0111 commented Apr 30, 2026

Summary

This implements an approach we can take nested blocks, by mimicking what we already do for columns and generalizing that API to make it externally consumable.

I gave it the name container to sort of give a different name than a "nested block", what we care about is actually the parent block, not the children (which are inter-changeable).
So, I felt like "container" better described the parent element than "nested block wrapper" or so. But, of course, always open to names.

This is what the JSON for a "callout", which allows multiple children would look like in code:

const createCallout = createReactBlockSpec(
  {
    type: "callout",
    propSchema: {
      flavor: {
        default: "tip",
        values: ["tip", "info", "warning", "success"],
      },
    },
    // The callout has no content of it's own
    // It defers all content to it's children
    content: "none",
    // This is the new property which indicates that this block is a container over other block types
    container: {
      // It requires at least 1 child block
      min: 1,
      // If none are found, or this block is just inserted, it will create a paragraph block
      defaultBlocks: ["paragraph"],
    },
  },
  {
    render: (props) => {
      // Will figure out a better API around this, but we do still need to have this for node-views, TBD here
      return (
        <NodeViewWrapper
          className={"callout"}
          data-node-type="callout"
          data-id={props.block.id}
          data-flavor={flavor.value}
        >
           <Icon size={20} />
          <div className={"callout-body"} ref={props.contentRef} />
        </NodeViewWrapper>
      );
    },
  },
);

And, here is what the equivalent BlockNote JSON:

{
  "id": "callout-1",
  "type": "callout",
  "props": { /* callout-specific props */ },
  "content": undefined,           // content stays "none"
  "children": [                   // body lives in children
    {
      "id": "para-1",
      "type": "paragraph",
      "props": { /* … */ },
      "content": [
        { "type": "text", "text": "Hello", "styles": {} }
      ],
      "children": []
    }
  ]
}
image

Rationale

  • This approach was taken because it mirrors the existing work we've done on the columnlist and column API, and enables nested blocks rather cheaply
  • columns and column lists should be prosemirror node compatible, so this should be a safe Y.js migration (TBD this is unvalidated)
  • This has the benefit that we do not need to invest in a different BlockNote JSON format (v.s. expanding content to allow blocks as well)
  • This has the drawback of muddling the purpose of children (indentation), but this already started with the support of columns, it is just an extension of it

Changes

Future

I would likely want to look into 2 more things if we decide to pursure this route:

  • Right now, content must be "none" for a container to work properly, would like to consider allowing content for containers too
  • If we decide that the above is desirable, we can actually migrate toggle blocks to the container pattern too (hoping it'd be prosemirror node compatible)
  • We will need to invest in a number of UX issues around the container handling:
    • Side Menu hides when you try to hover a child block
    • Side Menu awkwardly appears over content
    • You can get "stuck" with a container as the last item in a block, with no way to insert an item after (since enter creates a new block within the container now)
    • keyboard handling at the edges of containers needs to be defined

Testing

  • We would want to expand the test suite for this, just a POC at this point though

Screenshots/Video

Summary by CodeRabbit

  • New Features

    • Added support for container blocks—custom blocks that can wrap and manage nested blocks within them.
    • New example demonstrating how to create a custom "Callout" container block with slash menu integration and interactive flavor selection.
  • Documentation

    • Added guide for building container-style custom blocks, including nested content management and slash menu integration.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 30, 2026

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

Project Deployment Actions Updated (UTC)
blocknote Ready Ready Preview Apr 30, 2026 3:36pm
blocknote-website Ready Ready Preview Apr 30, 2026 3:36pm

Request Review

@nperez0111 nperez0111 requested a review from YousefED April 30, 2026 15:33
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

📝 Walkthrough

Walkthrough

This PR introduces container blocks, a new block type that can host nested child blocks. It adds a complete example demonstrating container block creation, updates core schema and conversion logic to handle container configuration, modifies React rendering to support container-specific handling, and migrates the existing Column block to the new container block infrastructure.

Changes

Cohort / File(s) Summary
New Container Block Example
examples/06-custom-schema/08-container-block/*
Adds complete working example of a custom callout container block with flavor variants, slash menu integration, and documentation. Includes configuration files, React components, styles, and package manifest.
Core Container Block Support
packages/core/src/schema/blocks/createSpec.ts, packages/core/src/schema/blocks/types.ts, packages/core/src/schema/blocks/internal.ts
Introduces container block system with dedicated TipTap node builder, ContainerConfig type, content expression generation, and validation logic. Adds NodeView update hooks and container-specific HTML parsing/rendering.
Block Conversion & Seeding
packages/core/src/api/nodeConversions/blockToNode.ts
Conditionally seeds empty container blocks with default child nodes from container configuration when children are absent from input.
React Block Spec & Rendering
packages/react/src/schema/ReactBlockSpec.tsx, packages/react/src/schema/ReactBlockSpec.container.test.tsx
Updates React block rendering to distinguish container blocks from standard blocks, using block ID lookup instead of position-based lookup and bypassing content wrapper. Adds test coverage for container block transformations.
Public API Exports
packages/react/src/index.ts
Exposes NodeViewWrapper for container block authors to use in custom implementations.
Extension Configuration Updates
packages/core/src/editor/managers/ExtensionManager/extensions.ts, packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts, packages/core/src/extensions/TrailingNode/TrailingNode.ts
Updates UniqueID extension to dynamically attach IDs to all bnBlock nodes, adjusts TypeScript typing, and shifts trailing node insertion position.
Column Block Migration
packages/xl-multi-column/src/blocks/Columns/index.ts, packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts, packages/xl-multi-column/src/pm-nodes/Column.ts
Migrates Column block from TipTap node to container block spec, removes legacy ProseMirror node definition, and converts extension factory to BlockNote's createExtension API.
Generated & Test Files
playground/src/examples.gen.tsx, tests/nextjs-test-app/package.json
Updates example registry with new container-block example and bumps test app BlockNote dependencies to 0.49.0.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • matthewlipski

Poem

🐰 With whiskers twitched and joy complete,
Container blocks now make things neat!
Nested children, style so sweet,
Callouts dancing—oh, what a treat!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: nested blocks POC' is clear, concise, and directly summarizes the main feature being introduced—a proof-of-concept implementation of nested blocks using a generalized container API.
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.
Description check ✅ Passed The pull request description comprehensively covers all required sections including summary, rationale, changes, testing notes, and additional context about the feature.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 feat/nested-blocks

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 and usage tips.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 30, 2026

Open in StackBlitz

@blocknote/ariakit

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

@blocknote/code-block

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

@blocknote/core

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

@blocknote/mantine

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

@blocknote/react

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

@blocknote/server-util

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

@blocknote/shadcn

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

@blocknote/xl-ai

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

@blocknote/xl-docx-exporter

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

@blocknote/xl-email-exporter

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

@blocknote/xl-multi-column

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

@blocknote/xl-odt-exporter

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

@blocknote/xl-pdf-exporter

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

commit: ab86f5b

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react/src/schema/ReactBlockSpec.tsx (1)

239-277: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

React container blocks now lose domAttributes.blockContent.

Once isContainer is true, these paths return the block component directly and never apply this.blockContentDOMAttributes. In the vanilla container path that data is still available via the render call context, but React renderers are plain FCs, so consumers have no way to preserve editor-level classes/data-* attrs on container blocks.

Please thread blockContentDOMAttributes into the React container renderer contract, or provide a small container-root helper that applies them before returning the outer NodeViewWrapper. Right now any styling/test hooks configured through domAttributes.blockContent disappear specifically on container blocks.

Also applies to: 323-359, 367-399

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/src/schema/ReactBlockSpec.tsx` around lines 239 - 277, When
isContainer is true the code returns the BlockContent directly from the
renderToDOMSpec callback and therefore never applies
this.blockContentDOMAttributes; update the React container rendering path to
accept and apply blockContentDOMAttributes before returning content. Concretely,
modify the renderToDOMSpec callback surrounding isContainer/BlockContent so that
the container path either (a) wraps the returned BlockContent in a lightweight
DOM wrapper that merges this.blockContentDOMAttributes onto the container root,
or (b) passes those attributes into BlockContent via a new prop (e.g.
blockContentDOMAttributes) and ensure BlockContent consumers apply them to their
root; adjust the renderToDOMSpec usage and any BlockContent consumer
expectations accordingly (also apply the same change in the other similar blocks
around the referenced ranges).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@examples/06-custom-schema/08-container-block/index.html`:
- Line 1: Add the HTML5 doctype declaration before the opening <html> tag in the
file (the top of examples/06-custom-schema/08-container-block/index.html) so the
document starts with <!doctype html> to prevent quirks-mode rendering; simply
insert the doctype line immediately above the existing <html lang="en"> tag.

In `@examples/06-custom-schema/08-container-block/src/Callout.tsx`:
- Around line 62-68: The icon-only toggle button rendered in Callout.tsx (button
with className "callout-icon-button", onClick={cycleFlavor}, title={`Click to
cycle flavor (current: ${flavor.title})`}) lacks an explicit accessible name;
add one by supplying an aria-label (or aria-labelledby) that conveys its purpose
and current state (e.g., include flavor.title) so screen readers get a
consistent name across AT instead of relying solely on title attributes.

In `@examples/06-custom-schema/08-container-block/vite.config.ts`:
- Around line 17-29: The alias paths and the fs.existsSync check are using
two-level up paths ("../../packages/...") which are one directory too shallow
from this file; update the existence check and each path.resolve for the alias
entries (the block that sets "@blocknote/core" and "@blocknote/react") to go up
three levels (e.g., "../../../packages/core/src" and
"../../../packages/react/src") so the fs.existsSync correctly detects the repo
packages and the aliasing works.

In `@packages/core/src/api/nodeConversions/blockToNode.ts`:
- Around line 373-387: The defaultBlocks expansion in blockToNode can recurse
indefinitely for cyclic container defaults; modify blockToNode (or the call path
that maps defaultBlocks) to detect and prevent cycles by tracking a visited/set
or depth for block types (e.g., pass a visitedTypes Set or maxDepth through the
blockToNode call) and skip expanding a default block if its type is already in
the visited set (or depth exceeded); reference blockToNode, getBlockSchema,
containerConfig, defaultBlocks, and effectiveChildren when adding the cycle
guard so seeded defaults do not re-enter types already on the current expansion
stack.

In `@packages/core/src/editor/managers/ExtensionManager/extensions.ts`:
- Around line 70-76: The code assumes every block spec has
spec.implementation.node and reads node.config.group directly; guard that access
by first checking spec.implementation.node exists and has a config (e.g., use an
existence check or optional chaining) before reading config.group so specs
without a node don't throw during setup; update the filter that references
editor.schema.blockSpecs and spec.implementation.node/config.group to skip
entries where node or node.config is missing while still matching the "bnBlock"
group.

In `@packages/xl-multi-column/src/blocks/Columns/index.ts`:
- Around line 51-69: The update handler in the Columns block (the update:
(newNode: ...) function) currently only sets data-id when newNode.attrs.id is
truthy, leaving a stale data-id on the DOM if id is removed; change the logic so
that when newNode.attrs.id is present you set dom.setAttribute("data-id", id)
and when it's absent you call dom.removeAttribute("data-id") — mirror the
existing data-width handling around COLUMN_WIDTH_DEFAULT to ensure the DOM's
data-id always reflects the node's attrs.id.

---

Outside diff comments:
In `@packages/react/src/schema/ReactBlockSpec.tsx`:
- Around line 239-277: When isContainer is true the code returns the
BlockContent directly from the renderToDOMSpec callback and therefore never
applies this.blockContentDOMAttributes; update the React container rendering
path to accept and apply blockContentDOMAttributes before returning content.
Concretely, modify the renderToDOMSpec callback surrounding
isContainer/BlockContent so that the container path either (a) wraps the
returned BlockContent in a lightweight DOM wrapper that merges
this.blockContentDOMAttributes onto the container root, or (b) passes those
attributes into BlockContent via a new prop (e.g. blockContentDOMAttributes) and
ensure BlockContent consumers apply them to their root; adjust the
renderToDOMSpec usage and any BlockContent consumer expectations accordingly
(also apply the same change in the other similar blocks around the referenced
ranges).
🪄 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: 2e4e27fc-0786-4e9f-ac0d-c1095acb02e5

📥 Commits

Reviewing files that changed from the base of the PR and between d48a92a and ab86f5b.

⛔ Files ignored due to path filters (5)
  • packages/react/src/schema/__snapshots__/ReactBlockSpec.container.test.tsx.snap is excluded by !**/*.snap, !**/__snapshots__/**
  • packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html is excluded by !**/__snapshots__/**
  • packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html is excluded by !**/__snapshots__/**
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • tests/src/unit/core/schema/__snapshots__/blocks.json is excluded by !**/__snapshots__/**
📒 Files selected for processing (25)
  • examples/06-custom-schema/08-container-block/.bnexample.json
  • examples/06-custom-schema/08-container-block/README.md
  • examples/06-custom-schema/08-container-block/index.html
  • examples/06-custom-schema/08-container-block/main.tsx
  • examples/06-custom-schema/08-container-block/package.json
  • examples/06-custom-schema/08-container-block/src/App.tsx
  • examples/06-custom-schema/08-container-block/src/Callout.tsx
  • examples/06-custom-schema/08-container-block/src/styles.css
  • examples/06-custom-schema/08-container-block/tsconfig.json
  • examples/06-custom-schema/08-container-block/vite.config.ts
  • packages/core/src/api/nodeConversions/blockToNode.ts
  • packages/core/src/editor/managers/ExtensionManager/extensions.ts
  • packages/core/src/extensions/TrailingNode/TrailingNode.ts
  • packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts
  • packages/core/src/schema/blocks/createSpec.ts
  • packages/core/src/schema/blocks/internal.ts
  • packages/core/src/schema/blocks/types.ts
  • packages/react/src/index.ts
  • packages/react/src/schema/ReactBlockSpec.container.test.tsx
  • packages/react/src/schema/ReactBlockSpec.tsx
  • packages/xl-multi-column/src/blocks/Columns/index.ts
  • packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts
  • packages/xl-multi-column/src/pm-nodes/Column.ts
  • playground/src/examples.gen.tsx
  • tests/nextjs-test-app/package.json
💤 Files with no reviewable changes (1)
  • packages/xl-multi-column/src/pm-nodes/Column.ts

@@ -0,0 +1,14 @@
<html lang="en">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add HTML5 doctype at the top.

Line [1] starts with <html> directly. Add <!doctype html> before it to avoid quirks-mode rendering differences.

Suggested fix
+<!doctype html>
 <html lang="en">
📝 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
<html lang="en">
<!doctype html>
<html lang="en">
🧰 Tools
🪛 HTMLHint (1.9.2)

[error] 1-1: Doctype must be declared before any non-comment content.

(doctype-first)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/06-custom-schema/08-container-block/index.html` at line 1, Add the
HTML5 doctype declaration before the opening <html> tag in the file (the top of
examples/06-custom-schema/08-container-block/index.html) so the document starts
with <!doctype html> to prevent quirks-mode rendering; simply insert the doctype
line immediately above the existing <html lang="en"> tag.

Comment on lines +62 to +68
<button
className={"callout-icon-button"}
type={"button"}
contentEditable={false}
onClick={cycleFlavor}
title={`Click to cycle flavor (current: ${flavor.title})`}
>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add an explicit accessible name to the flavor toggle.

This is an icon-only button, so relying on title alone leaves its accessible name inconsistent across assistive tech.

Suggested change
           <button
             className={"callout-icon-button"}
             type={"button"}
             contentEditable={false}
             onClick={cycleFlavor}
+            aria-label={`Cycle callout flavor (current: ${flavor.title})`}
             title={`Click to cycle flavor (current: ${flavor.title})`}
           >
📝 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
<button
className={"callout-icon-button"}
type={"button"}
contentEditable={false}
onClick={cycleFlavor}
title={`Click to cycle flavor (current: ${flavor.title})`}
>
<button
className={"callout-icon-button"}
type={"button"}
contentEditable={false}
onClick={cycleFlavor}
aria-label={`Cycle callout flavor (current: ${flavor.title})`}
title={`Click to cycle flavor (current: ${flavor.title})`}
>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/06-custom-schema/08-container-block/src/Callout.tsx` around lines 62
- 68, The icon-only toggle button rendered in Callout.tsx (button with className
"callout-icon-button", onClick={cycleFlavor}, title={`Click to cycle flavor
(current: ${flavor.title})`}) lacks an explicit accessible name; add one by
supplying an aria-label (or aria-labelledby) that conveys its purpose and
current state (e.g., include flavor.title) so screen readers get a consistent
name across AT instead of relying solely on title attributes.

Comment on lines +17 to +29
!fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
? {}
: ({
// Comment out the lines below to load a built version of blocknote
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
"../../packages/core/src/"
),
"@blocknote/react": path.resolve(
__dirname,
"../../packages/react/src/"
),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Local alias paths look one directory too shallow.

From this folder, Line [17] / Line [24] / Line [28] should go up three levels to reach repo-root packages/*. With the current paths, the existence check likely returns false and aliasing is skipped.

Suggested fix
-      !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+      !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
         ? {}
         : ({
@@
-              "../../packages/core/src/"
+              "../../../packages/core/src/"
             ),
             "@blocknote/react": path.resolve(
               __dirname,
-              "../../packages/react/src/"
+              "../../../packages/react/src/"
             ),
           } as any),
📝 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
!fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
? {}
: ({
// Comment out the lines below to load a built version of blocknote
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
"../../packages/core/src/"
),
"@blocknote/react": path.resolve(
__dirname,
"../../packages/react/src/"
),
!fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// Comment out the lines below to load a built version of blocknote
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
"../../../packages/core/src/"
),
"@blocknote/react": path.resolve(
__dirname,
"../../../packages/react/src/"
),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/06-custom-schema/08-container-block/vite.config.ts` around lines 17
- 29, The alias paths and the fs.existsSync check are using two-level up paths
("../../packages/...") which are one directory too shallow from this file;
update the existence check and each path.resolve for the alias entries (the
block that sets "@blocknote/core" and "@blocknote/react") to go up three levels
(e.g., "../../../packages/core/src" and "../../../packages/react/src") so the
fs.existsSync correctly detects the repo packages and the aliasing works.

Comment on lines +373 to +387
// Seed `defaultBlocks` for container blocks when no children would
// otherwise be present — covers both `block.children === undefined` and
// `block.children === []` (e.g. converting a leaf block whose
// `nodeToBlock` produced empty children into a container).
if (children.length === 0) {
// `container` is normalized to `ContainerConfig | undefined` at spec
// registration time (see addNodeAndExtensionsToSpec).
const containerConfig = getBlockSchema(schema)[block.type]
?.container as ContainerConfig | undefined;
const defaultBlocks = containerConfig?.defaultBlocks;
if (defaultBlocks && defaultBlocks.length > 0) {
effectiveChildren = defaultBlocks.map((type) =>
blockToNode({ type } as PartialBlock<any, any, any>, schema, styleSchema),
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard against recursive defaultBlocks expansion loops.

On Line 384, seeded defaults recurse through blockToNode without cycle protection. A container config like defaultBlocks: ["callout"] (or an indirect cycle) will recurse indefinitely and crash on insert/update.

Suggested fix
-export function blockToNode(
+export function blockToNode(
   block: PartialBlock<any, any, any>,
   schema: Schema,
   styleSchema: StyleSchema = getStyleSchema(schema),
+  defaultSeedPath: string[] = [],
 ) {
@@
-      if (defaultBlocks && defaultBlocks.length > 0) {
-        effectiveChildren = defaultBlocks.map((type) =>
-          blockToNode({ type } as PartialBlock<any, any, any>, schema, styleSchema),
-        );
+      if (defaultBlocks && defaultBlocks.length > 0) {
+        const currentType = block.type;
+        if (currentType && defaultSeedPath.includes(currentType)) {
+          throw new Error(
+            `container defaultBlocks cycle detected: ${[...defaultSeedPath, currentType].join(" -> ")}`,
+          );
+        }
+        const nextPath = currentType
+          ? [...defaultSeedPath, currentType]
+          : defaultSeedPath;
+        effectiveChildren = defaultBlocks.map((type) =>
+          blockToNode(
+            { type } as PartialBlock<any, any, any>,
+            schema,
+            styleSchema,
+            nextPath,
+          ),
+        );
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/api/nodeConversions/blockToNode.ts` around lines 373 - 387,
The defaultBlocks expansion in blockToNode can recurse indefinitely for cyclic
container defaults; modify blockToNode (or the call path that maps
defaultBlocks) to detect and prevent cycles by tracking a visited/set or depth
for block types (e.g., pass a visitedTypes Set or maxDepth through the
blockToNode call) and skip expanding a default block if its type is already in
the visited set (or depth exceeded); reference blockToNode, getBlockSchema,
containerConfig, defaultBlocks, and effectiveChildren when adding the cycle
guard so seeded defaults do not re-enter types already on the current expansion
stack.

Comment on lines +70 to +76
...Object.entries(editor.schema.blockSpecs)
.filter(([, spec]) => {
const group = (spec.implementation.node as Node).config.group;
return (
typeof group === "string" &&
group.split(/\s+/).includes("bnBlock")
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard access to spec.implementation.node before reading config.group.

Line [72] assumes every block spec has a node implementation. For specs without node, this can throw during editor setup and break initialization.

Suggested fix
         ...Object.entries(editor.schema.blockSpecs)
           .filter(([, spec]) => {
-            const group = (spec.implementation.node as Node).config.group;
+            if (!("node" in spec.implementation)) {
+              return false;
+            }
+            const group = (spec.implementation.node as Node).config.group;
             return (
               typeof group === "string" &&
               group.split(/\s+/).includes("bnBlock")
             );
           })
📝 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
...Object.entries(editor.schema.blockSpecs)
.filter(([, spec]) => {
const group = (spec.implementation.node as Node).config.group;
return (
typeof group === "string" &&
group.split(/\s+/).includes("bnBlock")
);
...Object.entries(editor.schema.blockSpecs)
.filter(([, spec]) => {
if (!("node" in spec.implementation)) {
return false;
}
const group = (spec.implementation.node as Node).config.group;
return (
typeof group === "string" &&
group.split(/\s+/).includes("bnBlock")
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/editor/managers/ExtensionManager/extensions.ts` around
lines 70 - 76, The code assumes every block spec has spec.implementation.node
and reads node.config.group directly; guard that access by first checking
spec.implementation.node exists and has a config (e.g., use an existence check
or optional chaining) before reading config.group so specs without a node don't
throw during setup; update the filter that references editor.schema.blockSpecs
and spec.implementation.node/config.group to skip entries where node or
node.config is missing while still matching the "bnBlock" group.

Comment on lines +51 to +69
update: (newNode: {
type: { name: string };
attrs: { id?: string; width?: number };
}) => {
if (newNode.type.name !== "column") {
return false;
}
const newWidth = newNode.attrs.width ?? COLUMN_WIDTH_DEFAULT;
dom.style.flexGrow = String(newWidth);
if (newWidth !== COLUMN_WIDTH_DEFAULT) {
dom.setAttribute("data-width", String(newWidth));
} else {
dom.removeAttribute("data-width");
}
if (newNode.attrs.id) {
dom.setAttribute("data-id", newNode.attrs.id);
}
return true;
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle data-id removal to avoid stale DOM identity.

At Line 65, data-id is only updated when truthy; if id becomes absent, the old attribute remains. That can desync DOM↔node identity and break lookup paths that rely on data-id (e.g., column resize node resolution).

Proposed fix
           if (newNode.attrs.id) {
             dom.setAttribute("data-id", newNode.attrs.id);
+          } else {
+            dom.removeAttribute("data-id");
           }
📝 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
update: (newNode: {
type: { name: string };
attrs: { id?: string; width?: number };
}) => {
if (newNode.type.name !== "column") {
return false;
}
const newWidth = newNode.attrs.width ?? COLUMN_WIDTH_DEFAULT;
dom.style.flexGrow = String(newWidth);
if (newWidth !== COLUMN_WIDTH_DEFAULT) {
dom.setAttribute("data-width", String(newWidth));
} else {
dom.removeAttribute("data-width");
}
if (newNode.attrs.id) {
dom.setAttribute("data-id", newNode.attrs.id);
}
return true;
},
update: (newNode: {
type: { name: string };
attrs: { id?: string; width?: number };
}) => {
if (newNode.type.name !== "column") {
return false;
}
const newWidth = newNode.attrs.width ?? COLUMN_WIDTH_DEFAULT;
dom.style.flexGrow = String(newWidth);
if (newWidth !== COLUMN_WIDTH_DEFAULT) {
dom.setAttribute("data-width", String(newWidth));
} else {
dom.removeAttribute("data-width");
}
if (newNode.attrs.id) {
dom.setAttribute("data-id", newNode.attrs.id);
} else {
dom.removeAttribute("data-id");
}
return true;
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/xl-multi-column/src/blocks/Columns/index.ts` around lines 51 - 69,
The update handler in the Columns block (the update: (newNode: ...) function)
currently only sets data-id when newNode.attrs.id is truthy, leaving a stale
data-id on the DOM if id is removed; change the logic so that when
newNode.attrs.id is present you set dom.setAttribute("data-id", id) and when
it's absent you call dom.removeAttribute("data-id") — mirror the existing
data-width handling around COLUMN_WIDTH_DEFAULT to ensure the DOM's data-id
always reflects the node's attrs.id.

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