Skip to content

Instantly share code, notes, and snippets.

@remorses
Created July 10, 2026 23:12
Show Gist options
  • Select an option

  • Save remorses/5040f8069ac740886c82a59c35b9e7b0 to your computer and use it in GitHub Desktop.

Select an option

Save remorses/5040f8069ac740886c82a59c35b9e7b0 to your computer and use it in GitHub Desktop.
Merging Two Yjs Documents with ProseMirror/TipTap — full algorithm breakdown with code, diagrams, and examples

Merging Two Yjs Documents with ProseMirror/TipTap

TL;DR

Convert each document source (markdown, user edits, GitHub content) into a TipTap JSON tree, load each into a separate Y.Doc via mergeDocument, then call Y.applyUpdate to merge one into the other. Yjs CRDT semantics guarantee both sets of changes survive. Read the result back as TipTap JSON with yDocToJson.

markdown A ──► toTipTap() ──► TipTapNode ──► mergeDocument(doc1, ...) ──┐
                                                                        ├─► Y.applyUpdate(doc1, update2)
markdown B ──► toTipTap() ──► TipTapNode ──► mergeDocument(doc2, ...) ──┘         │
                                                                           yDocToJson(doc1)
                                                                                  │
                                                                           TipTapNode (merged)

The critical detail: mergeDocument doesn't "merge" two trees directly. It diffs a ProseMirror tree against a Yjs XmlFragment and applies minimal CRDT mutations. The actual merge happens when Yjs applies updates from one doc to another.


Dependencies

Package Version Role
yjs ^13.x CRDT document model. Y.Doc, Y.XmlFragment, Y.XmlElement, Y.XmlText
y-prosemirror ^1.2.x Bridge between Yjs and ProseMirror. Provides updateYFragment, yXmlFragmentToProsemirrorJSON
@tiptap/core ^2.x getSchema() to build ProseMirror schema from TipTap extensions
prosemirror-model ^1.x Node, Mark, Schema types. schema.nodeFromJSON() to parse TipTap JSON
lib0 (transitive via yjs) simpleDiff used inside updateYFragment for text-level diffing

No patches are applied to any of these dependencies in the holocron codebase. They are used as-is from npm. The updateYFragment function is exported from y-prosemirror and marked as @unstable and @private, but it works reliably.


Architecture

┌─────────────────────────────────────────────────────────────────────────────────────────┐
│                                     Full Pipeline                                       │
│                                                                                         │
│  ┌──────────┐     ┌──────────┐     ┌─────────────────┐     ┌─────────────────────────┐  │
│  │ Markdown │────►│ toTipTap │────►│   TipTapNode    │────►│   jsonToProsemirror     │  │
│  │  string  │     │          │     │   (JSON tree)   │     │   schema.nodeFromJSON() │  │
│  └──────────┘     └──────────┘     └─────────────────┘     └───────────┬─────────────┘  │
│                                                                        │                │
│                                                            ProseMirror Node             │
│                                                                        │                │
│                                                                        ▼                │
│  ┌──────────────────────────────────────────────────────────────────────────────────┐    │
│  │                          updateYFragment (y-prosemirror)                         │    │
│  │                                                                                  │    │
│  │  Existing Y.XmlFragment ◄──── structural diff ────► New ProseMirror Node         │    │
│  │                                                                                  │    │
│  │  1. Sync attributes (setAttribute/removeAttribute)                               │    │
│  │  2. Match children from left  ──► stable left region                             │    │
│  │  3. Match children from right ──► stable right region                            │    │
│  │  4. Process unstable middle (update, replace, or insert)                         │    │
│  │  5. Cleanup (delete extras, insert new)                                          │    │
│  │                                                                                  │    │
│  │  All mutations go through Yjs ──► become CRDT operations                         │    │
│  └──────────────────────────────────────────────────────────────────────────────────┘    │
│                                                                                         │
│                              Y.Doc (contains merged CRDT state)                         │
│                                          │                                              │
│                                          ▼                                              │
│                              yDocToJson(doc) ──► TipTapNode                             │
│                                                                                         │
└─────────────────────────────────────────────────────────────────────────────────────────┘

