Skip to content

feat(core): keep nested block groups in Y so concurrent nesting merges - #3183

Open
dmonad wants to merge 3 commits into
TypeCellOS:feat/versioning-sidebar-ux-bfrom
dmonad:persistent-block-groups
Open

dmonad wants to merge 3 commits into
TypeCellOS:feat/versioning-sidebar-ux-bfrom
dmonad:persistent-block-groups

Conversation

@dmonad

@dmonad dmonad commented Oct 11, 2026

Copy link
Copy Markdown

Summary

Nested blockGroups become persistent in the Y document: a block gets its group when it is created, the group stays when its last child is removed, and the editor shows a group only while it has children. Two users who nest a first child under the same block at the same time now write into the same group, so the merge is an ordinary concurrent list insert. Nesting changes (indent, unindent, first child, last child) diff in place instead of replacing the parent block.

Stacked on #3090 (targets feat/versioning-sidebar-ux-b), which brings @y/prosemirror 2.0.0-14, isInitialContent, the conversions through ynodeToPmnode / pmnodeToDelta, and the nestingChanges.test.ts markers this PR turns green. It also bumps @y/prosemirror to 2.0.0-15 (the latest beta).

Rationale

The problem

A blockContainer holds blockContent blockGroup?, and the schema forbids an empty blockGroup. So the editor creates the group together with the first nested block and removes it with the last one. In a collaborative document that is a conflict generator: two users nesting a first child under the same block concurrently each insert their own blockGroup; after the merge the container holds two, the schema rejects that, and the binding drops content.

blockMatchNodes worked around this with its nesting rule: a container that gains or loses a group is treated as a different node, so the diff replaces the whole container. That keeps the document valid, but

  • the same race now produces two copies of the parent (gallery scenario "Both nest a new block under N0", its note said "A better fix for the schema compatibility could resolve this"),
  • every first nesting and every last un-nesting rewrites the parent in Y (delete + insert), so relative positions anchored in it (cursors, comments) lose their anchor,
  • version diffs and suggestions show the parent as deleted and re-inserted when a child is nested under it. That is most of the "noisy diff" the gallery notes describe for Indent, Unindent, Nest a bullet, Delete a nested block.

The model

What Y stores is decoupled from what ProseMirror shows, by a binding transformer (transformers option of syncPlugin) on every blockContainer:

Y ProseMirror
blockContainer[ content, group[] ] blockContainer[ content ]
blockContainer[ content, group[a] ] blockContainer[ content, group[a] ]
blockContainer[ content, g1[a], g2[b] ] blockContainer[ content, group[a, b] ]
blockContainer[ content ] (legacy) blockContainer[ content ]
  • Groups are never deleted. Deleting is what conflicts: a group one user empties and removes while another user inserts into it loses the insert. An emptied group stays, hidden, and is reused by the next nesting.
  • Every block the editor creates gets an empty group right away (implicitGroups, on by default). A new document then has exactly one group per block for the block's lifetime, and a first nesting is a plain insert into an existing list. This is the part that makes the race disappear by construction for new content.
  • Legacy containers (no group) get none implicitly. Creating one on load would be a write by every client that opens the document, the race all over again. Their first nesting creates the group as today; if two users do that concurrently, Y holds two groups and the view merges them into one (third row): edits route back to the group holding that position (the earlier group wins at a boundary), deleting the view group empties every Y group and keeps them. The race degrades to an ordering question instead of invalid content.
  • The nesting rule in blockMatchNodes goes. With persistent groups an in-place diff can never leave two groups in a container, and keeping the rule would recreate the race: the view's first nesting must reach Y as a modify of the container, not as a new container with its own group. The content-type rule and the table rule stay.
  • Cursor and relative positions map through the same layout in both directions, so the cursor plugin and RelativePositionMapping keep working with hidden and merged groups.

No schema change, no storage migration, and no change in @y/prosemirror was needed; the transformer only uses lib0/delta.

Other attempts

  • feat(versioning): diff nesting changes in place #3168 (mergeBlockGroups, @YousefED) implemented the same merge / hide / keep idea as a transformer and was closed because it changed the stored document. This PR changes the stored document too, deliberately: keeping emptied groups and creating them with every block is the fix, there is no way to make concurrent first nestings merge without a group that already exists on both sides. The rest of feat(versioning): diff nesting changes in place #3168 is here as well (nesting rule removed, the conversions in utils.ts through the binding's pipeline, the gallery notes). What this PR adds over feat(versioning): diff nesting changes in place #3168: implicit groups (with an option to turn them off), position mapping through hidden and merged groups (feat(versioning): diff nesting changes in place #3168 passed root marks through with their Y-side keys, which is wrong once a container has two groups or a hidden one), and a much larger test surface (see Testing).
  • The transformer itself was developed and benchmarked in the y-prosemirror repository's blocknote-demo against BlockNote 0.55.0 (suggestion multi-editor example with four editors, race staged by holding updates between two clients), then ported here.

