Overview
Transactions group multiple operations into atomic units that are recorded as a single history entry. This ensures that related operations are undone/redone together, maintaining document consistency and providing a better user experience.
Key benefits:
- Atomic operations - all succeed or all fail
- Single undo entry for related operations
- Consistent document state
- Better undo/redo granularity
Transaction and History
Transactions are the foundation of atomic history management. Each transaction becomes a single entry in the undo/redo stack.
Atomic History Entries
Group related operations into a single history entry:
interface HistoryEntry {
id: string;
timestamp: number;
operations: Operation[]; // All operations in transaction
beforeModel: DocumentModel;
afterModel: DocumentModel;
beforeSelection: Selection | null;
afterSelection: Selection | null;
}
class Transaction {
#operations: Operation[] = [];
#editor: Editor;
#beforeModel: DocumentModel;
#beforeSelection: Selection | null;
#isCommitted = false;
constructor(editor: Editor) {
this.#editor = editor;
this.#beforeModel = editor.getModel();
this.#beforeSelection = editor.getSelection();
}
add(operation: Operation) {
if (this.#isCommitted) {
throw new Error('Cannot add to committed transaction');
}
this.#operations.push(operation);
}
async commit(): Promise<boolean> {
if (this.#isCommitted) {
return false;
}
// Validate all operations
for (const op of this.#operations) {
if (!this.#editor.canApply(op)) {
return false;
}
}
// Apply all operations
try {
for (const op of this.#operations) {
this.#editor.applyOperation(op);
}
// Record as single history entry
// Note: Store operations only, not full model snapshots for efficiency
// See history-management-optimization for details
const historyEntry: HistoryEntry = {
id: generateId(),
timestamp: Date.now(),
operations: [...this.#operations],
beforeSelection: this.#beforeSelection,
afterSelection: this.#editor.getSelection()
// beforeModel and afterModel are optional - only store if needed
// For operation-based undo, store inverse operations instead
};
this.#editor.getHistory().push(historyEntry);
this.#isCommitted = true;
return true;
} catch (error) {
// Rollback on error
this.rollback();
return false;
}
}
rollback() {
this.#editor.setModel(this.#beforeModel);
this.#editor.setSelection(this.#beforeSelection);
this.#operations = [];
}
}Transaction Boundaries
Define transaction boundaries based on user actions:
class Editor {
#currentTransaction: Transaction | null = null;
beginTransaction(): Transaction {
if (this.#currentTransaction) {
throw new Error('Transaction already in progress');
}
this.#currentTransaction = new Transaction(this);
return this.#currentTransaction;
}
async commitTransaction(): Promise<boolean> {
if (!this.#currentTransaction) {
return false;
}
const success = await this.#currentTransaction.commit();
this.#currentTransaction = null;
return success;
}
rollbackTransaction() {
if (this.#currentTransaction) {
this.#currentTransaction.rollback();
this.#currentTransaction = null;
}
}
// Helper to add operation to current transaction
addOperation(operation: Operation) {
if (this.#currentTransaction) {
this.#currentTransaction.add(operation);
} else {
// No transaction, create one and commit immediately
const tx = this.beginTransaction();
tx.add(operation);
tx.commit();
}
}
}Transaction Lifecycle
Understanding the complete transaction lifecycle is crucial for proper implementation.
Begin, Commit, Rollback
// Example: Formatting operation
async function applyBoldFormatting(editor: Editor, selection: Selection) {
const tx = editor.beginTransaction();
try {
// Multiple operations for formatting
tx.add({
type: 'format',
path: selection.start,
format: 'bold',
value: true
});
tx.add({
type: 'format',
path: selection.end,
format: 'bold',
value: true
});
// All operations committed as single history entry
const success = await tx.commit();
if (!success) {
console.error('Formatting failed');
}
} catch (error) {
tx.rollback();
throw error;
}
}
// Example: Paste operation
async function handlePaste(editor: Editor, pastedContent: string) {
const tx = editor.beginTransaction();
try {
// Delete selected content
const selection = editor.getSelection();
if (!selection.isCollapsed) {
tx.add({
type: 'delete',
path: selection.start,
length: selection.length
});
}
// Insert pasted content
tx.add({
type: 'insert',
path: selection.start,
content: pastedContent
});
await tx.commit(); // Single undo entry for paste
} catch (error) {
tx.rollback();
throw error;
}
}Transaction State Management
class TransactionManager {
#activeTransactions: Transaction[] = [];
#transactionStack: Transaction[] = []; // For nested transactions
beginTransaction(editor: Editor): Transaction {
const tx = new Transaction(editor);
this.#transactionStack.push(tx);
this.#activeTransactions.push(tx);
return tx;
}
async commitTransaction(tx: Transaction): Promise<boolean> {
const index = this.#activeTransactions.indexOf(tx);
if (index === -1) {
return false;
}
const success = await tx.commit();
if (success) {
this.#activeTransactions.splice(index, 1);
const stackIndex = this.#transactionStack.indexOf(tx);
if (stackIndex !== -1) {
this.#transactionStack.splice(stackIndex, 1);
}
}
return success;
}
rollbackTransaction(tx: Transaction) {
tx.rollback();
const index = this.#activeTransactions.indexOf(tx);
if (index !== -1) {
this.#activeTransactions.splice(index, 1);
}
const stackIndex = this.#transactionStack.indexOf(tx);
if (stackIndex !== -1) {
this.#transactionStack.splice(stackIndex, 1);
}
}
getCurrentTransaction(): Transaction | null {
return this.#transactionStack.length > 0
? this.#transactionStack[this.#transactionStack.length - 1]
: null;
}
}History Integration
Transactions integrate seamlessly with history management to provide atomic undo/redo.
Single Undo Entry
class HistoryManager {
#undoStack: HistoryEntry[] = [];
#redoStack: HistoryEntry[] = [];
recordTransaction(transaction: Transaction) {
if (!transaction.isCommitted()) {
return;
}
// Store operations only - no full model snapshots for efficiency
const entry: HistoryEntry = {
id: generateId(),
timestamp: Date.now(),
operations: transaction.getOperations(),
beforeSelection: transaction.getBeforeSelection(),
afterSelection: transaction.getAfterSelection()
// Use inverse operations for undo instead of model snapshots
// See history-management-optimization for details
};
this.#undoStack.push(entry);
this.#redoStack = []; // Clear redo stack on new action
}
undo(): boolean {
if (this.#undoStack.length === 0) {
return false;
}
const entry = this.#undoStack.pop()!;
// Apply inverse operations
for (let i = entry.operations.length - 1; i >= 0; i--) {
const op = entry.operations[i];
const inverse = this.#getInverseOperation(op);
this.#editor.applyOperation(inverse);
}
// Restore selection
this.#editor.setSelection(entry.beforeSelection);
// Move to redo stack
this.#redoStack.push(entry);
return true;
}
redo(): boolean {
if (this.#redoStack.length === 0) {
return false;
}
const entry = this.#redoStack.pop()!;
// Re-apply operations
for (const op of entry.operations) {
this.#editor.applyOperation(op);
}
// Restore selection
this.#editor.setSelection(entry.afterSelection);
// Move to undo stack
this.#undoStack.push(entry);
return true;
}
}Operation Grouping
// Example: User types "Hello" - should be single undo entry
class InputHandler {
#transaction: Transaction | null = null;
#lastInputTime = 0;
#inputTimeout: number | null = null;
handleInput(e: InputEvent) {
// Group rapid inputs into single transaction
const now = Date.now();
const timeSinceLastInput = now - this.#lastInputTime;
if (timeSinceLastInput > 500 || !this.#transaction) {
// Start new transaction
this.#commitCurrentTransaction();
this.#transaction = this.#editor.beginTransaction();
}
this.#lastInputTime = now;
// Add operation to transaction
if (e.inputType === 'insertText') {
this.#transaction.add({
type: 'insert',
path: this.#editor.getSelection().start,
content: e.data
});
}
// Commit after delay (debounce)
if (this.#inputTimeout) {
clearTimeout(this.#inputTimeout);
}
this.#inputTimeout = setTimeout(() => {
this.#commitCurrentTransaction();
}, 500);
}
#commitCurrentTransaction() {
if (this.#transaction) {
this.#transaction.commit();
this.#transaction = null;
}
}
}Transaction Patterns
Common patterns for using transactions in different scenarios.
User Action Transactions
// Pattern: Each user action is a transaction
class UserActionHandler {
async handleUserAction(action: UserAction) {
const tx = this.#editor.beginTransaction();
try {
switch (action.type) {
case 'type':
tx.add({ type: 'insert', path: action.position, content: action.text });
break;
case 'delete':
tx.add({ type: 'delete', path: action.position, length: action.length });
break;
case 'format':
tx.add({ type: 'format', path: action.selection.start, format: action.format });
break;
}
await tx.commit();
} catch (error) {
tx.rollback();
throw error;
}
}
}Formatting Transactions
// Pattern: Formatting operations grouped together
async function applyFormatting(
editor: Editor,
selection: Selection,
format: Format
) {
const tx = editor.beginTransaction();
// Apply format to entire selection
const nodes = editor.getNodesInRange(selection);
for (const node of nodes) {
tx.add({
type: 'format',
path: node.path,
format: format.name,
value: format.value
});
}
await tx.commit(); // Single undo for entire formatting
}
// Pattern: Toggle formatting
async function toggleFormatting(
editor: Editor,
selection: Selection,
format: string
) {
const tx = editor.beginTransaction();
const hasFormat = editor.hasFormat(selection, format);
if (hasFormat) {
// Remove format
const nodes = editor.getNodesInRange(selection);
for (const node of nodes) {
tx.add({
type: 'format',
path: node.path,
format: format,
value: false
});
}
} else {
// Apply format
const nodes = editor.getNodesInRange(selection);
for (const node of nodes) {
tx.add({
type: 'format',
path: node.path,
format: format,
value: true
});
}
}
await tx.commit();
}Paste Transactions
// Pattern: Paste as single transaction
async function handlePaste(
editor: Editor,
clipboardData: DataTransfer
) {
const tx = editor.beginTransaction();
const selection = editor.getSelection();
try {
// 1. Delete selected content
if (!selection.isCollapsed) {
tx.add({
type: 'delete',
path: selection.start,
length: selection.length
});
}
// 2. Parse and insert pasted content
const pastedContent = await parseClipboardData(clipboardData);
for (const item of pastedContent) {
if (item.type === 'text') {
tx.add({
type: 'insert',
path: selection.start,
content: item.text
});
} else if (item.type === 'node') {
tx.add({
type: 'insert',
path: selection.start,
node: item.node
});
}
}
await tx.commit(); // Single undo for entire paste
} catch (error) {
tx.rollback();
throw error;
}
}Nested Transactions
Support nested transactions for complex operations.
class NestedTransactionManager {
#transactionStack: Transaction[] = [];
beginTransaction(editor: Editor): Transaction {
const tx = new Transaction(editor);
this.#transactionStack.push(tx);
return tx;
}
async commitTransaction(tx: Transaction): Promise<boolean> {
const index = this.#transactionStack.indexOf(tx);
if (index === -1) {
return false;
}
// Only commit if it's the top-level transaction
if (index === this.#transactionStack.length - 1) {
const success = await tx.commit();
if (success) {
this.#transactionStack.pop();
}
return success;
} else {
// Nested transaction - just mark as committed
// Will be committed when parent commits
return true;
}
}
getCurrentTransaction(): Transaction | null {
return this.#transactionStack.length > 0
? this.#transactionStack[this.#transactionStack.length - 1]
: null;
}
}Best Practices
- Group related operations: Operations that should be undone together should be in the same transaction
- User action boundaries: Each user action (keystroke, paste, format) should typically be its own transaction
- Debounce rapid inputs: Group rapid text input into single transactions for better undo granularity
- Always rollback on error: Ensure transaction state is restored if commit fails
- Validate before commit: Check all operations can be applied before committing
- Preserve selection: Store selection state in transaction for proper undo/redo
- Avoid nested transactions: Keep transaction structure simple unless necessary