Types

// The TipTap/ProseMirror JSON tree structure
interface TipTapNode {
    type: string               // 'doc', 'heading', 'paragraph', 'codeBlock', etc.
    attrs?: Record<string, any>
    marks?: Array<{
        type: { name: string }
        attrs?: any
    }>
    content?: TipTapNode[]
    text?: string              // only for text nodes
}

// Block types used in the schema
const blockTypes = {
    blockquote: 'blockquote',
    paragraph: 'paragraph',
    heading: 'heading',
    codeBlock: 'codeBlock',
    bulletList: 'bulletList',
    orderedList: 'orderedList',
    listItem: 'listItem',
    horizontalRule: 'horizontalRule',
    hardBreak: 'hardBreak',
    text: 'text',
    image: 'image',
    table: 'table',
    tableRow: 'tableRow',
    tableCell: 'tableCell',
    tableHeader: 'tableHeader',
    mdxCode: 'mdxCode',
    yamlFrontmatter: 'yamlFrontmatter',
    htmlCode: 'htmlCode',
    doc: 'doc',
    taskItem: 'taskItem',
    taskList: 'taskList',
} as const

// Mark types for inline formatting
const markTypes = {
    italic: 'italic',
    bold: 'bold',
    strike: 'strike',
    link: 'link',
    code: 'code',
    dummyMdxExpression: 'dummyMdxExpression',
    mdxExpression: 'mdxExpression',
} as const

Core Functions (Full Source)

mergeDocument — the entry point

Takes a Y.Doc, a TipTap JSON node, and a page slug. Converts the JSON to a ProseMirror Node using the schema, then calls updateYFragment to diff-and-patch the Yjs document.

import { getSchema } from '@tiptap/core'
import { Node } from 'prosemirror-model'
import * as Y from 'yjs'
import { updateYFragment } from 'y-prosemirror'

// Schema is memoized per file extension (.md, .mdx)
// because different extensions enable different TipTap extensions
const schemaGetter = memoize(
    (pageSlug: string) => getSchema(createExtensions({ pageSlug })),
    { transformKey: ([pageSlug]) => [getExtension(pageSlug || '')] },
)

function jsonToProsemirror(node: TipTapNode | Node, pageSlug: string): Node {
    if (node instanceof Node) {
        return node
    }
    return schemaGetter(pageSlug).nodeFromJSON(node)
}

function prosemirrorToYXmlFragment(
    doc: any,
    xmlFragment: Y.XmlFragment,
    mapping: Map<any, any> = new Map(),
): Y.XmlFragment {
    const type = xmlFragment || new Y.XmlFragment()
    const ydoc = type.doc
        ? type.doc
        : { transact: (transaction) => transaction(undefined) }
    updateYFragment(ydoc, type, doc, mapping)
    return type
}

function mergeDocument(
    currentDoc: Y.Doc,
    node: TipTapNode | Node,
    pageSlug: string,
) {
    const pDoc = jsonToProsemirror(node, pageSlug)
    const mapping = new Map()
    const frag = currentDoc.getXmlFragment('default')
    prosemirrorToYXmlFragment(pDoc, frag, mapping)
    return { mapping, pDoc }
}

yDocToJson — read TipTap JSON back from a Y.Doc

import { yXmlFragmentToProsemirrorJSON } from 'y-prosemirror'

function yDocToJson(yDoc: Y.Doc): TipTapNode {
    const fragment = yDoc.getXmlFragment('default')
    const json = yXmlFragmentToProsemirrorJSON(fragment) as any
    // Fix: empty paragraphs need an empty content array
    for (let child of json.content || []) {
        if (child.type === 'paragraph' && !child.content) {
            child.content = []
        }
    }
    return json
}

toTipTap — markdown string to TipTap JSON

function toTipTap({
    str,
    addPosition = false,
    pageSlug = '.mdx',
}): { node?: TipTapNode; error?: Error } {
    // Uses remark + remarkGfm + remarkMdx to parse markdown into mdast,
    // then a custom MdastToProseMirror visitor converts to TipTap JSON.
    // Returns { node, error }
}

