Skip to content

Take y-tiptap 3.0.10 so a remote change stops rebuilding the paragraphs below it #520

Open

Description

@HMarzban

Part of #512. Read the tracker first. Work on main.

Summary

  • Today. @tiptap/y-tiptap 3.0.9 clears its whole Yjs-to-ProseMirror node map on every remote change and every y-undo step. Every block then comes back as a fresh ProseMirror node object.
  • Effect. After a remote change that shifts blocks, prosemirror-view rebuilds the paragraphs below it. Each rebuilt paragraph rebuilds its link <a>. After Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513, that is a new mark view, a new resolver call and a new icon element.
  • How far it runs. The rebuild runs down to the next block with different markup, such as a heading, a list or a media embed. On a pad of plain paragraphs, it runs to the end.
  • Media node views are not rebuilt. The probe below found 0 media constructor calls and 0 src changes in every case. The Chrome check in §Verification must confirm this outside jsdom.
  • Upstream already fixed it. @tiptap/y-tiptap 3.0.10 (npm, 2026-10-13) keeps unchanged nodes and replaces only the changed blocks. Open PR Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 takes 3.0.10.
  • Goal. Land 3.0.10 on main, measure it there, and fix the docs that still describe the full replace.

Maintainer rulings that bind this issue

#512 quotes every ruling with its number and date. Both below are from 2026-10-17.

  • Ruling 17, file the side issues. This issue is the y-tiptap re-render cascade, adjusted to the mark-view plan.
  • Ruling 9, mark view, not a decoration. So the cascade now rebuilds link mark views, not widget decorations.

Depends on / Blocks

Current behaviour

