Skip to content

[draft] Raise diff boundary for strict schemas via a matchNodes predicate - #267

Open
YousefED wants to merge 3 commits into
yjs:masterfrom
YousefED:schema-fix-matchnodes-diff-config
Open

[draft] Raise diff boundary for strict schemas via a matchNodes predicate#267
YousefED wants to merge 3 commits into
yjs:masterfrom
YousefED:schema-fix-matchnodes-diff-config

Conversation

@YousefED

@YousefED YousefED commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Problem

In suggestion mode, a node-type change (e.g. paragraph → heading) is diffed as a leaf-level delete old + insert new. Inside a strict container the tombstoned old child ends up next to the inserted new child — two block-contents in one parent. For schemas like BlockNote's blockContainer (content blockContent blockGroup?) that's schema-invalid and throws Invalid content for node blockContainer.

Solution

This POC lets the diff decide modify-in-place vs. replace via a caller-supplied predicate, so the boundary can be raised to a whole node — producing two sibling blockContainers (allowed by blockContainer+) instead of two block-contents in one.

  • lib0/delta.diff gains an optional matchNodes(a, b) argument. The diff already pairs a removed node with an inserted node by name to decide "same node → descend / else → replace"; this just makes that one decision overridable. Defaults to name-equality, so existing behaviour is unchanged.
  • syncPlugin gains a matchNodes option, forwarded to the PM→Y write diff. Declaring a node's identity (e.g. "a blockContainer is identified by its first block-content child") makes a child-type change report the two containers as different → whole-node replace → two siblings. A plain text edit keeps the same identity → descends and merges as before.

The binding stays schema-agnostic — what identifies a node is the integrator's policy.

syncPlugin({
  matchNodes: (a, b) =>
    a.name === b.name &&
    (a.name !== 'blockContainer' || firstBlockChildName(a) === firstBlockChildName(b))
})

Caveats

  • The BlockNote demo doesn't work yet as it needs some changes to handle conflicting duplicate IDs. We'll create a separate POC there
  • This impacts the "mergeability" of certain changes, which needs to be tested. In particular:
  • -- the POC is tested for the "change block type" scenario, but not yet created to also handle <blockgroup> (nesting) related cases
  • -- changes like "edit text + change from paragraph to heading" won't merge nicely

Tests

tests/block-boundary.test.js — strict BlockNote-like schema; verifies the boundary raise renders two sibling blockContainer

YousefED and others added 2 commits June 11, 2026 14:54
Add a `matchNodes` option to `syncPlugin`, forwarded to `lib0/delta.diff`
as a node-pairing predicate. It lets an integrator declare when two
same-named nodes should be treated as *different* (a whole-node replace)
instead of diffed in place, so a suggestion-mode type change inside a
strict container (e.g. BlockNote's `blockContainer`, content
`blockContent blockGroup?`) becomes two sibling containers rather than
two block-contents in one container - which the content expression
rejects ("Invalid content for node blockContainer").

- `lib0/delta.diff` gains an optional `matchNodes(a, b)` argument
  (defaults to name-equality, so existing behaviour is unchanged).
  Vendored via patch-package pending an upstream lib0 change.
- `syncPlugin({ matchNodes })` + `defaultMatchNodes`; the predicate is
  forwarded only to the PM->Y write diff. The "what identifies a node"
  policy is schema-specific and therefore the integrator's to define -
  the binding stays schema-agnostic.
- block-boundary.test.js: strict BlockNote-like schema; verifies the
  boundary raise renders two sibling blockContainers (attribution as
  node marks, no schema change) and that accept/reject converge. The
  three flip tests crash without `matchNodes`; the seed test does not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the storage-transform experiment and instead pass a BlockNote
`matchNodes` policy (a blockContainer is identified by its first
block-content child) to `syncPlugin`. A type-change suggestion now
renders as two sibling blockContainers against the *unmodified*
BlockNote schema - it already whitelists the `y-attributed-*` marks on
blockContainer, so the attribution rides on node marks (no variant, no
content relaxation, no storage migration).

The demo keeps its own `node_modules/lib0`, so the lib0 patch is
duplicated here via patch-package.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@netlify

netlify Bot commented Jun 11, 2026

Copy link
Copy Markdown

Deploy Preview for y-prosemirror-demo canceled.

Name Link
🔨 Latest commit 66e4a86
🔍 Latest deploy log https://app.netlify.com/projects/y-prosemirror-demo/deploys/6a2ac992be545d0007549460

@YousefED YousefED changed the title Raise diff boundary for strict schemas via a matchNodes predicate [draft] Raise diff boundary for strict schemas via a matchNodes predicate Jun 11, 2026
The lib0 dependency is pinned to `^1.0.0-rc.14` (lockfiles at rc.14), but the
vendored patches were still `lib0+1.0.0-rc.13.patch`, so a fresh `npm install`
got rc.14 and patch-package failed to apply the `matchNodes` hook. rc.14's
`diff` is structurally identical (it even now documents the name-based pairing
the hook makes overridable), so the patch ports over unchanged. Bumps the
demo's lib0 to rc.14 to match the root; replaces both patch files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@YousefED
YousefED force-pushed the schema-fix-matchnodes-diff-config branch from 370c8a9 to 66e4a86 Compare June 11, 2026 14:43
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