updateYFragment — the merge algorithm (from y-prosemirror)

This is the function that does the actual structural diff. It is called recursively for nested elements.

// Full source from y-prosemirror/src/plugins/sync-plugin.js

// ═══════════════════════════════════════════════════════════════════
// Helper: deep attribute comparison (ignores 'ychange' key)
// ═══════════════════════════════════════════════════════════════════
const equalAttrs = (pattrs, yattrs) => {
    const keys = Object.keys(pattrs).filter((key) => pattrs[key] !== null)
    let eq =
        keys.length ===
        Object.keys(yattrs).filter((key) => yattrs[key] !== null).length
    for (let i = 0; i < keys.length && eq; i++) {
        const key = keys[i]
        const l = pattrs[key]
        const r = yattrs[key]
        eq = key === 'ychange' || l === r ||
            (isObject(l) && isObject(r) && equalAttrs(l, r))
    }
    return eq
}

// ═══════════════════════════════════════════════════════════════════
// Helper: group consecutive text nodes into arrays
// Element nodes stay as-is. This normalizes the children for diffing.
//
// Input:  [TextNode("hello"), TextNode(" world"), ElementNode(heading)]
// Output: [[TextNode("hello"), TextNode(" world")], ElementNode(heading)]
// ═══════════════════════════════════════════════════════════════════
const normalizePNodeContent = (pnode) => {
    const c = pnode.content.content
    const res = []
    for (let i = 0; i < c.length; i++) {
        const n = c[i]
        if (n.isText) {
            const textNodes = []
            for (let tnode = c[i]; i < c.length && tnode.isText; tnode = c[++i]) {
                textNodes.push(tnode)
            }
            i--
            res.push(textNodes)
        } else {
            res.push(n)
        }
    }
    return res
}

// ═══════════════════════════════════════════════════════════════════
// Helper: compare Y.XmlText with ProseMirror text nodes
// Compares text content AND marks/attributes
// ═══════════════════════════════════════════════════════════════════
const equalYTextPText = (ytext, ptexts) => {
    const delta = ytext.toDelta()
    return delta.length === ptexts.length &&
        delta.every((d, i) =>
            d.insert === ptexts[i].text &&
            Object.keys(d.attributes || {}).length === ptexts[i].marks.length &&
            ptexts[i].marks.every((mark) =>
                equalAttrs(d.attributes[mark.type.name] || {}, mark.attrs)
            )
        )
}

// ═══════════════════════════════════════════════════════════════════
// Helper: deep equality between a Yjs type and a ProseMirror node
// ═══════════════════════════════════════════════════════════════════
const equalYTypePNode = (ytype, pnode) => {
    if (
        ytype instanceof Y.XmlElement && !(pnode instanceof Array) &&
        matchNodeName(ytype, pnode)
    ) {
        const normalizedContent = normalizePNodeContent(pnode)
        return ytype._length === normalizedContent.length &&
            equalAttrs(ytype.getAttributes(), pnode.attrs) &&
            ytype.toArray().every((ychild, i) =>
                equalYTypePNode(ychild, normalizedContent[i])
            )
    }
    return ytype instanceof Y.XmlText && pnode instanceof Array &&
        equalYTextPText(ytype, pnode)
}

// ═══════════════════════════════════════════════════════════════════
// Helper: check if a mapping value is identical to content
// ═══════════════════════════════════════════════════════════════════
const mappedIdentity = (mapped, pcontent) =>
    mapped === pcontent ||
    (mapped instanceof Array && pcontent instanceof Array &&
        mapped.length === pcontent.length && mapped.every((a, i) =>
        pcontent[i] === a
    ))