Installed file: node_modules/.bun/@tiptap+y-tiptap@3.0.9+b4bff67c2908c8af/node_modules/@tiptap/y-tiptap/dist/y-tiptap.js. The root catalog pins ^3.0.9 (package.json#L187). Only the webapp depends on it (apps/webapp/package.json#L67). The @tiptap+y-tiptap@3.0.2 folder in node_modules/.bun is install residue with no bun.lock entry. Ignore it.

  1. _typeChanged clears the map (y-tiptap.js:780-784):

    transaction.changed.forEach(delType);
    transaction.changedParentTypes.forEach(delType);
    // Rebuild the full Y↔PM mapping so relative cursor positions resolve against
    // current node sizes after structural changes (e.g. drag-and-drop block moves).
    this.mapping.clear();

    It then builds every top-level node again through createNodeIfNotExists (defined at :854). It applies one replace over the whole document (:785-797), then calls restoreRelativeSelection (:799, defined at :317).

  2. Why the clear exists. Upstream commit ueberdosis/y-tiptap@0f3f68a added it, and 3.0.6 shipped it (npm, 2026-07-03). It fixes the local caret position after a block move (drag and drop). In this issue, "caret position after a block move" means that case.

  3. y-prosemirror 1.3.7 does not clear. It deletes only the changed and deleted types (node_modules/.bun/y-prosemirror@1.3.7+b4bff67c2908c8af/node_modules/y-prosemirror/src/plugins/sync-plugin.js:603-620).

  4. How prosemirror-view turns fresh nodes into a cascade (prosemirror-view 1.42.3, node_modules/.bun/prosemirror-view@1.42.3/node_modules/prosemirror-view/src/viewdesc.ts). Fresh node objects defeat preMatch, so each paragraph after the change is overwritten and its <a> rebuilt. This runs until the next block with different markup.

  5. Who pays.

  6. Upstream fix. ueberdosis/y-tiptap#54 ("Every remote edit rebuilds the whole ProseMirror document since 3.0.6") is closed. ueberdosis/tiptap#8475, merged 2026-10-13, fixed it and also closed y-tiptap#55. The y-tiptap source now lives in ueberdosis/tiptap, folder packages/y-tiptap. In the 3.0.10 npm tarball:

    • _typeChanged no longer clears the map (package/dist/index.js:1563-1589). A clear remains only in the snapshot, forced re-render and projection-error paths (:1461, :1472, :1485, :1527).
    • replaceChangedChildren (:1352-1374) compares old and new top-level children by identity from both ends. It replaces only the span between them. It falls back to the full replace when the result does not equal the new content.
    • Every name the webapp imports (ySyncPluginKey, yUndoPluginKey) is still exported (:2209). @tiptap/extension-collaboration and -caret 3.31.3 ask for ^3.0.7, which 3.0.10 meets. The build layout moved from dist/y-tiptap.js to dist/index.js.

Measured

Probe: the <details> block in Step 0, run outside the repo. Label: laptop, jsdom view, no layout (jsdom 26.1.0, bun 1.4.3, prosemirror-view 1.42.3, yjs 13.6.32). Client B makes the change, and client A is measured. Each cell is "mark views built / mark views destroyed" on client A. Every block has its own href.

Build Blocks, shape Remote keystroke Remote 1-block insert B undoes that insert Remote Enter Remote 10-block paste
3.0.9 62, flat 0 / 0 63 / 61 0 / 2 122 / 122 81 / 61
3.0.9 500, flat 0 / 0 501 / 499 0 / 2 998 / 998 519 / 499
3.0.9 2000, flat 0 / 0 2001 / 1999 0 / 2 3998 / 3998 2019 / 1999
3.0.9 62, mixed 0 / 0 6 / 4 0 / 2 8 / 8 28 / 8
3.0.9 62, 500, 2000, embed runs 0 / 0 8 / 6 0 / 2 12 / 12 32 / 12
3.0.9 with line 784 removed every row above 0 / 0 2 / 0 0 / 2 2 / 2 20 / 0
3.0.10 every row above 0 / 0 2 / 0 0 / 2 2 / 2 20 / 0
  • "flat" is paragraphs only. "mixed" puts a heading with its own toc-id at every 10th block and an embed at every 10th block. "embed runs" is 7 paragraphs, then 3 embeds, repeated.
  • Cause proven by sabotage. Removing only line 784 from the 3.0.9 file gives the 3.0.10 numbers in every row. Restoring it brings the cascade back.
  • The floor. Every build builds 2 mark views per inserted link block and destroys 0, in jsdom. That is the floor, not the cascade. Its cause was not traced. In real Chromium 151 and WebKit 26.5, the floor is 1 per inserted link block (laptop, headless, layout on, 2026-10-18). The browser floor is the one Show a link icon before every pad link in the webapp #516 Step 9 gates.
  • Media. In every case and every build, media node views had 0 constructor calls, 0 update calls that changed src and 0 destroys. An embed inserted into a run of embeds built only its own view (2 constructor calls, same floor).
  • Not measured by this probe: time per transaction and prosemirror-view 1.42.6 (the version Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 ships). [Tracking] Inline link icons through a hyperlink mark view #512 §Tests and benchmarks has real-browser remote-edit times on y-tiptap 3.0.9 and 3.0.10, on prosemirror-view 1.42.3 (laptop, Chromium headless, layout on).

Target behaviour

  • On main with 3.0.10, a remote insert, Enter, paste or y-undo builds and destroys only the floor counts in the table above. That holds at 62, 500 and 2000 flat blocks.
  • In Chrome, every unchanged <a> and every media iframe below a remote change keeps its DOM element.
  • The caret position after a block move still lands on the same text, on the client that moved the block.

Implementation steps

Step 0. Read first, then reproduce the probe

Read the files in §Read first. Then run the probe once on 3.0.9 at 62 flat blocks, outside the repo. That run proves that the probe sees the cascade. Do not run bun add or bun install in the repo. Use a scratch folder ($P below). The probe resolves every shared module from one real path, so instanceof Y.Item checks hold. Two yjs copies exist in node_modules/.bun (13.6.31 and 13.6.32); use 13.6.32, the one y-tiptap 3.0.9 links to.

Save the JS block as $P/probe.mjs.

Setup commands and probe
export REPO=$(git rev-parse --show-toplevel)
R=$REPO/node_modules/.bun
P=<your scratch folder>; mkdir -p "$P/node_modules/@tiptap" && cd "$P"
ln -sfn $R/yjs@13.6.32/node_modules/yjs node_modules/yjs
ln -sfn $R/lib0@0.2.117/node_modules/lib0 node_modules/lib0
Y9=$R/@tiptap+y-tiptap@3.0.9+b4bff67c2908c8af/node_modules
ln -sfn "$(cd $Y9/y-protocols && pwd -P)" node_modules/y-protocols
for p in prosemirror-view prosemirror-state prosemirror-model prosemirror-transform; do
  ln -sfn "$(cd $R/@tiptap+pm@3.31.3/node_modules/$p && pwd -P)" node_modules/$p; done
bun probe.mjs 309 62 flat
// probe.mjs. Real y-tiptap, two clients, client A measured.
// Label: laptop, jsdom view, no layout. Usage: bun probe.mjs <309|3010> <blocks> <flat|mixed|embedrun>
const { JSDOM } = await import(`${process.env.REPO}/node_modules/.bun/jsdom@26.1.0/node_modules/jsdom/lib/api.js`)
const dom = new JSDOM('<!doctype html><body><div id=a></div><div id=b></div></body>')
for (const k of ['window', 'document', 'Node', 'Element', 'HTMLElement', 'MutationObserver', 'getComputedStyle', 'DOMParser', 'Text']) globalThis[k] = k === 'window' ? dom.window : dom.window[k]
globalThis.requestAnimationFrame = (f) => setTimeout(f, 0)
const VER = process.argv[2] || '309', N = Number(process.argv[3] || 62), MODE = process.argv[4] || 'mixed'
const Y = await import('yjs')
const yt = VER === '3010' ? await import('@tiptap/y-tiptap')
  : await import(`${process.env.REPO}/node_modules/.bun/@tiptap+y-tiptap@3.0.9+b4bff67c2908c8af/node_modules/@tiptap/y-tiptap/dist/y-tiptap.js`)
const { Schema, Fragment } = await import('prosemirror-model')
const { EditorView } = await import('prosemirror-view')
const { EditorState } = await import('prosemirror-state')
const schema = new Schema({
  nodes: {
    doc: { content: 'block+' },
    paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] },
    heading: { group: 'block', content: 'inline*', attrs: { level: { default: 2 }, id: { default: null } }, toDOM: (n) => ['h' + n.attrs.level, 0] },
    embed: { group: 'block', atom: true, attrs: { src: { default: '' } }, toDOM: (n) => ['div', { 'data-src': n.attrs.src }] },
    text: { group: 'inline' },
  },
  marks: { hyperlink: { attrs: { href: {} }, inclusive: false, toDOM: (m) => ['a', { href: m.attrs.href }, 0] } },
})
const c = { mvNew: 0, mvDestroy: 0, nvNew: 0, nvUpdate: 0, nvUpdateSrc: 0, nvDestroy: 0 }
// One mark-view build = one resolver call in the #513 design (read from code, not measured).
const markViews = { hyperlink: (mark) => {
  c.mvNew++
  const dom = document.createElement('a'); dom.href = mark.attrs.href
  const icon = document.createElement('img'); icon.contentEditable = 'false'
  const contentDOM = document.createElement('span'); dom.append(icon, contentDOM)
  return { dom, contentDOM, destroy() { c.mvDestroy++ }, ignoreMutation: (m) => m.type !== 'selection' && icon.contains(m.target) }
} }
// Like a media node view: update() accepts the same type, so a shift could change src in place.
const nodeViews = { embed: (node) => {
  c.nvNew++
  const d = document.createElement('div'); d.dataset.src = node.attrs.src; let cur = node
  return { dom: d, update(n) { if (n.type !== cur.type) return false; c.nvUpdate++; if (n.attrs.src !== cur.attrs.src) { c.nvUpdateSrc++; d.dataset.src = n.attrs.src } cur = n; return true }, destroy() { c.nvDestroy++ } }
} }
const para = (i) => schema.nodes.paragraph.create(null, [schema.text(`Block ${i} text `), schema.text(`link ${i}`, [schema.marks.hyperlink.create({ href: `https://site${i}.example.com/p/${i}` })]), schema.text(' tail.')])
const blocks = []
for (let i = 0; i < N; i++) {
  if (MODE === 'flat') blocks.push(para(i))
  else if (MODE === 'embedrun') blocks.push(i % 10 >= 7 ? schema.nodes.embed.create({ src: `https://video.example.com/${i}` }) : para(i))
  else if (i % 10 === 5) blocks.push(schema.nodes.embed.create({ src: `https://video.example.com/${i}` }))
  else if (i % 10 === 0) blocks.push(schema.nodes.heading.create({ id: `h-${i}` }, [schema.text(`Heading ${i} `), schema.text(`hl ${i}`, [schema.marks.hyperlink.create({ href: `https://head${i}.example.com/` })])]))
  else blocks.push(para(i))
}
const yA = new Y.Doc(), yB = new Y.Doc()
yA.on('update', (u, o) => { if (o !== 'net') Y.applyUpdate(yB, u, 'net') })
yB.on('update', (u, o) => { if (o !== 'net') Y.applyUpdate(yA, u, 'net') })
yt.prosemirrorToYXmlFragment(schema.nodes.doc.create(null, blocks), yA.getXmlFragment('prosemirror'))
const mk = (el, ydoc, undo) => new EditorView(document.getElementById(el), {
  markViews, nodeViews,
  state: EditorState.create({ schema, plugins: [yt.ySyncPlugin(ydoc.getXmlFragment('prosemirror')), ...(undo ? [yt.yUndoPlugin()] : [])] }),
})
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const A = mk('a', yA, false), B = mk('b', yB, true)
await sleep(30)
const out = { ver: VER, mode: MODE, blocks: A.state.doc.childCount, links: A.dom.querySelectorAll('a').length }
const run = async (name, fn) => {
  for (const k in c) c[k] = 0
  const before = new Set(A.dom.querySelectorAll('a, [data-src]'))
  fn(); await sleep(10)
  out[name] = { ...c, newEls: [...A.dom.querySelectorAll('a, [data-src]')].filter((e) => !before.has(e)).length }
}
const um = () => yt.yUndoPluginKey.getState(B.state).undoManager
await run('remoteKeystrokeMid', () => { const p = 1 + Math.floor(B.state.doc.content.size / 2); let q = p; B.state.doc.nodesBetween(p, p + 200, (n, pos) => { if (n.isTextblock && q === p) { q = pos + 2; return false } }); B.dispatch(B.state.tr.insertText('x', q)) })
um().stopCapturing()
const p1 = () => B.state.doc.child(0).nodeSize // start of block 1, a paragraph
await run('remoteInsert', () => B.dispatch(B.state.tr.insert(p1(), para(9999))))
um().stopCapturing()
await run('bUndoesItsInsert', () => yt.undo(B.state)) // yUndoPlugin tracks local origins only, so B undoes its own insert
um().stopCapturing()
await run('remoteEnter', () => B.dispatch(B.state.tr.split(p1() + 3)))
um().stopCapturing()
await run('remotePaste10', () => B.dispatch(B.state.tr.insert(p1(), Fragment.from(Array.from({ length: 10 }, (_, i) => para(8000 + i))))))
if (MODE === 'embedrun') { um().stopCapturing(); let at = 0; for (let i = 0; i < 7; i++) at += B.state.doc.child(i).nodeSize
  await run('remoteEmbedIntoRun', () => B.dispatch(B.state.tr.insert(at, schema.nodes.embed.create({ src: 'https://video.example.com/new' })))) }