Changes

  • packages/core/src/y/extensions/persistentBlockGroups.ts (new): the transformer, with the model and the per-container rules in its module doc. persistentBlockGroups($d, { groupName, containerName, nestingNodes, implicitGroups }); the recursion descends through blockGroup, column and columnList, every other node opts out and costs nothing. Exported from @blocknote/core/y.
  • YSync.ts: transformers: [persistentBlockGroups].
  • blockMatchNodes.ts: the nesting rule (hasBlockGroup(a) !== hasBlockGroup(b)) is removed; the doc comment says why nesting is no longer an identity change.
  • utils.ts: blockNoteTransformer (exported) is the binding's pipeline (attribution marks + persistent groups). yfragmentToBlocks / yDocToBlocks, blocksToYType, blocksToYDoc and yNodeToTransaction go through it, so a document imported with blocksToYDoc gets its groups and one read with yDocToBlocks hides the empty ones (reading a container with two groups used to return an empty document).
  • pm-nodes/README.md: a note on the persistent group in Y documents.
  • Gallery (scenarios.ts): the nesting notes describe the new behavior; "Both nest a new block under N0" gets an info note instead of the duplication notes.
  • @y/prosemirror 2.0.0-14 → 2.0.0-15 (catalog, peer range, examples, lockfile). 2.0.0-15 stops rendering a format attribution that a span's own insert/delete attribution implies; unrelated to this PR.