// ═══════════════════════════════════════════════════════════════════
// Helper: compute how well a Yjs element matches a ProseMirror node
// by counting matching children from both ends.
// Used to decide which side to update when both left and right match.
// ═══════════════════════════════════════════════════════════════════
const computeChildEqualityFactor = (ytype, pnode, mapping) => {
    const yChildren = ytype.toArray()
    const pChildren = normalizePNodeContent(pnode)
    const pChildCnt = pChildren.length
    const yChildCnt = yChildren.length
    const minCnt = Math.min(yChildCnt, pChildCnt)
    let left = 0
    let right = 0
    let foundMappedChild = false
    for (; left < minCnt; left++) {
        const leftY = yChildren[left]
        const leftP = pChildren[left]
        if (mappedIdentity(mapping.get(leftY), leftP)) {
            foundMappedChild = true
        } else if (!equalYTypePNode(leftY, leftP)) {
            break
        }
    }
    for (; left + right < minCnt; right++) {
        const rightY = yChildren[yChildCnt - right - 1]
        const rightP = pChildren[pChildCnt - right - 1]
        if (mappedIdentity(mapping.get(rightY), rightP)) {
            foundMappedChild = true
        } else if (!equalYTypePNode(rightY, rightP)) {
            break
        }
    }
    return {
        equalityFactor: left + right,
        foundMappedChild
    }
}

// ═══════════════════════════════════════════════════════════════════
// Helper: extract string content and formatting from Y.XmlText
// Walks the internal linked list of Yjs items
// ═══════════════════════════════════════════════════════════════════
const ytextTrans = (ytext) => {
    let str = ''
    let n = ytext._start
    const nAttrs = {}
    while (n !== null) {
        if (!n.deleted) {
            if (n.countable && n.content instanceof Y.ContentString) {
                str += n.content.str
            } else if (n.content instanceof Y.ContentFormat) {
                nAttrs[n.content.key] = null
            }
        }
        n = n.right
    }
    return { str, nAttrs }
}

// ═══════════════════════════════════════════════════════════════════
// Helper: convert ProseMirror marks to Yjs attributes
// ═══════════════════════════════════════════════════════════════════
const marksToAttributes = (marks) => {
    const pattrs = {}
    marks.forEach((mark) => {
        if (mark.type.name !== 'ychange') {
            pattrs[mark.type.name] = mark.attrs
        }
    })
    return pattrs
}

// ═══════════════════════════════════════════════════════════════════
// Helper: update Y.XmlText content using character-level diff
// Uses lib0/simpleDiff for efficient text patching
// ═══════════════════════════════════════════════════════════════════
const updateYText = (ytext, ptexts, mapping) => {
    mapping.set(ytext, ptexts)
    const { nAttrs, str } = ytextTrans(ytext)
    const content = ptexts.map((p) => ({
        insert: p.text,
        attributes: Object.assign({}, nAttrs, marksToAttributes(p.marks))
    }))
    const { insert, remove, index } = simpleDiff(
        str,
        content.map((c) => c.insert).join('')
    )
    ytext.delete(index, remove)
    ytext.insert(index, insert)
    ytext.applyDelta(
        content.map((c) => ({ retain: c.insert.length, attributes: c.attributes }))
    )
}

const matchNodeName = (yElement, pNode) =>
    !(pNode instanceof Array) && yElement.nodeName === pNode.type.name

