Skip to content

Instantly share code, notes, and snippets.

@mkromik
Created April 25, 2026 07:01
Show Gist options
  • Select an option

  • Save mkromik/3aa2eb1248a08b0647e486e6b91c6927 to your computer and use it in GitHub Desktop.

Select an option

Save mkromik/3aa2eb1248a08b0647e486e6b91c6927 to your computer and use it in GitHub Desktop.
TipTap Collaboration Bug: storedMark

TipTap Collaboration Bug: Why Marks (Bold, Italic, Underline) Disappear When Two Users Type Together

A subtle but frustrating issue in real-time collaborative editors — and the clean fix that solves it.


If you've built a real-time collaborative editor with TipTap and Hocuspocus (or any y-prosemirror-based sync), you may have encountered this maddening bug:

User 2 clicks Bold (or Italic, or Underline, or any custom mark) in the toolbar, starts typing — and nothing comes out formatted.

The mark toggle worked. The UI showed the mark as active. But the text arrived plain. Meanwhile, User 1 was happily typing away in the same paragraph.

This bug affects every mark type — built-in ones like bold, italic, underline, code, strike, and any custom marks you've defined. If it uses storedMarks under the hood, it's vulnerable.

This post breaks down exactly what's happening and how to fix it with a single, surgical ProseMirror plugin.


The Setup

Two users are editing the same document node — say, a <p> paragraph — simultaneously via a WebSocket-backed CRDT sync (Yjs).

  • User 1 is typing continuously.
  • User 2 clicks a mark in the toolbar — Bold, Italic, Underline, or a custom mark — with no text selected, intending to write in that style from that point forward.

User 2 hits a key. The character appears — but the mark is gone.


What's Actually Happening: storedMarks

In ProseMirror (the engine under TipTap), when you toggle any mark — bold, italic, underline, strikethrough, or a custom mark — with no text selected, the editor doesn't apply the mark immediately. Instead, it saves it in a special state field called storedMarks.

editor.view.state.storedMarks → [Mark: bold]
editor.view.state.storedMarks → [Mark: italic]
editor.view.state.storedMarks → [Mark: your-custom-mark]

On the next keypress, ProseMirror reads storedMarks, applies those marks to the inserted character, then clears the stored marks. This is how "click a mark, then type" works at all.

The problem: When User 1's remote transaction arrives via y-sync$ and gets applied to the editor state, it triggers a full state update. That state update wipes out storedMarks.

So the sequence looks like this:

User 2 clicks Bold / Italic / Underline / Custom mark
  → storedMarks = [Mark: bold] (or italic, underline, etc.)

User 1's remote transaction arrives
  → newState = applyTransaction(oldState, remoteTransaction)
  → newState.storedMarks = null  ← wiped

User 2 types a character
  → storedMarks is null
  → character is inserted with no marks

The mark is silently lost between the click and the keystroke — regardless of which mark it was.


The Fix: tiptap-collab-plus

I've packaged this fix as a standalone npm extension — tiptap-collab-plus — so you don't have to wire up the plugin yourself.

Install

npm install tiptap-collab-plus

Usage

import { Editor } from '@tiptap/core'
import { CollaborationPlus } from 'tiptap-collab-plus'
import * as Y from 'yjs'

const ydoc = new Y.Doc()

const editor = new Editor({
  extensions: [
    CollaborationPlus.configure({
      document: ydoc,
    }),
    // ...other extensions
  ],
})

That's it. No boilerplate, no manual plugin registration.


Why appendTransaction Is the Right Hook

ProseMirror's appendTransaction runs after all transactions (local and remote) have been applied and the new state has been computed. It receives:

  • transactions — the list of transactions that just ran
  • oldState — the editor state before those transactions
  • newState — the editor state after those transactions

This makes it perfect for detecting the problem and fixing it before the editor renders. Returning a new transaction from appendTransaction chains it into the update without triggering another render cycle.


Checking the Right Meta Key

The remote transaction meta key varies depending on which sync provider you're using:

Provider Meta key
y-prosemirror / TipTap Collab 'y-sync$'
prosemirror-collab (non-Yjs) 'collab$'
Hocuspocus 'y-sync$'

Check which one your setup uses. If you're unsure, add a temporary console.log:

transactions.forEach(tr => {
  console.log('Transaction metas:', tr.getMeta)
})

You can also handle both to be safe:

const isRemote =
  tr.getMeta('y-sync$') !== undefined ||
  tr.getMeta('collab$') !== undefined

Bonus: Guarding Against Cursor-Only Remote Updates

Remote presence updates — where User 1 just moves their cursor without typing — can also trigger this issue. If you want tighter control:

appendTransaction(transactions, oldState, newState) {
  const hasRemoteTransaction = transactions.some(
    (tr) => tr.getMeta('y-sync$') || tr.getMeta('collab$')
  )

  if (!hasRemoteTransaction) return null

  if (oldState.storedMarks && !newState.storedMarks) {
    // Only restore if the document content is unchanged
    // (i.e. this was a cursor/selection-only remote update)
    if (oldState.doc.eq(newState.doc)) {
      return newState.tr.setStoredMarks(oldState.storedMarks)
    }
  }

  return null
}

This version is more conservative: it only restores stored marks when the remote transaction didn't change any document content — just moved a cursor or updated presence metadata.


Why This Doesn't Break Anything

You might worry: "What if restoring storedMarks is wrong — e.g., the user's cursor moved to a position where the mark doesn't apply?"

ProseMirror handles this gracefully. If storedMarks are set but the next transaction is a selection move (not a character insert), ProseMirror automatically clears them again. The marks only get applied when an actual insertion happens — which is exactly the behavior the user intended.


Summary

Detail
Bug storedMarks (pending marks from toolbar click) get wiped by incoming remote transactions
Affects All marks — bold, italic, underline, strike, code, and any custom marks
Root cause Remote Yjs transactions create a new EditorState where storedMarks defaults to null
Fix Use appendTransaction to detect remote transactions and restore storedMarks if lost
Risk None — ProseMirror clears storedMarks naturally if no insertion follows

Quick Reference

npm install tiptap-collab-plus
import { CollaborationPlus } from 'tiptap-collab-plus'

CollaborationPlus.configure({ document: ydoc })

Package on npm: tiptap-collab-plus


If this helped you, share it with someone building a collaborative editor. These kinds of subtle state bugs are easy to overlook and hard to search for.

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