Repository navigation
Conversation
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.
|
@dmonad is attempting to deploy a commit to the TypeCell Team on Vercel. A member of the Team first needs to authorize it. |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
@blocknote/ariakit
@blocknote/code-block
@blocknote/core
@blocknote/diagram-block
@blocknote/mantine
@blocknote/math-block
@blocknote/react
@blocknote/server-util
@blocknote/shadcn
@blocknote/xl-ai
@blocknote/xl-docx-exporter
@blocknote/xl-email-exporter
@blocknote/xl-multi-column
@blocknote/xl-odt-exporter
@blocknote/xl-pdf-exporter
@blocknote/xl-typst-exporter
commit: |
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/prosemirror2.0.0-14,isInitialContent, the conversions throughynodeToPmnode/pmnodeToDelta, and thenestingChanges.test.tsmarkers this PR turns green. It also bumps@y/prosemirrorto 2.0.0-15 (the latest beta).Rationale
The problem
A
blockContainerholdsblockContent blockGroup?, and the schema forbids an emptyblockGroup. 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 ownblockGroup; after the merge the container holds two, the schema rejects that, and the binding drops content.blockMatchNodesworked 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, butThe model
What Y stores is decoupled from what ProseMirror shows, by a binding transformer (
transformersoption ofsyncPlugin) on everyblockContainer: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 ]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.blockMatchNodesgoes. 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 amodifyof the container, not as a new container with its own group. The content-type rule and the table rule stay.RelativePositionMappingkeep working with hidden and merged groups.No schema change, no storage migration, and no change in
@y/prosemirrorwas needed; the transformer only useslib0/delta.Other attempts
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 inutils.tsthrough 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).blocknote-demoagainst 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 throughblockGroup,columnandcolumnList, 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,blocksToYDocandyNodeToTransactiongo through it, so a document imported withblocksToYDocgets its groups and one read withyDocToBlockshides 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.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/prosemirror2.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;yDocToBlocksand 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'screateAndFillinstead 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
blockGroupwrapper 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 twoversionDiffAttribution.test.tsexpectations that encoded the old behavior are updated; the legacy-binding case "a nesting change" now diffs like the new binding and moved out of theit.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:
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: falsekeeps 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 fiveit.failsmarkers for feat(versioning): diff nesting changes in place #3168 are nowit, parametrized over a persistent document (blocksToYTypeseeding: 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-utilpasses.vp lint --type-awareandvp fmtclean.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:ydocXml) now shows every block's<blockGroup></blockGroup>(the stored document; the editor HTML snapshots show none);nesting,nesting.concurrent,addRemoveBlocks,moveBlocksand their screenshots; the "Both nest a new block under N0" screenshot shows one N0 with both children);y-attributed-formatwrapper on spans the same user inserted and formatted is gone (links,basicText, ...): that is@y/prosemirror2.0.0-15, see above.The
versioning.test.tsxsnapshot 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
Additional Notes
getTableDimensionsinblockMatchNodes.tsreadstd.attrs.colspanas a value; on a lib0 deltaattrs[key]is the attr op, so every span counts as 1. Left as is (fix(versioning): diff structural changes made by the old Yjs binding #3173 turns the table rule off); mentioning it because the port surfaced it.splitChangedBlocksno longer splits a nesting change made by the old binding, because the nesting rule is gone; the transformer shows the change in place instead, which is what the legacy test now asserts.🤖 Generated with Claude Code