// ═══════════════════════════════════════════════════════════════════
// MAIN: updateYFragment
// Recursively diffs a Yjs XmlFragment against a ProseMirror Node,
// applying minimal CRDT mutations to make the Yjs tree match.
// ═══════════════════════════════════════════════════════════════════
const updateYFragment = (y, yDomFragment, pNode, mapping) => {
    if (
        yDomFragment instanceof Y.XmlElement &&
        yDomFragment.nodeName !== pNode.type.name
    ) {
        throw new Error('node name mismatch!')
    }
    mapping.set(yDomFragment, pNode)

    // ── Step 1: Sync attributes ──────────────────────────────────
    if (yDomFragment instanceof Y.XmlElement) {
        const yDomAttrs = yDomFragment.getAttributes()
        const pAttrs = pNode.attrs
        for (const key in pAttrs) {
            if (pAttrs[key] !== null) {
                if (yDomAttrs[key] !== pAttrs[key] && key !== 'ychange') {
                    yDomFragment.setAttribute(key, pAttrs[key])
                }
            } else {
                yDomFragment.removeAttribute(key)
            }
        }
        for (const key in yDomAttrs) {
            if (pAttrs[key] === undefined) {
                yDomFragment.removeAttribute(key)
            }
        }
    }

    // ── Step 2-3: Find stable left and right regions ─────────────
    const pChildren = normalizePNodeContent(pNode)
    const pChildCnt = pChildren.length
    const yChildren = yDomFragment.toArray()
    const yChildCnt = yChildren.length
    const minCnt = Math.min(pChildCnt, yChildCnt)
    let left = 0
    let right = 0

    // Scan from left: advance while children match
    for (; left < minCnt; left++) {
        const leftY = yChildren[left]
        const leftP = pChildren[left]
        if (!mappedIdentity(mapping.get(leftY), leftP)) {
            if (equalYTypePNode(leftY, leftP)) {
                mapping.set(leftY, leftP)
            } else {
                break
            }
        }
    }

    // Scan from right: advance while children match
    for (; right + left + 1 < minCnt; right++) {
        const rightY = yChildren[yChildCnt - right - 1]
        const rightP = pChildren[pChildCnt - right - 1]
        if (!mappedIdentity(mapping.get(rightY), rightP)) {
            if (equalYTypePNode(rightY, rightP)) {
                mapping.set(rightY, rightP)
            } else {
                break
            }
        }
    }

    // ── Step 4: Process the unstable middle ──────────────────────
    //
    //  children: [  stable left  |  UNSTABLE MIDDLE  |  stable right  ]
    //              0..left-1       left..end-right      end-right+1..end
    //
    y.transact(() => {
        while (yChildCnt - left - right > 0 && pChildCnt - left - right > 0) {
            const leftY = yChildren[left]
            const leftP = pChildren[left]
            const rightY = yChildren[yChildCnt - right - 1]
            const rightP = pChildren[pChildCnt - right - 1]

            if (leftY instanceof Y.XmlText && leftP instanceof Array) {
                // Both are text: update in-place with character diff
                if (!equalYTextPText(leftY, leftP)) {
                    updateYText(leftY, leftP, mapping)
                }
                left += 1
            } else {
                let updateLeft = leftY instanceof Y.XmlElement &&
                    matchNodeName(leftY, leftP)
                let updateRight = rightY instanceof Y.XmlElement &&
                    matchNodeName(rightY, rightP)

                if (updateLeft && updateRight) {
                    // Both sides could be updated. Pick the better match.
                    const equalityLeft = computeChildEqualityFactor(leftY, leftP, mapping)
                    const equalityRight = computeChildEqualityFactor(rightY, rightP, mapping)
                    if (equalityLeft.foundMappedChild && !equalityRight.foundMappedChild) {
                        updateRight = false
                    } else if (!equalityLeft.foundMappedChild && equalityRight.foundMappedChild) {
                        updateLeft = false
                    } else if (equalityLeft.equalityFactor < equalityRight.equalityFactor) {
                        updateLeft = false
                    } else {
                        updateRight = false
                    }
                }

                if (updateLeft) {
                    // Recursively update the left child
                    updateYFragment(y, leftY, leftP, mapping)
                    left += 1
                } else if (updateRight) {
                    // Recursively update the right child
                    updateYFragment(y, rightY, rightP, mapping)
                    right += 1
                } else {
                    // No match: delete old, insert new
                    mapping.delete(yDomFragment.get(left))
                    yDomFragment.delete(left, 1)
                    yDomFragment.insert(left, [
                        createTypeFromTextOrElementNode(leftP, mapping)
                    ])
                    left += 1
                }
            }
        }

        // ── Step 5: Cleanup ──────────────────────────────────────
        const yDelLen = yChildCnt - left - right
        if (
            yChildCnt === 1 && pChildCnt === 0 &&
            yChildren[0] instanceof Y.XmlText
        ) {
            // Edge case: keep Y.Text object alive to retain remote changes
            mapping.delete(yChildren[0])
            yChildren[0].delete(0, yChildren[0].length)
        } else if (yDelLen > 0) {
            yDomFragment.slice(left, left + yDelLen).forEach(
                type => mapping.delete(type)
            )
            yDomFragment.delete(left, yDelLen)
        }

        // Insert remaining new ProseMirror children
        if (left + right < pChildCnt) {
            const ins = []
            for (let i = left; i < pChildCnt - right; i++) {
                ins.push(createTypeFromTextOrElementNode(pChildren[i], mapping))
            }
            yDomFragment.insert(left, ins)
        }
    }, ySyncPluginKey)
}