console.log(JSON.stringify({ ...out, sameDoc: A.state.doc.eq(B.state.doc) }))
A.destroy(); B.destroy(); process.exit(0)
  • The changes start at block 1, a paragraph. A change at block 0 of the "mixed" shape hits a heading first, and the cascade stops at once.
  • Expect the 3.0.9, 62, flat row of the table above. sameDoc must print true. If your numbers differ from that row, post them here and stop.
  • If that run shows no cascade, post the numbers as a comment here and stop.

Step 1. Wait for #473 on main

Stop until a Maintainer ruling comment exists for Decision 1. Do not check out the #473 branch.

Step 2. Re-measure on main

  1. Confirm the versions: ls node_modules/.bun | grep -E '^@tiptap\+y-tiptap@|^prosemirror-view@'. Expect 3.0.10 and the prosemirror-view version that Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 ships (1.42.6 in its diff).
  2. Re-run the probe against the installed 3.0.10 and that prosemirror-view. Work in $P, with R set as in Step 0.
    • rm -rf node_modules/@tiptap/y-tiptap && cp -R "$(cd $R/@tiptap+y-tiptap@3.0.10*/node_modules/@tiptap/y-tiptap && pwd -P)" node_modules/@tiptap/y-tiptap. The probe imports @tiptap/y-tiptap by its bare name, so it needs this folder.
    • Re-link the four prosemirror-* folders from the @tiptap+pm@3.32.0 folder.
    • Link the yjs folder that y-tiptap 3.0.10 links to.
    • Then run bun probe.mjs 3010 <blocks> flat at 62, 500 and 2000 blocks.
  3. Post the numbers as a comment here, labelled "laptop, jsdom view, no layout". Expect the floor counts from the table. If the numbers on main differ from the floor, post them here and stop.