Impact

  • Stored document. A block written by this version is blockContainer[blockContent, blockGroup] in Y, with the group possibly empty. Emptied groups stay. A legacy container may end up with several groups after concurrent first nestings; they are never consolidated (deleting is what conflicts). Code that reads the raw Y structure must expect empty groups and several groups per container; yDocToBlocks and the versioning / suggestion views go through the transformer and are covered.

  • Mixed clients. A client without the transformer on @y/prosemirror ≥ 2.0.0-12 treats an empty group as schema-invalid and deletes it from Y (the existing schema-invalid handling); the container becomes a legacy container, the transformer clients follow, nothing diverges (test "keeps syncing with a client that has no transformer"). On ≤ 2.0.0-11 (e.g. main's 2.0.0-6) ProseMirror's createAndFill instead fills the empty group with a placeholder block that gets written to Y, so clients on those versions should not share documents with this version. Within one deployment, ship the change to all clients of a document at once.

  • A trade-off documented in feat(versioning): diff nesting changes in place #3168 applies. A move is still a delete and an insert. With the nesting rule gone, content a user nests into a block that another user moves at the same time is lost instead of duplicated (gallery "Cascading indents", "Nest blocks into a block that is moved"; notes updated). The old rule only turned that conflict into duplicates. Needs move support.

  • Version diffs and suggestions. The blockGroup wrapper carries no attribution mark of its own; its children do, which renders the same. An indent shows the moved block only; deleting the last child shows only the child. The two versionDiffAttribution.test.ts expectations that encoded the old behavior are updated; the legacy-binding case "a nesting change" now diffs like the new binding and moved out of the it.fails.each.

  • Cost. One empty Y node per block. Measured through the binding on jsdom in the y-prosemirror demo bench, BlockNote-shaped document, each top-level block with two nested blocks:

    document metric before transformer, doc with groups
    6000 containers bind 1478 ms 1577 ms
    heap after bind 138 MB 156 MB
    local keystroke, median 14.8 ms 15.3 ms
    remote keystroke, median 13.8 ms 17.1 ms
    nest / un-nest, median 32.5 ms 33.1 ms
    position round trip, median 0.45 ms 0.73 ms
    900 containers bind 286 ms 256 ms
    local keystroke, median 3.16 ms 3.26 ms

    The transformer is within noise for keystrokes and binding; the extra node costs about 7% bind time and 12% heap at 6000 containers, nothing measurable at 900. A change costs O(its size) plus O(children) of the containers on its path; no stage re-reads the document.

  • implicitGroups: false keeps the merge / hide / keep behavior without the extra node per block (the behavior of feat(versioning): diff nesting changes in place #3168), for a deployment that wants the smaller stored-document change first.

Testing

  • nestingChanges.test.ts: the five it.fails markers for feat(versioning): diff nesting changes in place #3168 are now it, parametrized over a persistent document (blocksToYType seeding: one group per block, [2] after the concurrent first nesting) and a legacy document (seeded without groups: [1, 1], routed edits [2, 1], kept empty group [2, 0]), plus two tests for the implicit group of a new block and the absence of one on a legacy block. The two feat(versioning): experimental version diff fixes #3166 markers stay.

  • persistentBlockGroups.test.ts (new, 18 tests): the per-container stage on hand-built deltas (hiding, showing, merging, routing, implicit creation and the option, position mapping); the binding on BlockNote's schema (persistent groups across nest / un-nest, concurrent first nesting as moves and as new blocks, legacy concurrent nesting with later edits into the merged group, moves, type change, suggestion mode: suggest / reject / accept / suggest a deletion, relative positions across peers, the standalone conversions, a client without the transformer); a fuzz of 150 seeded runs (random insert / delete / nest / un-nest / typing on two peers with random sync points) asserting convergence, equality with the pipeline's rendering of the Y document, and exactly one group per container.

  • Core unit suite: all 88 files pass (1079 tests, 26 expected failures are the fix(versioning): diff structural changes made by the old Yjs binding #3173 / feat(versioning): experimental version diff fixes #3166 / feat(versioning): show copied blocks once in version diffs (experimental) #3172 markers). server-util passes. vp lint --type-aware and vp fmt clean.

  • Upstream, in the y-prosemirror demo on BlockNote 0.55.0: the same suite (17 tests) plus 3595 fuzz runs in one minute, and the race staged in the browser (four editors, updates held between two clients, each nests a new first child under the same block; after the merge every editor shows both children in one nested list and Y holds one group with two children).

  • e2e (src/end-to-end/y-prosemirror, run in the Docker image with podman): 48 files, 408 tests pass on Chromium, Firefox and WebKit; 311 snapshots regenerated. Three kinds of changes in them:

    • the Y document XML (ydocXml) now shows every block's <blockGroup></blockGroup> (the stored document; the editor HTML snapshots show none);
    • the nesting scenarios show the moved or deleted child only, with its parent unchanged (nesting, nesting.concurrent, addRemoveBlocks, moveBlocks and their screenshots; the "Both nest a new block under N0" screenshot shows one N0 with both children);
    • a y-attributed-format wrapper on spans the same user inserted and formatted is gone (links, basicText, ...): that is @y/prosemirror 2.0.0-15, see above.

    The versioning.test.tsx snapshot records, for every gallery scenario, exactly the changes the notes describe (for example "Both nest a new block under N0": insert block "N1" A, insert block "N2" B, nothing else; "Delete a nested block": delete block "Child" A).

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

🤖 Generated with Claude Code

A block's `blockGroup` is now persistent in the Y document: every block
gets one when it is created, an emptied group stays (hidden from the
editor), and the editor shows one group per block only while it has
children. Two users nesting a first child under the same block
concurrently write into the same group, and nesting changes diff in
place instead of replacing the parent block.

A binding transformer (persistentBlockGroups) maps between the two
layouts in both directions, including positions; documents written
before this (no group until the first nesting) keep working, and a
container that ended up with two groups is shown as one. The nesting
rule in blockMatchNodes is removed, and the standalone conversions go
through the binding's pipeline.
…k groups

The Y document XML now shows every block's (empty) blockGroup, the
nesting scenarios show only the moved or deleted child with the parent
unchanged, and @y/prosemirror 2.0.0-15 no longer renders a format
attribution that a span's own insert attribution implies. The gallery
notes of the two scenarios that lose a concurrently nested block say
whom the loss is credited to, as the versioning snapshot records it.
@vercel

vercel Bot commented Oct 11, 2026

Copy link
Copy Markdown

@dmonad is attempting to deploy a commit to the TypeCell Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 429c0e18-75d0-4321-bd8a-8a65a5c3fc72

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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 Oct 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

@blocknote/ariakit

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

@blocknote/code-block

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

@blocknote/core

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

@blocknote/diagram-block

npm i https://pkg.pr.new/@blocknote/diagram-block@3183

@blocknote/mantine

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

@blocknote/math-block

npm i https://pkg.pr.new/@blocknote/math-block@3183

@blocknote/react

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

@blocknote/server-util

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

@blocknote/shadcn

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

@blocknote/xl-ai

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

@blocknote/xl-docx-exporter

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

@blocknote/xl-email-exporter

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

@blocknote/xl-multi-column

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

@blocknote/xl-odt-exporter

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

@blocknote/xl-pdf-exporter

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

@blocknote/xl-typst-exporter

npm i https://pkg.pr.new/@blocknote/xl-typst-exporter@3183

commit: 6ad8abd

This branch has not been deployed

No deployments
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