How merging 2 conflicting documents works

The scenario

You have a document stored as a Yjs binary (from user edits in the editor). Separately, someone updates the markdown on GitHub. You want to merge the GitHub version into the user's version, preserving both sets of changes.

                    ┌──────────────────────────────┐
                    │   Common ancestor (base)      │
                    │   "# hello"                   │
                    └──────────────┬────────────────┘
                                   │
                    ┌──────────────┴────────────────┐
                    │                               │
                    ▼                               ▼
  ┌─────────────────────────────┐  ┌─────────────────────────────┐
  │  User edits (doc1)          │  │  GitHub markdown (doc2)      │
  │  "# hello"                  │  │  "# hello"                   │
  │  "in the middle"            │  │  "<SomeJsx />"               │
  └──────────────┬──────────────┘  └──────────────┬──────────────┘
                 │                                 │
                 │    Y.applyUpdate(doc1, update2)  │
                 │◄────────────────────────────────┘
                 │
                 ▼
  ┌─────────────────────────────┐
  │  Merged result (doc1)       │
  │  "# hello"                  │
  │  "in the middle"            │
  │  "<SomeJsx />"              │
  └─────────────────────────────┘

Key constraint: GitHub docs have no history

The Yjs document generated from GitHub markdown is created on the fly from the markdown content. It has no editing history, no vector clock state. It's a fresh Y.Doc with a single snapshot loaded via mergeDocument.

To make the merge work, you need a shared base state. Without it, Yjs sees two completely independent documents and the merge produces duplicated content (both full trees appear).

Step-by-step merge procedure

import * as Y from 'yjs'

// ── Step 1: Create the base document ────────────────────────────
// This represents the common ancestor both sides started from.
const baseDoc = new Y.Doc()
mergeDocument(baseDoc, baseNode, '.mdx')

// ── Step 2: Fork into two branches ──────────────────────────────
// doc1 = user's editing doc (could also be loaded from stored Yjs binary)
// doc2 = GitHub's version (generated fresh from markdown)

const doc1 = new Y.Doc()
// CRITICAL: same clientID so Yjs treats the base as shared history
doc1.clientID = baseDoc.clientID
Y.applyUpdate(doc1, Y.encodeStateAsUpdate(baseDoc))

const doc2 = new Y.Doc()
doc2.clientID = baseDoc.clientID
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(baseDoc))

// ── Step 3: Apply divergent changes ─────────────────────────────
// User edits their doc
mergeDocument(doc1, userEditedNode, '.mdx')

// GitHub content is loaded into doc2
mergeDocument(doc2, githubNode, '.mdx')

// ── Step 4: Merge ───────────────────────────────────────────────
Y.applyUpdate(doc1, Y.encodeStateAsUpdate(doc2))

// ── Step 5: Read result ─────────────────────────────────────────
const mergedJson: TipTapNode = yDocToJson(doc1)

The clientID trick explained

doc1.clientID = baseDoc.clientID

This is critical. When you set the same clientID, the base operations in doc1 and doc2 share the same origin. Yjs recognizes them as the same operations and doesn't duplicate them. Without this, each doc's base content looks like independent insertions and you get the content doubled.

When the user doc is already a Yjs binary

If the user's document comes from a stored Yjs binary (from the websocket/database), you already have history. The flow becomes:

// Load the user's existing Y.Doc (has full editing history)
const userDoc = new Y.Doc()
Y.applyUpdate(userDoc, storedYjsBinary)

// Create a fresh doc for GitHub content, forked from the user's current state
const githubDoc = new Y.Doc()
githubDoc.clientID = userDoc.clientID
Y.applyUpdate(githubDoc, Y.encodeStateAsUpdate(userDoc))

// Now apply the new GitHub markdown on top
mergeDocument(githubDoc, githubNode, '.mdx')

