Skip to content

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

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

fix(core): fix unnesting blocks with siblings (BLO-1017)#2601
YousefED wants to merge 2 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 and unnesting behavior: handles deep and sequential unnesting, preserves children and sibling relationships, and avoids errors on edge cases.
  • Tests

    • Added a comprehensive test suite covering many nesting/unnesting scenarios and block types.
  • Refactor

    • Updated unnest handling used by keyboard shortcuts (e.g., Shift‑Tab) 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 Mar 30, 2026 1:12pm
blocknote-website Ready Ready Preview Mar 30, 2026 1:12pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 30, 2026

📝 Walkthrough

Walkthrough

Refactors nesting/unnesting to explicit transaction-based handlers, adds exported liftItem, updates keyboard shortcuts to call the new APIs, and introduces a comprehensive Vitest suite exercising nestBlock() and unnestBlock() across regression and edge cases.

Changes

Cohort / File(s) Summary
Tests
packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts
New Vitest file (~630 lines) adding regression and edge-case tests for nestBlock() / unnestBlock(), using setupNestTestEnv() and snapshot/content assertions.
Core Nesting Logic
packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
Renamed sinkListItemsinkItem; added liftToOuterList and exported liftItem(tr, itemType, groupType); changed unnestBlock to transact liftItem instead of TipTap liftListItem.
Keyboard Integration
packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
Replaced commands.liftListItem("blockContainer") usages with liftItem(...) and updated Shift‑Tab to call unnestBlock(editor). Callback signatures adjusted to receive { state, tr } where necessary.

Sequence Diagram

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

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

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • nperez0111

Poem

🐇 I hopped through nodes both deep and light,

sank and lifted by transaction's sight.
Tests now guard each twist and part —
a rabbit's cheer for careful art! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 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 (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main fix: addressing unnesting blocks with siblings, and is concise and specific.
Description check ✅ Passed The pull request description is comprehensive and well-structured, covering all major template sections with clear details about the feature, rationale, changes, impact, testing, and known limitations.

✏️ 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

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

Labels

None yet

Projects

None yet

1 participant