Step 3. Browser checks

Run the checks in §Verification. Post the results as a comment.

Step 4. Update the docs

Edit the files in §Docs to update. Then run bun run check:agent-docs.

Review checkpoint

Show the touched files and the probe numbers, then stop. Do not run git add, git commit, git push or git stash. Do not create a branch.

Decisions

Owner: the maintainer. The recommended answer comes first.

  1. Mark draft PR Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 ready, then merge it before Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 and this issue? Recommended: yes. This also answers Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 2. If Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 closes unmerged, rule Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 2 alone.

Acceptance criteria

  • A comment here shows the Step 0 numbers for the 3.0.9 run at 62 flat blocks.
  • Decision 1 has a Maintainer ruling comment, and @tiptap/y-tiptap 3.0.10 is on main.
  • A comment shows the Step 2 numbers on main. They equal the floor counts at 62, 500 and 2000 flat blocks.
  • In Chrome, a remote Enter in the second paragraph of a 500-link pad leaves every <a> below the changed block connected, and no media iframe reloads.
  • After a block move on one client, that client's caret stays on the same text.
  • A remote y-undo and y-redo leave both clients with the same document, and the moving client's caret stays on the same text.
  • collab-heading-sync.cy.js passes, and a full-document paste (⌘A then ⌘V) keeps the document.
  • The files in §Docs to update are edited, and bun run check:agent-docs passes.

