Skip to content

fix(core): fix unnesting blocks with siblings (BLO-1017)#2601

Open
YousefED wants to merge 4 commits intomainfrom
fix/unnest-block-bugs-blo-1017
Open

fix(core): fix unnesting blocks with siblings (BLO-1017)#2601
YousefED wants to merge 4 commits intomainfrom
fix/unnest-block-bugs-blo-1017

Conversation

@YousefED
Copy link
Copy Markdown
Collaborator

@YousefED YousefED commented Mar 30, 2026

Summary

Adds a custom liftItem / liftToOuterList implementation — a minimal adaptation of ProseMirror's upstream liftListItem / liftToOuterList — to fix crashes when unnesting blocks that have siblings after them.

Rationale

ProseMirror's upstream liftListItem assumes a flexible list schema (listItem content = paragraph block*), but BlockNote uses a strict blockContainer schema (blockContent blockGroup?). When unnesting a block that has siblings after it, the upstream code creates invalid blockContainer(blockGroup(...)) nodes (missing the required blockContent), causing RangeError: Invalid content for node blockContainer.

The existing sinkListItem (nesting) was already adapted for BlockNote's schema, but the symmetric liftListItem (unnesting) was not — it used TipTap's unmodified upstream directly.

Changes

  • nestBlock.ts: Added liftToOuterList and liftItem as minimal adaptations of the upstream PM functions, following the same pattern as the existing sinkItem adaptation. Key changes from upstream:
    • Range predicate checks node.type for blockGroup/column instead of firstChild.type
    • nestedAfter detection adjusts ReplaceAroundStep depth to merge siblings into existing child blockGroups
    • Uses groupType.create() instead of range.parent.copy()
    • Skips liftOutOfList path (root-level blocks can't be unnested)
  • KeyboardShortcutsExtension.ts: Replaced commands.liftListItem("blockContainer") with direct liftItem(tr, ...) calls in Backspace/Enter handlers, and unnestBlock(editor) in Shift-Tab handler
  • Renamed sinkListItemsinkItem and liftListItemliftItem (no lists in BlockNote)
  • unnestBlock now returns the boolean result (matching nestBlock)

Impact

Closes #73
Closes #1338
Closes #2066
Closes #481
Closes #598

Testing

  • 15 new unit tests in nestBlock.test.ts covering all 5 bug scenarios plus edge cases (root-level blocks, different block types, existing children + siblings, nest/unnest round-trip)
  • All 305 existing core tests pass with no regressions

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

The implementation mirrors the existing sinkItem pattern exactly: a 1-1 copy of the upstream PM source with clearly numbered minimal changes documented in the docblock.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved nesting/unnesting: handles deep and sequential unnesting, preserves children and sibling structure, and avoids errors on edge cases (including prior Shift‑Tab/backspace regressions).
  • Tests

    • Added a comprehensive test suite exercising many nest/unnest scenarios, validating behavior across multi‑level and edge‑case documents and ensuring round‑trip integrity.
  • Refactor

    • Reworked lift/unnest logic and switched keyboard shortcuts to use the new transaction-level lift flow for more reliable, consistent behavior.

…(BLO-1017)

ProseMirror's upstream liftListItem doesn't work with BlockNote's strict
blockContainer schema (blockContent blockGroup?), producing RangeError
when unnesting blocks that have siblings after them. This adds a minimal
adaptation of liftToOuterList (mirroring the existing sinkItem adaptation)
that correctly handles merging siblings into existing child blockGroups.

Fixes BLO-835, BLO-899, BLO-953, BLO-844, BLO-847.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 30, 2026

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

Project Deployment Actions Updated (UTC)
blocknote Ready Ready Preview Apr 7, 2026 7:28pm
blocknote-website Ready Ready Preview Apr 7, 2026 7:28pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 30, 2026

📝 Walkthrough

Walkthrough

Refactors nesting/unnesting to explicit transaction-level handlers: adds exported liftItem, replaces TipTap's liftListItem calls with liftItem/unnestBlock transactions, updates keyboard shortcuts to use the new APIs, and adds a comprehensive Vitest suite exercising nestBlock() and unnestBlock() across edge cases and regressions.

Changes

Cohort / File(s) Summary
Tests
packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts
Adds a new Vitest suite that mounts a shared BlockNoteEditor, repeatedly swaps documents with PartialBlock[] fixtures, and exercises many nest/unnest edge cases (shift-tab/backspace/lift scenarios) with snapshots and ID/content assertions.
Core Nesting Logic
packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
Renamed sinkListItemsinkItem; introduced liftToOuterList and exported liftItem(tr, itemType, groupType); changed unnestBlock to call liftItem inside editor.transact; adjusted slice/ReplaceAroundStep/lift/join logic and blockRange predicate to use node.type.
Keyboard Integration
packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
Replaced commands.liftListItem("blockContainer") with direct liftItem(...) and unnestBlock(...) calls; updated affected shortcut callbacks to accept { state, tr } where needed and adjusted imports.

Sequence Diagram

sequenceDiagram
    actor User
    participant Keyboard as "KeyboardShortcuts"
    participant Editor as "BlockNoteEditor"
    participant Tr as "Transaction (tr)"
    participant Schema as "Schema / NodeTypes"

    User->>Keyboard: press Shift-Tab / invoke shortcut
    Keyboard->>Editor: call unnestBlock(editor)
    Editor->>Editor: editor.transact(fn)
    Editor->>Tr: fn(tr) -> liftItem(tr, itemType, groupType)
    Tr->>Schema: compute blockRange (predicate on node.type)
    alt selection inside itemType
        Tr->>Tr: liftToOuterList(tr, itemType, groupType, range)
        Tr->>Tr: apply ReplaceAroundStep / create Slice
        Tr->>Tr: tr.lift and optional tr.join
    else not inside itemType
        Tr->>Tr: return false (no-op)
    end
    Tr->>Editor: scrollIntoView / commit
    Editor->>User: UI updates (block un-nested)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • nperez0111
  • matthewlipski

Poem

🐇 I hopped through nodes both deep and light,

I pushed and pulled with transactional might.
Tests now watch each nest and lift,
IDs preserved, no missing drift.
A joyful rabbit cheers tonight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% 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 PR title clearly summarizes the main fix: addressing unnesting block bugs when blocks have siblings, directly corresponding to the core issue resolved in this changeset.
Description check ✅ Passed The PR description is comprehensive, covering Summary, Rationale, Changes, Impact, Testing, and Checklist sections that align with the template; it thoroughly documents the problem, solution, and testing approach.
Linked Issues check ✅ Passed The PR successfully addresses all five linked issues (#73, #1338, #2066, #481, #598) by fixing the upstream liftListItem behavior for BlockNote's strict schema, preventing Invalid content errors when unnesting blocks with siblings.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing unnesting block issues: nestBlock.ts adds liftItem/liftToOuterList, KeyboardShortcutsExtension.ts updates handlers to use the new implementation, and nestBlock.test.ts validates the fixes—no unrelated modifications present.

✏️ 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 fix/unnest-block-bugs-blo-1017

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.

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.

🧹 Nitpick comments (2)
packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts (1)

98-98: Use const instead of let since end is never reassigned.

🔧 Proposed fix
-  let end = range.end;
+  const end = range.end;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts` at
line 98, Change the variable declaration for end from a mutable binding to an
immutable one because it is never reassigned; in nestBlock.ts (inside the
nestBlock function / scope where range is used) replace "let end = range.end"
with a const declaration so the identifier end is declared with const for
clarity and safety.
packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts (1)

1-6: Remove unused import beforeEach.

The beforeEach hook is imported but never used in this test file.

🧹 Proposed fix
 import { describe, expect, it } from "vitest";

 import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
 import { PartialBlock } from "../../../../blocks/defaultBlocks.js";
-import { afterAll, beforeAll, beforeEach } from "vitest";
+import { afterAll, beforeAll } from "vitest";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts`
around lines 1 - 6, The import list at the top of nestBlock.test.ts includes an
unused symbol beforeEach; remove beforeEach from the import statement (leaving
describe, expect, it, beforeAll, afterAll as needed) so the file no longer
imports an unused identifier, then run tests/lint to confirm no references to
beforeEach remain.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts`:
- Around line 1-6: The import list at the top of nestBlock.test.ts includes an
unused symbol beforeEach; remove beforeEach from the import statement (leaving
describe, expect, it, beforeAll, afterAll as needed) so the file no longer
imports an unused identifier, then run tests/lint to confirm no references to
beforeEach remain.

In `@packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts`:
- Line 98: Change the variable declaration for end from a mutable binding to an
immutable one because it is never reassigned; in nestBlock.ts (inside the
nestBlock function / scope where range is used) replace "let end = range.end"
with a const declaration so the identifier end is declared with const for
clarity and safety.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 41f813a5-79b4-4d59-b40b-63b6c523e838

📥 Commits

Reviewing files that changed from the base of the PR and between a850078 and eb6502a.

⛔ Files ignored due to path filters (1)
  • packages/core/src/api/blockManipulation/commands/nestBlock/__snapshots__/nestBlock.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (3)
  • packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts
  • packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
  • packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.

🧹 Nitpick comments (2)
packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts (2)

1-5: Consider consolidating vitest imports.

The imports from vitest are split across two statements. Consolidating them improves readability.

Suggested consolidation
-import { describe, expect, it } from "vitest";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";

 import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
 import { PartialBlock } from "../../../../blocks/defaultBlocks.js";
-import { afterAll, beforeAll } from "vitest";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts`
around lines 1 - 5, Consolidate the two separate vitest import statements into a
single import that brings in describe, expect, it, beforeAll, and afterAll;
update the top of the test file where vitest is imported so the symbols
(describe, expect, it, beforeAll, afterAll) come from one import statement
rather than two separate ones.

594-618: Consider adding a structural verification for the round-trip test.

The current assertions verify content preservation, but don't explicitly confirm that block1.children is empty after the round-trip. Adding this check would strengthen the test against edge cases where children arrays might unexpectedly retain entries.

Optional enhancement
       // Content should be preserved (IDs may differ but structure/content same)
       expect(editor.document.length).toBe(originalDoc.length);
       expect(editor.document[0].content).toEqual(originalDoc[0].content);
       expect(editor.document[1].content).toEqual(originalDoc[1].content);
+      expect(editor.document[0].children).toEqual([]);
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts`
around lines 594 - 618, The test "nest then unnest should be a round trip"
currently checks content but not structure; update the assertions after
editor.unnestBlock() to also verify that the first block's children array is
empty (e.g., assert that editor.document[0].children is undefined or has length
0) to ensure the nest/unnest cycle fully restores structure; locate this in the
test using the withEditor setup and the editor.nestBlock()/editor.unnestBlock()
calls and add a check referencing editor.document[0].children (or
block1.children) accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts`:
- Around line 1-5: Consolidate the two separate vitest import statements into a
single import that brings in describe, expect, it, beforeAll, and afterAll;
update the top of the test file where vitest is imported so the symbols
(describe, expect, it, beforeAll, afterAll) come from one import statement
rather than two separate ones.
- Around line 594-618: The test "nest then unnest should be a round trip"
currently checks content but not structure; update the assertions after
editor.unnestBlock() to also verify that the first block's children array is
empty (e.g., assert that editor.document[0].children is undefined or has length
0) to ensure the nest/unnest cycle fully restores structure; locate this in the
test using the withEditor setup and the editor.nestBlock()/editor.unnestBlock()
calls and add a check referencing editor.document[0].children (or
block1.children) accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 889f6867-99f8-4119-8be1-ff06a0cdeb51

📥 Commits

Reviewing files that changed from the base of the PR and between eb6502a and f04b8ce.

📒 Files selected for processing (2)
  • packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts
  • packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts

Copy link
Copy Markdown
Contributor

@nperez0111 nperez0111 left a comment

Choose a reason for hiding this comment

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

lgtm

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.

🧹 Nitpick comments (3)
packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts (3)

621-645: Round-trip test could verify block IDs are preserved.

The round-trip test verifies document length and content equality, but doesn't verify that block IDs are preserved. Since block IDs may differ (as noted in the comment on line 641), consider either:

  1. Explicitly verifying IDs are preserved (if that's the expected behavior), or
  2. Using a more comprehensive structural comparison that ignores IDs.
✨ Suggested enhancement for more comprehensive verification
      // Content should be preserved (IDs may differ but structure/content same)
      expect(editor.document.length).toBe(originalDoc.length);
      expect(editor.document[0].content).toEqual(originalDoc[0].content);
      expect(editor.document[1].content).toEqual(originalDoc[1].content);
+     // Verify IDs are preserved (if expected)
+     expect(editor.document[0].id).toBe(originalDoc[0].id);
+     expect(editor.document[1].id).toBe(originalDoc[1].id);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts`
around lines 621 - 645, The test currently checks content/length after calling
nestBlock() and unnestBlock() but doesn't assert block IDs; update the test
around withEditor / editor.document to also assert that IDs are preserved across
the round trip by comparing originalDoc[0].id and originalDoc[1].id to
editor.document[0].id and editor.document[1].id (or, if the intended behavior is
that IDs can change, replace the simple content checks with a structural
comparison that ignores the id fields when comparing originalDoc and
editor.document). Ensure you reference the same originalDoc variable and the
editor.document array when adding these assertions.

1-3: Consider consolidating vitest imports.

The vitest imports are split across two lines. Consolidating them improves readability.

✨ Suggested consolidation
-import { describe, expect, it } from "vitest";
-
-import { afterAll, beforeAll } from "vitest";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts`
around lines 1 - 3, The test file currently imports from vitest on two separate
statements; consolidate them into a single import by merging the named imports
(describe, expect, it, beforeAll, afterAll) into one import from "vitest",
removing the duplicate import line so the test uses a single consolidated vitest
import statement.

635-635: Consider using structuredClone for deep copying.

structuredClone is a cleaner alternative to JSON.parse(JSON.stringify()) for deep cloning objects.

✨ Suggested change
-      const originalDoc = JSON.parse(JSON.stringify(editor.document));
+      const originalDoc = structuredClone(editor.document);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts`
at line 635, Replace the manual deep-copy using
JSON.parse(JSON.stringify(editor.document)) with
structuredClone(editor.document) by changing the assignment to const originalDoc
= structuredClone(editor.document); if your test runtime may not support
structuredClone, add a small fallback (e.g., globalThis.structuredClone ??
((obj)=>require('lodash/cloneDeep')(obj)) or import the v8 structuredClone
polyfill) so tests remain portable; update the reference in nestBlock.test.ts
where originalDoc is created.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts`:
- Around line 621-645: The test currently checks content/length after calling
nestBlock() and unnestBlock() but doesn't assert block IDs; update the test
around withEditor / editor.document to also assert that IDs are preserved across
the round trip by comparing originalDoc[0].id and originalDoc[1].id to
editor.document[0].id and editor.document[1].id (or, if the intended behavior is
that IDs can change, replace the simple content checks with a structural
comparison that ignores the id fields when comparing originalDoc and
editor.document). Ensure you reference the same originalDoc variable and the
editor.document array when adding these assertions.
- Around line 1-3: The test file currently imports from vitest on two separate
statements; consolidate them into a single import by merging the named imports
(describe, expect, it, beforeAll, afterAll) into one import from "vitest",
removing the duplicate import line so the test uses a single consolidated vitest
import statement.
- Line 635: Replace the manual deep-copy using
JSON.parse(JSON.stringify(editor.document)) with
structuredClone(editor.document) by changing the assignment to const originalDoc
= structuredClone(editor.document); if your test runtime may not support
structuredClone, add a small fallback (e.g., globalThis.structuredClone ??
((obj)=>require('lodash/cloneDeep')(obj)) or import the v8 structuredClone
polyfill) so tests remain portable; update the reference in nestBlock.test.ts
where originalDoc is created.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 37d032a5-d4bf-4909-8a12-59a1800a2dd4

📥 Commits

Reviewing files that changed from the base of the PR and between 0de3bae and 594c600.

📒 Files selected for processing (1)
  • packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 7, 2026

Open in StackBlitz

@blocknote/ariakit

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

@blocknote/code-block

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

@blocknote/core

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

@blocknote/mantine

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

@blocknote/react

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

@blocknote/server-util

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

@blocknote/shadcn

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

@blocknote/xl-ai

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

@blocknote/xl-docx-exporter

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

@blocknote/xl-email-exporter

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

@blocknote/xl-multi-column

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

@blocknote/xl-odt-exporter

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

@blocknote/xl-pdf-exporter

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

commit: 594c600

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

Labels

None yet

Projects

None yet

2 participants