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.
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.
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.
I've packaged this fix as a standalone npm extension — tiptap-collab-plus — so you don't have to wire up the plugin yourself.
npm install tiptap-collab-plusimport { 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.
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 ranoldState— the editor state before those transactionsnewState— 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.
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$') !== undefinedRemote 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.
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.
| 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 |
npm install tiptap-collab-plusimport { 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.