// Merge GitHub changes back into user doc
Y.applyUpdate(userDoc, Y.encodeStateAsUpdate(githubDoc))

// Read result
const merged = yDocToJson(userDoc)

Complete working example

import * as Y from 'yjs'
import dedent from 'dedent'

// These come from your codebase
import { mergeDocument } from './editor-utils'
import { toTipTap } from './markdown-to-prosemirror'
import { yDocToJson } from './utils'
import { getMarkdown } from './editor-utils'

// ── Parse markdown into TipTap JSON ─────────────────────────────
const base = toTipTap({
    pageSlug: '.mdx',
    str: dedent`
        # hello
    `,
}).node!

const userVersion = toTipTap({
    pageSlug: '.mdx',
    str: dedent`
        # hello

        in the middle
    `,
}).node!

const githubVersion = toTipTap({
    pageSlug: '.mdx',
    str: dedent`
        # hello

        <SomeJsx />
    `,
}).node!

// ── Create base Y.Doc ───────────────────────────────────────────
const doc1 = new Y.Doc()
mergeDocument(doc1, base, '.mdx')

// ── Fork doc2 from same base ────────────────────────────────────
const doc2 = new Y.Doc()
doc2.clientID = doc1.clientID
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))

// ── Apply divergent edits ───────────────────────────────────────
mergeDocument(doc1, userVersion, '.mdx')
mergeDocument(doc2, githubVersion, '.mdx')

// ── Merge ───────────────────────────────────────────────────────
Y.applyUpdate(doc1, Y.encodeStateAsUpdate(doc2))

// ── Read result ─────────────────────────────────────────────────
const mergedJson = yDocToJson(doc1)
const mergedMarkdown = getMarkdown(mergedJson, '.mdx')

console.log(mergedMarkdown)
// Output contains all three:
//   # hello
//   in the middle
//   <SomeJsx />

How updateYFragment diff works, visually

Given an existing Yjs tree and a new ProseMirror tree:

Yjs XmlFragment (before):         ProseMirror Node (target):
┌──────────────────────┐          ┌──────────────────────┐
│ XmlElement(heading)  │  ◄═══►   │ Node(heading)        │   ✓ match (left=1)
│ XmlText("old text")  │  ◄═══►   │ [Text("old text")]   │   ✓ match (left=2)
│ XmlElement(paragraph)│  ✗ ───   │ Node(codeBlock)      │   ✗ mismatch
│ XmlText("footer")    │  ◄═══►   │ [Text("footer")]     │   ✓ match (right=1)
└──────────────────────┘          └──────────────────────┘

Result: left=2, right=1

Stable left:    [heading, "old text"]       ──► no changes needed
Unstable middle: [paragraph] vs [codeBlock] ──► delete paragraph, insert codeBlock
Stable right:   ["footer"]                  ──► no changes needed

For the unstable middle, the algorithm tries in order:

  1. Text nodes: update in-place with simpleDiff (character-level insert/delete)
  2. Same-named elements: recursively call updateYFragment to update children
  3. Different elements: delete old Yjs node, create new one from ProseMirror node

When both left and right candidates match the node name, computeChildEqualityFactor breaks the tie by checking which side has more matching children or a previously mapped child.


Conflict resolution rules

Yjs CRDTs have deterministic conflict resolution. You cannot configure "mine wins" or "theirs wins".

Conflict type Resolution
Concurrent insertions at same position Both survive. Ordered by clientID (lower ID first)
Concurrent text edits at different positions Both applied cleanly (no conflict)
Concurrent text edits at same position Both insertions appear, ordered by clientID
One side deletes, other side edits Edit wins (deleted content may reappear if edited concurrently)
Concurrent attribute changes Last-writer-wins per attribute key (by Lamport timestamp)
One side deletes a node, other side inserts inside it The insertion is lost (parent was deleted)

Extracting back to markdown

After merging, convert the TipTap JSON back to markdown:

import { getMarkdown } from './editor-utils'

const markdown: string = getMarkdown(mergedJson, '.mdx')

This uses a custom MarkdownSerializer (from tiptap-markdown, customized) that walks the ProseMirror node tree and serializes each node type back to markdown syntax.

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