Verification

  • The Step 0 probe on 3.0.9 at 62 flat blocks, and the Step 2 probe on main. Run with bun, outside the repo.
  • Two-client Chrome check. Run make dev-local. Open one pad in two Chrome profiles, both signed in.
    • Fill the pad: write a scratch HTML file outside the repo with 500 lines like <p>Block 7 <a href="https://site7.example.com/">link 7</a> tail.</p>. Open it in Chrome, select all, copy, and paste into the pad.
    • Add three media embeds below the first 20 blocks with the media toolbar. Any provider works, for example a YouTube URL.
    • On client A, in DevTools: window.__a = [...document.querySelectorAll('.ProseMirror a')]; window.__f = [...document.querySelectorAll('.ProseMirror iframe')].
    • On client B, press Enter inside the second paragraph.
    • On client A: __a.filter((x) => !x.isConnected).length must be at most 1, the link in the split block. __f.every((x) => x.isConnected) must be true.
    • Repeat with a 10-block paste on client B. Expect no disconnected <a> outside the pasted span.
  • Caret position after a block move. On client A, drag a heading in the TOC to move its section to a new place. Then type one character. It must land at the caret, on the same text as before the move.
  • y-undo and y-redo. On client B, insert a block, then press ⌘Z and ⌘⇧Z. Both clients must show the same document.
  • Collab Cypress spec: run cypress/e2e/editor/collaboration/collab-heading-sync.cy.js in the Cypress form from [Tracking] Inline link icons through a hyperlink mark view #512 §Local stack. Follow its port note.
  • Full-document paste (AGENTS.md#L112): in a pad, press ⌘A, ⌘C, then ⌘V. The document must stay the same on both clients.
  • bun run --filter @docs.plus/webapp typecheck.
  • bun run check:agent-docs.

Docs to update

  • apps/webapp/src/components/TipTap/CLAUDE.md line 60: "A Yjs change ... replaces the whole document". With 3.0.10, a remote change replaces only the changed blocks, and the full replace is a fallback. Re-measure HeadingScale per remote transaction at 1501 headings, or mark the 8.7 ms figure as measured on 3.0.9.
  • apps/webapp/src/components/TipTap/CLAUDE.md §Editor Performance: Show a link icon before every pad link in the webapp #516 writes the clause "a remote block insert rebuilds the link icons below it", with a link to this issue. It writes that clause only while @tiptap/y-tiptap on main is older than 3.0.10. If that clause is on main when this issue runs, remove it and keep the rest of the line.
  • caret-find-plugin.test.ts line 40: the comment says "y-tiptap replaces the whole document on a remote change". Change it to say the full replace is y-tiptap's fallback path. Keep the test; it still covers that path.
  • No AGENTS.md edit. No local patch lands, so no §Dependencies note is needed.

Out of scope

Rejected options

Read first

  • AGENTS.md, mainly §Package Manager, §Dependencies, §Test Policy and §Simplified English Mandate.
  • apps/webapp/CLAUDE.md, the collaboration section around line 198.
  • apps/webapp/src/components/TipTap/CLAUDE.md §HeadingScale (lines 42-63) and §Editor Performance.

Activity

  1. added
    bugSomething isn't working
    EditorTiptap & Prosemirror
    PerformanceQuery cost, load, and capacity
    on Oct 18, 2026
  2. HMarzban commented on Oct 18, 2026

    @HMarzban
    CollaboratorAuthor

    Maintainer ruling, 2026-10-18

    The maintainer answered these Decisions through a multiple-choice prompt in the agent session. Each answer below is the option the maintainer picked; the quoted label is the option text.

    Recorded on the tracker #512.

  3. added this to the v2.3.0 milestone on Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    EditorTiptap & ProsemirrorPerformanceQuery cost, load, and capacitybugSomething isn't workingtiptap

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions