Skip to content

feat(core): support "plain" block content#2868

Open
YousefED wants to merge 2 commits into
mainfrom
feat/plain-block-content
Open

feat(core): support "plain" block content#2868
YousefED wants to merge 2 commits into
mainfrom
feat/plain-block-content

Conversation

@YousefED

@YousefED YousefED commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a "plain" block content type — for blocks that hold an unstyled text string rather than rich inline content — and migrates the code block to use it. At the ProseMirror level a plain block is text*; in the block model its content is a string. Plain blocks disallow formatting marks but allow the non-formatting marks (comments and suggestions/diffs).

Rationale

Some blocks (code blocks being the canonical example) should hold plain text only: no bold/italic/links, no inline-content array to reason about. Before this, the only options were "inline" (rich content) or "none" (no content). "plain" fills that gap with a simpler, string-typed content shape — while still supporting comments and tracked-changes on the block's text.

Changes

  • Type system: propagate "plain" through BlockFromConfig / PartialBlock, createBlockSpec, and the schema helpers so a block/inline-content spec declared with content: "plain" infers string.
  • Runtime: node spec emits text*; nodeToBlock returns the text content as a string; createSpec parse/render handle plain; schemaToJSONSchema maps plain → { type: "string" } (xl-ai).
  • Code block: migrated to content: "plain".
  • Marks on plain blocks: a shared "annotation" mark group is added to the comment mark and the suggestion marks (insertion/deletion/modification); plain blocks allow that group, so formatting marks are excluded but comments and suggestions/diffs are not. The always-present suggestion marks keep the group non-empty, so the reference is valid even when comments aren't configured (the comment mark is conditional).
  • Exporters: docx / odt / pdf / email default schemas now read code-block content as a string.
  • Yjs backwards compatibility: a pre-sync migration (stripDisallowedMarks). Legacy code blocks whose text carries formatting marks are otherwise deleted by y-prosemirror during reconstruction — and the deletion propagates to all peers. The migration strips only the disallowed (formatting) marks from the Y.XmlFragment before y-prosemirror touches it (keeping comments/suggestions), covering both documents present before mount and content that syncs in afterwards, and tears down once migration is done.
  • Tooling: tests/ is now type-checked/linted by vp lint; added a Commands section to CONTRIBUTING.md.

Impact

  • Existing "inline" / "none" / "table" blocks are unaffected (additive to the content union).
  • Breaking-ish for stored code blocks: a code block previously held inline content (could be formatted); it now holds a plain string. Formatting is dropped at the block-model level. The Yjs migration ensures old collaborative documents load gracefully (keeping comments/suggestions) instead of losing the block.
  • Limitations (documented in code): the Yjs pre-sync migration is one-shot (like the existing post-sync migration) — disallowed content arriving from an old-version peer after the first sync isn't re-migrated. Plain blocks reference the "annotation" group, which assumes the always-present suggestion marks keep it non-empty.

Testing

  • contentTypePropagation.test.ts: type-level assertions that "plain" (and the baseline kinds) propagate to the right content type for blocks and inline content. Verified it fails to compile if propagation regresses.
  • markGroups.test.ts: a code block allows insertion/deletion/modification (and comment when comments are enabled) but not bold.
  • stripDisallowedMarks.test.ts: rule unit tests + the two real collaborative flows (formatted code block present before mount, and synced in after mount) survive with content intact and the Yjs fragment uncorrupted; the migration keeps comment + suggestion marks while stripping formatting; the observer is removed once migration completes.
  • Updated stale fixtures/snapshots (core code-block + yjs tests, tests/ parse/export-equality JSON, xl-ai schema snapshot) to the new string shape.
  • Full @blocknote/core suite green; exporter, xl-ai, and xl-multi-column suites green.

Screenshots/Video

N/A — no visual changes.

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

Plain blocks deliberately disallow formatting marks (so code blocks can't be bolded/linked) but allow the non-formatting marks — comments and suggestions/diffs — via a shared "annotation" mark group. This keeps the "plain = unstyled string" semantics at the block-model level (those marks are blocknoteIgnore and don't appear in the block's content) while letting collaboration features work on code.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added support for plain text block content (including code blocks) with proper type inference and editor behavior.
  • Bug Fixes
    • Updated cursor/selection and conversion logic so plain blocks position and migrate correctly.
    • Added pre-sync schema migration to strip disallowed formatting marks for legacy collaborative content.
  • Documentation
    • Updated contribution guide commands to use pnpm dev and added a repo command reference.
  • Tests
    • Expanded type and Yjs migration coverage, including plain/code-block fixtures.
  • Chores
    • Refined tooling ignore patterns.

Adds a "plain" block content type for blocks that hold an unstyled text
string (rather than rich inline content), and migrates the code block to
use it. Plain content is `text*` with `marks: ""` at the ProseMirror level
and a `string` in the block model.

- Propagate "plain" through the block/inline-content type system
  (BlockFromConfig, PartialBlock, createBlockSpec, schema helpers) so a
  block/inline-content spec with `content: "plain"` infers `string`.
- Wire the runtime: node spec (text*, marks: ""), nodeToBlock,
  createSpec parse/render, schemaToJSONSchema (plain -> string).
- Update the default exporters (docx/odt/pdf/email) and the shared test
  util to read plain content as a string.
- Add a Yjs pre-sync migration (stripDisallowedMarks) so legacy code
  blocks that carry marks load gracefully instead of being deleted by
  y-prosemirror; covers both pre-mount and after-mount-sync loads and
  tears down once migration is done.
- Type-check tests/ via vp lint and add a root contributing guide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 23, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
blocknote Ready Ready Preview Jun 23, 2026 1:25pm
blocknote-website Ready Ready Preview Jun 23, 2026 1:25pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds "plain" as a new block content kind that stores content as a raw string (instead of inline nodes). The code block definition adopts "plain", and the change propagates through TypeScript type contracts, schema spec creation, node conversion, cursor logic, all exporters, and the AI JSON schema. A pre-sync Yjs migration rule (stripDisallowedMarks) is added to strip formatting marks from existing "plain" blocks in collaborative documents before y-prosemirror reconstruction. Non-formatting annotation marks (comment, insertion/deletion/modification) are allowed on "plain" blocks via a new NON_FORMATTING_MARK_GROUP.

Changes

"plain" content type for blocks

Layer / File(s) Summary
Type contracts: 'plain' content kind
packages/core/src/schema/blocks/types.ts, packages/core/src/schema/inlineContent/types.ts, packages/core/src/schema/blocks/internal.ts
Generic constraints and conditional type mappings across BlockConfig, BlockSpec, BlockImplementation, CustomBlockConfig, CustomInlineContentConfig, and related extractor types are widened to include "plain"; BlockFromConfigNoChildren and PartialBlockFromConfigNoChildren gain a "plain" → string branch.
Schema spec: TipTap node and parse rules
packages/core/src/schema/blocks/createSpec.ts
getParseRules, addNodeAndExtensionsToSpec, and all createBlockSpec overloads accept "plain"; "plain" blocks map to a "text*" content model with NON_FORMATTING_MARK_GROUP marks; a clipboard parse branch converts textContent to a text-node fragment; contentDOM is set for "plain" blocks in renderHTML.
Mark groups: non-formatting annotation marks
packages/core/src/schema/markGroups.ts, packages/core/src/schema/markGroups.test.ts, packages/core/src/comments/mark.ts, packages/core/src/extensions/tiptap-extensions/Suggestions/SuggestionMarks.ts
Defines NON_FORMATTING_MARK_GROUP and NON_FORMATTING_MARK_NAMES (comment, insertion, deletion, modification); updates CommentMark and all three suggestion marks to declare this group; compile-time tests verify mark authorization on "plain" blocks.
Runtime: node conversion, cursor, code block adoption
packages/core/src/api/nodeConversions/nodeToBlock.ts, packages/core/src/api/blockManipulation/selections/textCursorPosition.ts, packages/core/src/blocks/Code/block.ts, shared/formatConversionTestUtil.ts
nodeToBlock gains a "plain" branch reading textContent; setTextCursorPosition applies inline positioning to "plain"; createCodeBlockConfig switches from "inline" to "plain"; partialBlockToBlockForTesting adds a "plain" default and skips inline conversion for plain blocks.
Exporter and AI schema updates
packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts, packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx, packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx, packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx, packages/xl-ai/src/api/schema/schemaToJSONSchema.ts
All exporters replace StyledText[] casts with a direct string check on block.content; StyledText imports are removed; blockSchemaToJSONSchema gains a "plain" → { type: "string" } branch.
Tests: 'plain' type propagation and code block behavior
packages/core/src/schema/contentTypePropagation.test.ts, packages/core/src/blocks/Code/block.test.ts, packages/core/src/yjs/utils.test.ts, packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx
Compile-time type tests with an Equal utility verify "plain"→string inference across block and inline specs; code block assertions expect string content instead of arrays; fixture codeBlock content fields are updated to plain strings.

Yjs pre-sync migration: stripDisallowedMarks

Layer / File(s) Summary
PreSyncMigrationRule type and rule index
packages/core/src/yjs/extensions/schemaMigration/migrationRules/migrationRule.ts, packages/core/src/yjs/extensions/schemaMigration/migrationRules/index.ts
PreSyncMigrationRule is defined as (fragment, blockSchema) => void; named exports migrationRules and preSyncMigrationRules are added to the index and the default export is updated.
stripDisallowedMarks rule
packages/core/src/yjs/extensions/schemaMigration/migrationRules/stripDisallowedMarks.ts
Recursively traverses Yjs XmlElement descendants via traverseElement; for elements whose nodeName matches a "plain" block type, stripFormatting clears all mark attributes except those in NON_FORMATTING_MARK_NAMES from Y.XmlText children via text.format(..., null).
SchemaMigration: pre-sync observer lifecycle
packages/core/src/yjs/extensions/schemaMigration/SchemaMigration.ts
Adds runPreSyncMigrations and stopPreSyncObserver; runs pre-sync rules immediately if the fragment has content, otherwise via fragment.observeDeep; appendTransaction stops the observer after the first successful sync; a plugin view destroy hook cleans up on editor destruction.
Tests: stripDisallowedMarks unit and migration compatibility
packages/core/src/yjs/extensions/schemaMigration/migrationRules/stripDisallowedMarks.test.ts
Tests verify: bold marks stripped while text survives; disallowed marks removed while comment and insertion attributes remain; clean blocks unchanged; formatted code blocks present before or arriving after mount are preserved; observer count decreases after first content-bearing sync.

Sequence Diagram(s)

sequenceDiagram
  participant RemoteYjs as Remote Yjs update
  participant YjsFragment as Y.XmlFragment
  participant SchemaMigration
  participant stripDisallowedMarks
  participant yProsemirror as y-prosemirror
  participant appendTransaction

  rect rgba(255, 165, 0, 0.5)
    note over SchemaMigration,stripDisallowedMarks: Pre-sync phase (before document reconstruction)
    RemoteYjs->>YjsFragment: apply encoded state update
    YjsFragment-->>SchemaMigration: observeDeep fires → runPreSyncMigrations()
    SchemaMigration->>stripDisallowedMarks: stripDisallowedMarks(fragment, blockSchema)
    stripDisallowedMarks->>YjsFragment: text.format(0, len, {bold: null}) on "plain" blocks
  end

  rect rgba(70, 130, 180, 0.5)
    note over yProsemirror,appendTransaction: Post-sync phase (document reconstruction)
    yProsemirror->>appendTransaction: reconstruct ProseMirror doc from cleaned Yjs state
    appendTransaction->>SchemaMigration: first content-bearing sync detected
    SchemaMigration->>YjsFragment: fragment.unobserveDeep(runPreSyncMigrations)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • nperez0111

Poem

🐰 Hop hop, no bold in my code block tonight,
The "plain" type arrives, stripping marks left and right!
Yjs fragments scrubbed before sync's grand debut,
Exporters all updated — string content shines through.
A one-shot observer that fades like the dew,
From bunny with love: cleaner schemas for you! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.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 title 'feat(core): support "plain" block content' clearly and accurately summarizes the main feature introduced in this changeset.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering all required sections: Summary, Rationale, Changes, Impact, Testing, and Checklist, providing thorough context for the feature.
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.

✏️ 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/plain-block-content

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 OpenGrep (1.23.0)
packages/core/src/comments/mark.ts

┌──────────────┐
│ Opengrep CLI │
└──────────────┘

�[32m✔�[39m �[1mOpengrep OSS�[0m
�[32m✔�[39m Basic security coverage for first-party code vulnerabilities.

[00.16][ERROR]: unable to find a config; path .coderabbit-opengrep-fallback.yml does not exist

packages/core/src/schema/markGroups.test.ts

┌──────────────┐
│ Opengrep CLI │
└──────────────┘

�[32m✔�[39m �[1mOpengrep OSS�[0m
�[32m✔�[39m Basic security coverage for first-party code vulnerabilities.

[00.16][ERROR]: unable to find a config; path .coderabbit-opengrep-fallback.yml does not exist

packages/core/src/schema/blocks/createSpec.ts

┌──────────────┐
│ Opengrep CLI │
└──────────────┘

�[32m✔�[39m �[1mOpengrep OSS�[0m
�[32m✔�[39m Basic security coverage for first-party code vulnerabilities.

[00.19][ERROR]: unable to find a config; path .coderabbit-opengrep-fallback.yml does not exist

  • 4 others

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

❤️ Share

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

@pkg-pr-new

pkg-pr-new Bot commented Jun 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

@blocknote/ariakit

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

@blocknote/code-block

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

@blocknote/core

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

@blocknote/mantine

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

@blocknote/react

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

@blocknote/server-util

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

@blocknote/shadcn

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

@blocknote/xl-ai

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

@blocknote/xl-docx-exporter

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

@blocknote/xl-email-exporter

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

@blocknote/xl-multi-column

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

@blocknote/xl-odt-exporter

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

@blocknote/xl-pdf-exporter

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

commit: 2990657

@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

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

Built to branch gh-pages at 2026-06-23 13:28 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@YousefED

YousefED commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

Note that this PR takes the approach of mapping content: "plain" in the spec to a block with type content: string;

This means instantiated blocks can now hold a "string" as their content.

An alternative would have been to map content: "plain" to content: [{text: string, styles: {}] (i.e.: an unstyled piece of text).

The advantage of the latter is that the type system of instantiated blocks is unaffected, while the former (what's implemented here) is more strictly typed.

cc @nperez0111

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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/blocks/Code/block.ts`:
- Line 65: The code block content type has been updated to "plain" on line 65,
but the triple-backtick input-rule path still returns content as an array
instead of a string, causing a mismatch with the updated contract. Locate the
triple-backtick input-rule handler in this file (or in the input rules
configuration) and update its content payload to return a plain string value
instead of an array, ensuring consistency with the "plain" content type
declaration and preventing array content from leaking into string-expecting
paths.

In `@packages/core/src/yjs/extensions/schemaMigration/SchemaMigration.ts`:
- Around line 89-96: The stopPreSyncObserver() call halts all pre-sync migration
after the first sync without any version compatibility checking, leaving the
system vulnerable to silent document corruption if old-version peers send
disallowed content later. Either implement version negotiation and compatibility
gates at the collaboration provider level to ensure all peers are on compatible
versions before calling stopPreSyncObserver(), or conditionally keep the
pre-sync observer active when legacy client versions are detected to continue
filtering disallowed content from old-version peers throughout the session.
🪄 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: 71bae2d3-9dfb-4097-896b-d97bc44a08de

📥 Commits

Reviewing files that changed from the base of the PR and between ade62f2 and 3e590bd.

⛔ Files ignored due to path filters (16)
  • packages/xl-ai/src/api/schema/__snapshots__/schemaToJSONSchema.test.ts.snap is excluded by !**/*.snap, !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/codeBlock.json is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/complexDocument.json is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/defaultBlocks.json is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/specialCharEscaping.json is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocks.json is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocksMultiLine.json is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockBasic.json is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockIndented.json is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockPython.json is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockTildes.json is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockWithLanguage.json is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockWithSpecialChars.json is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/complexDocument.json is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/headingThenCode.json is excluded by !**/__snapshots__/**
  • tests/src/unit/core/schema/__snapshots__/blocks.json is excluded by !**/__snapshots__/**
📒 Files selected for processing (25)
  • CONTRIBUTING.md
  • packages/core/src/api/blockManipulation/selections/textCursorPosition.ts
  • packages/core/src/api/nodeConversions/nodeToBlock.ts
  • packages/core/src/blocks/Code/block.test.ts
  • packages/core/src/blocks/Code/block.ts
  • packages/core/src/schema/blocks/createSpec.ts
  • packages/core/src/schema/blocks/internal.ts
  • packages/core/src/schema/blocks/types.ts
  • packages/core/src/schema/contentTypePropagation.test.ts
  • packages/core/src/schema/inlineContent/types.ts
  • packages/core/src/yjs/extensions/schemaMigration/SchemaMigration.ts
  • packages/core/src/yjs/extensions/schemaMigration/migrationRules/index.ts
  • packages/core/src/yjs/extensions/schemaMigration/migrationRules/migrationRule.ts
  • packages/core/src/yjs/extensions/schemaMigration/migrationRules/stripDisallowedMarks.test.ts
  • packages/core/src/yjs/extensions/schemaMigration/migrationRules/stripDisallowedMarks.ts
  • packages/core/src/yjs/utils.test.ts
  • packages/xl-ai/src/api/schema/schemaToJSONSchema.ts
  • packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts
  • packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx
  • packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx
  • packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx
  • packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx
  • shared/formatConversionTestUtil.ts
  • tests/src/utils/positionalMouse.ts
  • vite.config.ts

},
},
content: "inline",
content: "plain",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Update code-block input-rule payload to plain-string content.

After Line 65 switches code blocks to "plain", this file’s triple-backtick input-rule path still returns content: []. That no longer matches the code block contract and can leak array content into paths now expecting a string.

Suggested fix
 return {
   type: "codeBlock",
   props: {
     language: attributes.language,
   },
-  content: [],
+  content: "",
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/blocks/Code/block.ts` at line 65, The code block content
type has been updated to "plain" on line 65, but the triple-backtick input-rule
path still returns content as an array instead of a string, causing a mismatch
with the updated contract. Locate the triple-backtick input-rule handler in this
file (or in the input rules configuration) and update its content payload to
return a plain string value instead of an array, ensuring consistency with the
"plain" content type declaration and preventing array content from leaking into
string-expecting paths.

Comment on lines +89 to +96
// The initial document has now synced in and been migrated by the
// pre-sync observer (which runs before this, during the y-sync). New
// edits can't introduce content the schema rejects, so the observer
// is no longer needed — stop it instead of running on every later
// transaction. (Like the post-sync migration, this is one-shot:
// disallowed content arriving from an old-version peer *after* the
// first sync would not be re-migrated.)
stopPreSyncObserver();

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.

🗄️ Data Integrity & Integration | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read-only verification: check for protocol/client-version gating around collaboration sessions.
# Expected result: evidence of strict mixed-version prevention (or explicit compatibility policy).

rg -n -C3 --type=ts --type=tsx --type=js --type=jsx \
  '\b(protocolVersion|schemaVersion|clientVersion|minVersion|compatibility|version gate|migration)\b' \
  packages/core/src

# Inspect collaboration wiring and SchemaMigration integration points.
rg -n -C4 'withCollaboration\(|SchemaMigration\(|YSyncExtension\(' packages/core/src

Repository: TypeCellOS/BlockNote

Length of output: 17930


🏁 Script executed:

# Read the SchemaMigration.ts file to understand the observer teardown and context
cat -n packages/core/src/yjs/extensions/schemaMigration/SchemaMigration.ts | head -120

Repository: TypeCellOS/BlockNote

Length of output: 5499


🏁 Script executed:

# Check for any version/compatibility checking in collaboration setup
rg -n 'version|Version|compatibility|Compatibility' packages/core/src/yjs/extensions/index.ts -A 5 -B 5

Repository: TypeCellOS/BlockNote

Length of output: 158


🏁 Script executed:

# Look for deployment or environment-based guards for migrations
rg -n 'environment|ENV|process\.env|deployment|legacy|old.client|old.version' packages/core/src/yjs -i

Repository: TypeCellOS/BlockNote

Length of output: 610


🏁 Script executed:

# Check the collaboration options type to see if version information is passed
ast-grep outline packages/core/src/yjs/extensions/index.ts

Repository: TypeCellOS/BlockNote

Length of output: 586


🏁 Script executed:

# Check the migration rules to understand what content is considered disallowed
cat -n packages/core/src/yjs/extensions/schemaMigration/migrationRules/index.ts

Repository: TypeCellOS/BlockNote

Length of output: 840


🏁 Script executed:

# Look at the stripDisallowedMarks rule mentioned in earlier tests
cat -n packages/core/src/yjs/extensions/schemaMigration/migrationRules/stripDisallowedMarks.ts | head -80

Repository: TypeCellOS/BlockNote

Length of output: 3169


🏁 Script executed:

# Check if there's any documentation about version compatibility
find packages/core -name "*.md" -o -name "*.txt" | xargs rg -l "version|compatibility|deploy" 2>/dev/null | head -5

Repository: TypeCellOS/BlockNote

Length of output: 246


🏁 Script executed:

# Search for any documented compatibility or version policy
rg -n "old.version|legacy|backward.compat|version.compat" packages/core -i

Repository: TypeCellOS/BlockNote

Length of output: 453


🏁 Script executed:

# Check the README files for any documentation about version compatibility
cat packages/core/src/yjs/README.md

Repository: TypeCellOS/BlockNote

Length of output: 534


🏁 Script executed:

# Check if there's a main README with deployment/version guidance
cat README.md | head -100

Repository: TypeCellOS/BlockNote

Length of output: 4436


🏁 Script executed:

# Look for any test cases that simulate old-version peer behavior
rg -n "old.*version|legacy.*sync|backward" packages/core/src/yjs/extensions/schemaMigration --type ts -A 5 -B 5

Repository: TypeCellOS/BlockNote

Length of output: 2956


🏁 Script executed:

# Check if there's any provider/collaboration documentation about version restrictions
rg -n "version" packages/core/src/yjs/extensions/YSync.ts -B 2 -A 2

Repository: TypeCellOS/BlockNote

Length of output: 158


🏁 Script executed:

# Check the moveColorAttributes post-sync migration rule
cat -n packages/core/src/yjs/extensions/schemaMigration/migrationRules/moveColorAttributes.ts | head -50

Repository: TypeCellOS/BlockNote

Length of output: 2242


🏁 Script executed:

# Verify the exact check condition for when post-sync migration runs
cat -n packages/core/src/yjs/extensions/schemaMigration/SchemaMigration.ts | sed -n '67,87p'

Repository: TypeCellOS/BlockNote

Length of output: 1205


Address mixed-version collaboration vulnerability—pre-sync observer stops after first sync without version gating.

The code acknowledges (lines 93–95) that disallowed content from old-version peers arriving after initial migration would not be re-migrated. However, no version negotiation, compatibility gate, or deployment policy exists to prevent such scenarios. After migrationDone is set, all migrations (pre- and post-sync) cease: appendTransaction returns early (line 69) and stopPreSyncObserver() halts further cleanup. If an old-version client sends disallowed marks in "plain" blocks after this point, y-prosemirror will reject and delete the node, silently corrupting the document across all peers.

Either implement version/compatibility checking at the collaboration provider level to prevent mixed-version sync, or keep pre-sync migration active when peers may include legacy clients.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/yjs/extensions/schemaMigration/SchemaMigration.ts` around
lines 89 - 96, The stopPreSyncObserver() call halts all pre-sync migration after
the first sync without any version compatibility checking, leaving the system
vulnerable to silent document corruption if old-version peers send disallowed
content later. Either implement version negotiation and compatibility gates at
the collaboration provider level to ensure all peers are on compatible versions
before calling stopPreSyncObserver(), or conditionally keep the pre-sync
observer active when legacy client versions are detected to continue filtering
disallowed content from old-version peers throughout the session.

"plain" blocks (e.g. code blocks) previously disallowed all marks
(`marks: ""`), which also blocked comments and suggestions/diffs. Allow
the non-formatting marks while still excluding formatting.

- Add a shared "annotation" mark group to the comment mark and the
  suggestion marks (insertion/deletion/modification), and reference that
  group from plain blocks' node spec. The always-present suggestion marks
  keep the group non-empty, so the reference resolves even when comments
  aren't configured (the comment mark is conditional).
- Update the stripDisallowedMarks migration to keep these non-formatting
  marks and strip only the disallowed formatting marks, so legacy code
  blocks retain their comments/suggestions.
- Tests: code block allows insertion/deletion/modification (and comment
  when comments are enabled) but not bold; migration keeps comment +
  insertion while stripping bold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@packages/core/src/yjs/extensions/schemaMigration/migrationRules/stripDisallowedMarks.test.ts`:
- Line 89: The arrow callback in the forEach method on the line with
Object.keys(op.attributes).forEach is using a concise arrow function body that
implicitly returns the value of remaining.add(k), which violates the Biome
linting rule requiring forEach callbacks to return void. Convert the concise
arrow callback to a block-bodied arrow callback by wrapping the remaining.add(k)
statement in curly braces, which will ensure the callback returns void instead
of the Set value.
🪄 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: eb09d941-7375-4485-94ca-8b7a11439ddd

📥 Commits

Reviewing files that changed from the base of the PR and between 3e590bd and 2990657.

📒 Files selected for processing (7)
  • packages/core/src/comments/mark.ts
  • packages/core/src/extensions/tiptap-extensions/Suggestions/SuggestionMarks.ts
  • packages/core/src/schema/blocks/createSpec.ts
  • packages/core/src/schema/markGroups.test.ts
  • packages/core/src/schema/markGroups.ts
  • packages/core/src/yjs/extensions/schemaMigration/migrationRules/stripDisallowedMarks.test.ts
  • packages/core/src/yjs/extensions/schemaMigration/migrationRules/stripDisallowedMarks.ts
✅ Files skipped from review due to trivial changes (2)
  • packages/core/src/schema/markGroups.ts
  • packages/core/src/extensions/tiptap-extensions/Suggestions/SuggestionMarks.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/core/src/yjs/extensions/schemaMigration/migrationRules/stripDisallowedMarks.ts
  • packages/core/src/schema/blocks/createSpec.ts

const remaining = new Set<string>();
for (const op of delta) {
if (op.attributes) {
Object.keys(op.attributes).forEach((k) => remaining.add(k));

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.

📐 Maintainability & Code Quality | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and read around line 89
fd -t f "stripDisallowedMarks.test.ts" --exec cat -n {} \; 2>/dev/null | head -120 | tail -40

Repository: TypeCellOS/BlockNote

Length of output: 1908


🏁 Script executed:

# Get the exact file and line
fd -t f "stripDisallowedMarks.test.ts" packages/

Repository: TypeCellOS/BlockNote

Length of output: 251


🏁 Script executed:

# Check if Biome config exists and what rules are enabled
find . -name "biome.json*" -o -name ".biomerc*" | head -5

Repository: TypeCellOS/BlockNote

Length of output: 158


🏁 Script executed:

# Search for Biome configuration or similar linting config
cat biome.json 2>/dev/null || cat biome.jsonc 2>/dev/null || echo "No biome config in root"

Repository: TypeCellOS/BlockNote

Length of output: 182


🏁 Script executed:

# Search for the specific Biome rule about forEach callbacks
rg "no-useless-catch|no-useless-escape|noUnusedVariables|forEach" --type json --type jsonc biome.json* 2>/dev/null | head -20

Repository: TypeCellOS/BlockNote

Length of output: 158


🏁 Script executed:

# Check if there are any ESLint/lint configs that might reference this rule
find . -maxdepth 3 -name "*.json" -o -name ".eslintrc*" | xargs grep -l "forEach\|callback" 2>/dev/null | head -10

Repository: TypeCellOS/BlockNote

Length of output: 158


🌐 Web query:

Biome rule forEach callback should not return value

💡 Result:

In Biome, the rule responsible for checking return values in iterable callbacks is useIterableCallbackReturn [1]. By default, this rule mandates that callbacks passed to.forEach should not return a value [1]. Starting from version 2.4.0, Biome introduced a configuration option called checkForEach to control this behavior [2][1]. When checkForEach is set to true (which is the default behavior), the rule will report an error if a.forEach callback returns a value [2][1]. If you prefer to allow return values in.forEach callbacks—for instance, when using concise arrow functions that unintentionally return a value—you can disable this check by setting checkForEach to false in your biome.json configuration file [2][1]: { "linter": { "rules": { "suspicious": { "useIterableCallbackReturn": { "options": { "checkForEach": false } } } } } } This configuration brings Biome's behavior in line with typical ESLint practices for this rule, where ignoring the return value of forEach is often preferred to avoid false positives in idiomatic code [3][4][2].

Citations:


🌐 Web query:

TypeScript Set.prototype.add return value forEach callback

💡 Result:

The return value and callback behavior for Set methods are as follows: Set.prototype.add(value) returns the Set object itself [1][2]. This design allows for method chaining (e.g., mySet.add(1).add(2)) [1][2]. Set.prototype.forEach(callbackFn, thisArg) executes the provided callback function once for each element in the Set [3][4]. This method does not return a value (it returns undefined) [3][5][6]. The callback function provided to forEach is invoked with three arguments [3][4]: 1. The element value: The current value being processed [3][4]. 2. The element key: In a Set, this is identical to the element value [3][4][6]. This argument is provided to maintain signature consistency with Map.prototype.forEach and Array.prototype.forEach [3][4][6]. 3. The Set object: The Set being iterated over [3][4].

Citations:


🏁 Script executed:

# Search the codebase for similar patterns and how they're handled
rg "forEach\s*\(\s*\([^)]+\)\s*=>" packages/core/src --type ts --type tsx -A 1 | head -40

Repository: TypeCellOS/BlockNote

Length of output: 186


🏁 Script executed:

# Search for similar forEach patterns in the codebase (ts files only)
rg "forEach\s*\(\s*\([^)]+\)\s*=>" packages/core/src --type ts -A 1 -B 1 | head -50

Repository: TypeCellOS/BlockNote

Length of output: 4044


Avoid returning a value from the forEach callback.

remaining.add(k) returns the Set, making this concise arrow callback return a value, which violates the Biome rule. Use a block-bodied callback to ensure it returns void.

Proposed fix
-        Object.keys(op.attributes).forEach((k) => remaining.add(k));
+        Object.keys(op.attributes).forEach((k) => {
+          remaining.add(k);
+        });
📝 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.keys(op.attributes).forEach((k) => remaining.add(k));
Object.keys(op.attributes).forEach((k) => {
remaining.add(k);
});
🧰 Tools
🪛 Biome (2.5.0)

[error] 89-89: This callback passed to forEach() iterable method should not return a value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/core/src/yjs/extensions/schemaMigration/migrationRules/stripDisallowedMarks.test.ts`
at line 89, The arrow callback in the forEach method on the line with
Object.keys(op.attributes).forEach is using a concise arrow function body that
implicitly returns the value of remaining.add(k), which violates the Biome
linting rule requiring forEach callbacks to return void. Convert the concise
arrow callback to a block-bodied arrow callback by wrapping the remaining.add(k)
statement in curly braces, which will ensure the callback returns void instead
of the Set value.

Source: Linters/SAST tools

@matthewlipski matthewlipski left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks good, main question is r.e. NON_FORMATTING_MARK_GROUP above. Will also need to remember to update docs.

? ""
: blockConfig.content) as TContent extends "inline" ? "inline*" : "",
: blockConfig.content === "plain"
? "text*"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just noting down that it feels a bit weird that you can technically have multiple separate text nodes in the content, but doubt that this would realistically cause any issues.

* can't read group membership from the schema and use this list instead. Keep
* it in sync with the marks that declare `group: NON_FORMATTING_MARK_GROUP`.
*/
export const NON_FORMATTING_MARK_NAMES: readonly string[] = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not a fan of having to keep information about which marks are non-formatting in multiple places and having to sync them. Imo would make more sense to just read NON_FORMATTING_MARK_NAMES in createSpec, and set the node content based on that. Then we can get rid of NON_FORMATTING_MARK_GROUP.

});

it("keeps comment and suggestion marks while stripping formatting", () => {
const doc = new Y.Doc();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor, but any way to make this more readable?

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.

2 participants