Skip to content

feat: add container block API for nested blocks - #2697

Closed
nperez0111 wants to merge 1 commit into
mainfrom
feat/nested-blocks
Closed

feat: add container block API for nested blocks#2697
nperez0111 wants to merge 1 commit into
mainfrom
feat/nested-blocks

Conversation

@nperez0111

@nperez0111 nperez0111 commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Adds first-class container blocks: custom blocks whose body is other blocks — a Notion-style callout wrapping a paragraph and a code block, a toggle with a title and a body, a multi-column layout, or a table. Declared with a children config on the block spec and exposed at runtime as block.children, the same field used for indented blocks, now with schema-enforced control over counts, allowed types, and rendering.

The smallest container is one line on any createBlockSpec / createReactBlockSpec config:

{
  type: "callout",
  propSchema: {},
  content: "none",
  children: { allow: "any" },
}

Child blocks mount into the element the render passes contentRef (React) / contentDOM (vanilla) to — the same placement mechanism as inline content. The block's JSON shape is unchanged: children live in the standard children array.

API surface

  • children: { allow, min, max, default, whenEmptied, boundary } on block configs.
    • allow: "any" | "blocks" | "containers" | an array of container block types. Wildcards never include containerOnly types; the array form is exact.
    • min / max: child counts, compiled into the editor schema.
    • default: insertion template used when a container is created without children, and the seed for refills.
    • whenEmptied: "refill" (top the container back up from default) or "unwrap" (replace the container with its survivors — how an emptied column list dissolves).
    • boundary: "open" | "isolated" | "sealed" — what crosses the container's edge (caret, editing gestures, text selections). "sealed" makes a compartment like a table cell with no hand-written keyboard handlers: Backspace/Delete/Enter/arrows never implicitly move content across the edge, while the block manipulation API always crosses seals as an intentional act.
  • placement: "containerOnly" restricts a block to containers that name it in their allow array (e.g. column inside columnList).
  • Containers with their own content: children combines with content: "inline" / "plain" (a toggle's title plus its body), rendered as [data-content-type] and [data-children-of] regions placed by the same single contentRef.
  • insertBlocks gains "start" / "end" placements to insert into a container's children — addressing containers that currently have no child to point at.
  • Up-front schema validation: impossible allows, unknown or regular block types in allow arrays, invalid defaults, orphaned containerOnly blocks, and container cycles all fail at schema creation with a message naming the block.
  • meta.draggable lets structural blocks opt out of the side-menu drag handle, falling through to the nearest draggable ancestor.

Interop

  • Containers round-trip through internal HTML via data-node-type, with non-default props as data-* attributes.
  • Foreign HTML is recognized via parse / parseContent on the block implementation; runsBefore orders container parse rules against each other (containers register in a priority band below regular blocks).
  • External HTML stays semantic via toExternalHTML returning a childrenDOM (how a toggle exports as <details>).
  • Markdown flattens containers; exporters (docx/PDF/ODT/email) require an explicit mapping and throw a clear error otherwise.

Reimplementations & examples

  • @blocknote/xl-multi-column (columnList / column) is reimplemented on this API, dropping its bespoke node implementations.
  • Container-block example: a callout holding arbitrary blocks, plus the string-prop pattern for non-document fields.
  • Container-table example: a stress test of the API — a working table built from four container specs (table, tableRow, tableCell, tableHeader) using only public APIs: sealed cell boundaries, containerOnly placement, Tab/Shift-Tab cell navigation via createExtension keyboard shortcuts, and row/column operations through insertBlocks / removeBlocks / updateBlock. Advanced table features (cell merging, column resizing, table handles) are intentionally out of scope; the point is proving the primitive carries the structural weight.

Docs

New Container Blocks page covering declaration, the two regions, boundaries, defaults/refilling, restricting children, validation, parsing, and interop; manipulating-content reference updated for the new insert placements.

@vercel

vercel Bot commented Apr 30, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
blocknote Error Error Aug 21, 2026 4:18pm
blocknote-website Error Error Aug 21, 2026 4:18pm

Request Review

@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor
📝 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
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.
Title check ✅ Passed The title clearly summarizes the main change: introducing a container block API for nested blocks.
Description check ✅ Passed The description provides detailed coverage of the feature, API, rationale, implementation, interoperability, examples, and documentation impact.
✨ 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.

@pkg-pr-new

pkg-pr-new Bot commented Apr 30, 2026

Copy link
Copy Markdown

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: 863e953

@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: 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
Contributor

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-21 19:56 UTC

Adds first-class *container blocks*: custom blocks whose body is other
blocks, declared with a `children` config on the block spec and exposed
at runtime as `block.children` — the same field used for indented
blocks, now with schema-enforced control over counts, allowed types, and
rendering.

API surface:
- `children: { allow, min, max, default, whenEmptied, boundary }` on
  block configs (createBlockSpec / createReactBlockSpec); children mount
  into the render's contentRef/contentDOM
- `allow`: "any" | "blocks" | "containers" | [container types]
- `whenEmptied`: "refill" (top up from `default`) or "unwrap"
  (dissolve into survivors) when non-empty children drop below `min`
- `boundary`: "open" | "isolated" | "sealed" — declares what
  crosses the container's edge (caret, gestures, text selections);
  sealed compartments need no hand-written keyboard handlers, and the
  block manipulation API always crosses seals intentionally
- `placement: "containerOnly"` restricts a block to containers that
  name it (e.g. column inside columnList)
- Containers may combine children with their own `content:
  "inline"`/"plain" (toggle title + body), rendered as
  data-content-type / data-children-of regions
- insertBlocks gains "start"/"end" placements to insert into a
  container's children
- Schema validation catches impossible configs up front (empty allow,
  container cycles, invalid defaults, misplaced containerOnly)

Interop: containers round-trip through internal HTML via
data-node-type, support custom `parse`/`parseContent` for foreign
HTML, serialize semantically via toExternalHTML/childrenDOM, and flatten
in Markdown. The multi-column blocks (columnList/column) are reimplemented
on this API, dropping their bespoke node implementations.

Includes the container-block callout example, a container-table example
stress-testing the API (a table built from four container specs using
only public APIs), and docs for the feature.
@nperez0111

Copy link
Copy Markdown
Contributor Author

Superseded by the stacked split: #2997 (core container API) → #2998 (multi-column migration) → #2999 (docs & examples).

@nperez0111 nperez0111 closed this Aug 21, 2026
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