Undo/Redo

Implementing per-user undo stacks with Yjs UndoManager.

Overview

In collaborative apps, users expect undo to reverse their own changes, not collaborators’. Yjs UndoManager provides this through “tracked origins.”

Basic Setup

import { UndoManager } from 'yjs'

const undoManager = new UndoManager([content])

undoManager.undo()
undoManager.redo()
undoManager.undoStack.length > 0  // canUndo
undoManager.redoStack.length > 0  // canRedo

Tracked Origins

Without tracked origins, undo captures remote changes too:

// WRONG: captures everything
const undoManager = new UndoManager([content])

// CORRECT: only capture local changes
const undoManager = new UndoManager([content], {
  trackedOrigins: new Set(['user-action'])
})

// Mark local changes
yDoc.transact(() => {
  content.set('title', 'New Title')
}, 'user-action')  // Tracked

// Remote changes have no origin — not tracked

With Editor Bindings

const binding = new QuillBinding(yText, quill, awareness)
const undoManager = new UndoManager(yText, {
  trackedOrigins: new Set([binding])
})

Scoping to Shared Types

// Only track changes to cells, rowOrder, colOrder
const undoManager = new UndoManager([cells, rowOrder, colOrder], {
  trackedOrigins: new Set(['user-action'])
})

// Changes to other types (metadata) not tracked

Capturing Metadata

Restore cursor position after undo:

const undoManager = new UndoManager([content], {
  trackedOrigins: new Set(['user-action']),
  captureTransaction: (transaction) => ({
    cursorPosition: getCursorPosition()
  })
})

undoManager.on('stack-item-popped', (event) => {
  if (event.stackItem.meta.cursorPosition) {
    setCursorPosition(event.stackItem.meta.cursorPosition)
  }
})

Transaction Grouping

Each transaction becomes one undo item:

yDoc.transact(() => {
  items.set('a', data1)
  items.set('b', data2)
  items.set('c', data3)
}, 'user-action')

undoManager.undo()  // Reverts all three at once

For typing, use captureTimeout to group rapid changes:

const undoManager = new UndoManager([content], {
  trackedOrigins: new Set([binding]),
  captureTimeout: 500  // Group changes within 500ms
})

Clear History

undoManager.clear()  // Clear undo/redo stacks
undoManager.stopCapturing()  // End current group, start new one

Stack Events

undoManager.on('stack-item-added', (event) => {
  updateUndoButtons()
})

undoManager.on('stack-item-popped', (event) => {
  // Restore metadata
  updateUndoButtons()
})

Common Mistakes

Forgetting trackedOrigins:

// WRONG
const undoManager = new UndoManager([content])

// CORRECT
const undoManager = new UndoManager([content], {
  trackedOrigins: new Set(['user-action'])
})

Inconsistent origins:

// WRONG: one tracked, one not
content.set('title', 'New')  // Not tracked
yDoc.transact(() => metadata.set('time', now()), 'user-action')  // Tracked

// CORRECT: consistent
yDoc.transact(() => {
  content.set('title', 'New')
  metadata.set('time', now())
}, 'user-action')

See Also