Overview
History management in model-based editors is fundamentally different from browser's native undo/redo. Your model tracks operations, while the browser tracks DOM changes. These two systems can conflict, especially when using preventDefault() or during IME composition.
⚠️ Core Challenge
The fundamental problem:
- Browser history tracks DOM changes, not model operations
- Using
preventDefault()prevents DOM changes but browser may still update internal state - IME composition state can become corrupted when events are prevented
- Programmatic model changes don't appear in browser history
- Selection must be preserved across history operations
Model-Based History Management
Model-based history tracks operations on your abstract document model, not DOM changes. This provides more control but requires careful synchronization.
History Architecture
Design your history system around model operations:
interface HistoryEntry {
id: string;
timestamp: number;
operations: Operation[];
inverseOperations?: Operation[]; // Pre-computed inverses for undo
// Note: beforeModel/afterModel are optional - avoid storing full snapshots
// Use operation-based undo instead (see history-management-optimization)
beforeModel?: DocumentModel; // Only if needed for specific use cases
afterModel?: DocumentModel; // Only if needed for specific use cases
beforeSelection: Selection | null;
afterSelection: Selection | null;
metadata?: {
source: 'user' | 'programmatic' | 'undo' | 'redo';
compositionState?: CompositionState;
};
}
interface Operation {
type: 'insert' | 'delete' | 'format' | 'replace';
path: Path;
data?: any;
inverse?: Operation; // For efficient undo
}
class HistoryManager {
private undoStack: HistoryEntry[] = [];
private redoStack: HistoryEntry[] = [];
private maxSize: number = 50;
private currentEntry: HistoryEntry | null = null;
constructor(private model: DocumentModel) {}
// Record a new history entry
// Note: Avoid cloning full models - use operation-based approach instead
record(operations: Operation[], selection: Selection | null) {
const beforeSelection = selection;
// Apply operations
operations.forEach(op => this.model.apply(op));
const afterSelection = this.calculateSelectionAfterOperations(
beforeSelection,
operations
);
// Compute inverse operations for efficient undo
const inverseOperations = this.computeInverseOperations(operations);
const entry: HistoryEntry = {
id: this.generateId(),
timestamp: Date.now(),
operations,
inverseOperations, // Use inverses instead of model snapshots
beforeSelection,
afterSelection,
metadata: {
source: 'user'
}
// NO beforeModel/afterModel - too memory-intensive!
};
this.undoStack.push(entry);
if (this.undoStack.length > this.maxSize) {
this.undoStack.shift();
}
this.redoStack = []; // Clear redo on new action
}
// Compute inverse operations for undo
computeInverseOperations(operations: Operation[]): Operation[] {
// Reverse order and compute inverse for each
return operations
.slice()
.reverse()
.map(op => this.getInverseOperation(op));
}
}Operation Tracking
Track all operations that modify the model:
class OperationTracker {
private pendingOperations: Operation[] = [];
private isRecording = false;
startRecording() {
this.isRecording = true;
this.pendingOperations = [];
}
recordOperation(operation: Operation) {
if (this.isRecording) {
this.pendingOperations.push(operation);
}
}
stopRecording(): Operation[] {
this.isRecording = false;
const operations = [...this.pendingOperations];
this.pendingOperations = [];
return operations;
}
// Integrate with event handlers
handleBeforeInput(e: InputEvent) {
this.startRecording();
// Convert DOM event to model operation
const operation = this.domEventToOperation(e);
this.recordOperation(operation);
// Prevent default to handle in model
e.preventDefault();
// Apply to model
this.model.apply(operation);
// Stop recording and save to history
const operations = this.stopRecording();
this.historyManager.record(operations, this.saveSelection());
}
}State Snapshots
⚠️ Important: Storing full model snapshots is memory-intensive. Use operation-based history instead.
Avoid full model snapshots: Storing complete model clones for each history entry consumes excessive memory and is slow for large documents.
See History Management Optimization for the recommended operation-based approach.
// ❌ AVOID: Full model snapshots (memory-intensive)
class HistoryManager {
// Option 1: Full model snapshots (simple but memory-intensive)
createSnapshot(model: DocumentModel): DocumentModel {
return model.clone(); // Deep clone - expensive!
}
}
// ✅ PREFERRED: Operation-based (memory-efficient)
class HistoryManager {
// Store only operations, use inverse operations for undo
createSnapshot(operations: Operation[]): HistoryEntry {
return {
operations,
inverseOperations: this.computeInverses(operations),
// NO full model snapshots!
};
}
// Apply inverse operations for undo
undo() {
const entry = this.#undoStack.pop()!;
entry.inverseOperations.forEach(op => this.model.apply(op));
}
}
// See history-management-optimization for complete implementationDOM History Conflict
Browser's native history and your model history can conflict. Understanding these conflicts is crucial for reliable history management.
Browser History Limitations
Browser history has several limitations:
- Only tracks DOM changes: Programmatic changes may not be included
- Granularity varies: Some browsers undo per keystroke, others per operation
- Can be cleared unexpectedly: Focus changes, programmatic DOM updates
- No operation metadata: Can't distinguish user actions from programmatic changes
- Selection not preserved: Undo may lose selection position
// Browser history behavior
// ❌ Problem: Programmatic changes not in history
element.innerHTML = newContent; // Not in browser undo stack
// ❌ Problem: preventDefault() operations not in history
element.addEventListener('beforeinput', (e) => {
e.preventDefault();
// Custom operation - not in browser history
this.applyCustomOperation();
});
// ❌ Problem: History cleared on focus change
element.addEventListener('blur', () => {
// Browser may clear undo stack
});
// ✅ Solution: Use custom history
class HistoryManager {
recordOperation(operation) {
// Always track in custom history
this.undoStack.push({
operation,
beforeModel: this.model.clone(),
afterModel: this.applyOperation(operation)
});
}
}preventDefault() Impact
When you call preventDefault(), you prevent DOM changes but browser's internal state may still update:
// The problem
element.addEventListener('beforeinput', (e) => {
e.preventDefault(); // Prevent DOM change
// Apply to model instead
const operation = this.domEventToOperation(e);
this.model.apply(operation);
// ❌ Problem: Browser's internal state (IME, undo stack) may be updated
// even though DOM didn't change
});
// Browser's perspective:
// 1. beforeinput fires → you preventDefault()
// 2. Browser doesn't update DOM (because prevented)
// 3. BUT browser may have already:
// - Updated internal IME state
// - Added entry to undo stack (in some browsers)
// - Updated selection tracking
// 4. This creates mismatch between browser state and actual DOM⚠️ State Mismatch
Critical issue: Browser's internal state (IME composition, undo stack, selection tracking) may be updated even when preventDefault() is called. This creates a mismatch between what the browser thinks happened and what actually happened in the DOM.
Programmatic Changes
Programmatic model changes don't appear in browser history:
// User types "Hello" → Browser adds to undo stack
// You programmatically insert "World" → Browser does NOT add to undo stack
class Editor {
insertText(text: string) {
const operation = {
type: 'insert',
path: this.getSelection().anchor,
text: text
};
// Apply to model
this.model.apply(operation);
// Update DOM
this.renderModelToDOM();
// ❌ Problem: This change is NOT in browser's undo stack
// User presses Ctrl+Z → Only "Hello" is undone, "World" remains
// ✅ Solution: Always record in custom history
this.historyManager.record([operation], this.saveSelection());
}
}IME Composition and History
IME composition creates unique challenges for history management. Preventing events during composition can corrupt browser's IME state.
Composition State Mismatch
When you prevent events during composition, browser's IME state can become corrupted:
// The critical problem
element.addEventListener('beforeinput', (e) => {
if (e.inputType === 'insertParagraph') {
e.preventDefault(); // Prevent paragraph insertion
// ❌ Problem: If composition is active, browser's IME state becomes corrupted
// Browser thinks composition ended, but it's still active internally
// Next IME input fails because browser state is inconsistent
}
});
// What happens:
// 1. User is composing Korean text (한글)
// 2. User presses Enter → insertParagraph fires
// 3. You call preventDefault()
// 4. Browser's internal IME state thinks composition ended
// 5. BUT composition is still active from IME's perspective
// 6. Next character input fails because state mismatch⚠️ Safari Composition Corruption
Safari-specific issue: In Safari, preventing insertParagraph during or after IME composition corrupts the browser's IME state. Subsequent IME input fails completely. This affects Korean, Japanese, and Chinese IME.
preventDefault() During Composition
Always check composition state before preventing events:
class CompositionAwareHistory {
private isComposing = false;
private compositionState: CompositionState | null = null;
init(editor: HTMLElement) {
// Track composition state
editor.addEventListener('compositionstart', () => {
this.isComposing = true;
this.compositionState = {
startTime: Date.now(),
text: ''
};
});
editor.addEventListener('compositionend', (e) => {
this.isComposing = false;
// Record composition as single operation
this.recordCompositionOperation(e.data);
this.compositionState = null;
});
// NEVER prevent events during composition
editor.addEventListener('beforeinput', (e) => {
if (this.isComposing) {
// Allow browser to handle composition
// Don't prevent default
return;
}
// Only prevent when NOT composing
if (e.inputType === 'insertParagraph') {
e.preventDefault();
this.handleCustomParagraphInsertion();
}
});
}
// Alternative: Check isComposing flag
handleBeforeInput(e: InputEvent) {
// Check both our state and browser's flag
if (this.isComposing || e.isComposing) {
// Don't prevent during composition
return;
}
// Safe to prevent
if (e.inputType === 'insertParagraph') {
e.preventDefault();
this.handleCustomOperation();
}
}
}// ✅ Safe pattern: Always check composition
editor.addEventListener('beforeinput', (e) => {
// NEVER prevent during composition
if (e.isComposing || this.compositionHandler.isComposing) {
return; // Let browser handle
}
// Safe to prevent
if (e.inputType === 'insertParagraph') {
e.preventDefault();
this.handleCustomParagraph();
}
});
// ✅ Alternative: Check in keydown
editor.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
// Check composition state
if (e.isComposing || this.compositionHandler.isComposing) {
return; // Let browser handle Enter during composition
}
e.preventDefault();
this.handleCustomEnter();
}
});Composition History Tracking
Track composition as a single history entry:
class CompositionHistory {
private compositionStartModel: DocumentModel | null = null;
private compositionStartSelection: Selection | null = null;
handleCompositionStart() {
// Save state at composition start
this.compositionStartModel = this.model.clone();
this.compositionStartSelection = this.saveSelection();
// Don't record intermediate updates
this.isRecordingComposition = true;
}
handleCompositionUpdate(text: string) {
// Update model for visual feedback
// But don't record in history yet
this.updateCompositionDisplay(text);
}
handleCompositionEnd(finalText: string) {
// Now record as single operation
const operation = {
type: 'insertText',
path: this.compositionStartSelection.anchor,
text: finalText,
metadata: {
source: 'ime',
composition: true
}
};
// Record in history
this.historyManager.record(
[operation],
this.compositionStartSelection,
{
compositionState: {
startText: '',
endText: finalText
}
}
);
this.isRecordingComposition = false;
}
// Prevent recording intermediate composition updates
shouldRecordOperation(operation: Operation): boolean {
if (this.isRecordingComposition) {
// Don't record composition updates, only final result
return false;
}
return true;
}
}History Synchronization Strategies
You need to synchronize your model history with browser's DOM history, or disable browser history entirely.
Disable Browser History
Completely disable browser history and use only custom history:
class HistoryManager {
disableBrowserHistory() {
// Prevent browser's undo/redo
this.editor.addEventListener('beforeinput', (e) => {
if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
e.preventDefault();
if (e.inputType === 'historyUndo') {
this.undo();
} else {
this.redo();
}
}
});
// Also handle keyboard shortcuts
this.editor.addEventListener('keydown', (e) => {
const isUndo = (e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey;
const isRedo = (e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey));
if (isUndo || isRedo) {
e.preventDefault();
e.stopPropagation();
if (isUndo) {
this.undo();
} else {
this.redo();
}
}
});
// Clear browser's undo stack by manipulating DOM
// (Browser clears stack when DOM is replaced)
this.clearBrowserStack();
}
clearBrowserStack() {
// Force clear by replacing content
const content = this.editor.innerHTML;
this.editor.innerHTML = content; // Browser clears stack
}
}Hybrid Approach
Use browser history for simple operations, custom history for complex ones:
class HybridHistoryManager {
shouldUseBrowserHistory(operation: Operation): boolean {
// Use browser history for simple text insertions
if (operation.type === 'insertText' && operation.text.length === 1) {
return true; // Let browser handle
}
// Use custom history for complex operations
if (operation.type === 'format' ||
operation.type === 'insertNode' ||
operation.complex) {
return false; // Use custom history
}
return false; // Default to custom
}
handleOperation(operation: Operation) {
if (this.shouldUseBrowserHistory(operation)) {
// Don't prevent default, let browser handle
// But still track in our history for consistency
this.recordInCustomHistory(operation);
} else {
// Prevent default, use custom history
this.preventDefaultAndRecord(operation);
}
}
}History Reconciliation
Reconcile browser history with model history:
class HistoryReconciler {
reconcile() {
// Detect if browser history was used
const browserHistoryUsed = this.detectBrowserHistoryUse();
if (browserHistoryUsed) {
// Browser undid something, sync model
const currentDOM = this.parseDOMToModel(this.editor);
// Find matching history entry
const matchingEntry = this.findMatchingHistoryEntry(currentDOM);
if (matchingEntry) {
// Restore model to this state
this.model = matchingEntry.beforeModel;
} else {
// No match, sync from DOM
this.model = currentDOM;
}
}
}
detectBrowserHistoryUse(): boolean {
// Monitor for browser undo/redo
let browserHistoryUsed = false;
this.editor.addEventListener('beforeinput', (e) => {
if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
browserHistoryUsed = true;
// Don't prevent, let browser handle
// But sync model after
setTimeout(() => {
this.reconcile();
}, 0);
}
});
return browserHistoryUsed;
}
}Selection in History
Selection must be preserved and transformed correctly across history operations.
Selection Preservation
Always save and restore selection with history entries:
class HistoryManager {
undo() {
if (this.undoStack.length === 0) return false;
const entry = this.undoStack.pop();
// Save current state for redo
const currentState = {
model: this.model.clone(),
selection: this.saveSelection()
};
this.redoStack.push(currentState);
// Restore model
this.model = entry.beforeModel;
// Update DOM from model
this.renderModelToDOM(this.model);
// Restore selection in next frame (after DOM update)
requestAnimationFrame(() => {
this.restoreSelection(entry.beforeSelection);
});
return true;
}
saveSelection(): Selection {
const selection = window.getSelection();
if (selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
// Convert to model selection
return {
anchor: this.domPositionToModelPath(range.startContainer, range.startOffset),
focus: this.domPositionToModelPath(range.endContainer, range.endOffset),
collapsed: range.collapsed
};
}
restoreSelection(modelSelection: Selection) {
if (!modelSelection) return;
// Convert model selection to DOM
const anchorPos = this.modelPathToDOMPosition(modelSelection.anchor);
const focusPos = this.modelPathToDOMPosition(modelSelection.focus);
if (!anchorPos || !focusPos) {
// Selection invalid, find nearest valid position
const nearest = this.findNearestValidPosition(modelSelection.anchor);
if (nearest) {
this.restoreSelection({ anchor: nearest, focus: nearest, collapsed: true });
}
return;
}
const range = document.createRange();
range.setStart(anchorPos.node, anchorPos.offset);
range.setEnd(focusPos.node, focusPos.offset);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
}Selection Transformation
Transform selection when operations affect it:
class SelectionTransformer {
transformSelection(
selection: Selection,
operation: Operation
): Selection {
// Transform selection based on operation
switch (operation.type) {
case 'insert':
return this.transformForInsert(selection, operation);
case 'delete':
return this.transformForDelete(selection, operation);
case 'replace':
return this.transformForReplace(selection, operation);
default:
return selection;
}
}
transformForInsert(selection: Selection, operation: Operation): Selection {
const { path, data } = operation;
// If insertion is before selection, shift selection
if (this.isBefore(path, selection.anchor.path)) {
return {
anchor: {
...selection.anchor,
offset: selection.anchor.offset + data.length
},
focus: {
...selection.focus,
offset: selection.focus.offset + data.length
}
};
}
// If insertion is within selection, expand selection
if (this.isWithin(path, selection)) {
return {
...selection,
// Selection expands to include inserted content
};
}
return selection; // No change
}
transformForDelete(selection: Selection, operation: Operation): Selection {
const { path, length } = operation;
// If deletion is before selection, shift selection
if (this.isBefore(path, selection.anchor.path)) {
const shift = Math.min(length, selection.anchor.offset);
return {
anchor: {
...selection.anchor,
offset: Math.max(0, selection.anchor.offset - shift)
},
focus: {
...selection.focus,
offset: Math.max(0, selection.focus.offset - shift)
}
};
}
// If deletion overlaps selection, adjust selection
if (this.overlaps(path, length, selection)) {
// Collapse to deletion start
return {
anchor: path,
focus: path,
collapsed: true
};
}
return selection;
}
}Transaction and History
Group related operations into transactions for atomic history entries.
Transaction Grouping
Group operations into transactions:
class TransactionHistory {
private currentTransaction: Operation[] = [];
private isInTransaction = false;
startTransaction() {
this.isInTransaction = true;
this.currentTransaction = [];
}
addToTransaction(operation: Operation) {
if (this.isInTransaction) {
this.currentTransaction.push(operation);
} else {
// Not in transaction, record immediately
this.record([operation]);
}
}
commitTransaction() {
if (this.currentTransaction.length > 0) {
// Record all operations as single history entry
this.record(this.currentTransaction);
this.currentTransaction = [];
}
this.isInTransaction = false;
}
rollbackTransaction() {
// Undo all operations in transaction
this.currentTransaction.forEach(op => {
this.model.apply(op.inverse);
});
this.currentTransaction = [];
this.isInTransaction = false;
}
// Example: Formatting operation
applyFormatting(format: Format) {
this.startTransaction();
// Multiple operations for formatting
this.addToTransaction({ type: 'format', format, start: selection.start });
this.addToTransaction({ type: 'format', format, end: selection.end });
this.commitTransaction(); // Single undo entry
}
}Undo Transaction Boundaries
Define what constitutes a single undo operation:
class HistoryManager {
// Group operations within time window
private lastOperationTime = 0;
private operationWindow = 300; // 300ms
shouldGroupWithPrevious(operation: Operation): boolean {
const now = Date.now();
const timeSinceLastOp = now - this.lastOperationTime;
// Group if within time window
if (timeSinceLastOp < this.operationWindow) {
return true;
}
this.lastOperationTime = now;
return false;
}
recordOperation(operation: Operation) {
if (this.shouldGroupWithPrevious(operation)) {
// Add to previous entry
const lastEntry = this.undoStack[this.undoStack.length - 1];
lastEntry.operations.push(operation);
lastEntry.afterModel = this.applyOperation(operation, lastEntry.afterModel);
} else {
// New entry
this.record([operation]);
}
}
// Group by operation type
shouldGroupByType(op1: Operation, op2: Operation): boolean {
// Group consecutive text insertions
if (op1.type === 'insertText' && op2.type === 'insertText') {
return this.areAdjacent(op1.path, op2.path);
}
return false;
}
}Edge Cases and Pitfalls
Common edge cases that break history management:
Focus Changes
Focus changes can clear browser history:
class HistoryManager {
handleFocusChange() {
this.editor.addEventListener('blur', () => {
// Browser may clear undo stack on blur
// Save current state before blur
this.saveStateBeforeBlur();
});
this.editor.addEventListener('focus', () => {
// Browser may have cleared stack
// Restore if needed
this.restoreStateAfterFocus();
});
}
// Prevent history loss on focus change
saveStateBeforeBlur() {
// Save to persistent storage or keep in memory
this.persistentState = {
model: this.model.clone(),
undoStack: this.undoStack,
redoStack: this.redoStack
};
}
}External DOM Changes
External DOM changes can corrupt history:
class HistoryManager {
watchForExternalChanges() {
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (!this.isExpectedChange(mutation)) {
// External change detected
// Option 1: Revert to model
this.revertToModel();
// Option 2: Sync model from DOM
// this.syncModelFromDOM();
// Option 3: Clear history (safest)
// this.clearHistory();
}
});
});
observer.observe(this.editor, {
childList: true,
subtree: true,
attributes: true
});
}
isExpectedChange(mutation: MutationRecord): boolean {
// Check if this change was initiated by our code
return mutation.target.hasAttribute('data-expected-change') ||
this.isOurOperation(mutation);
}
}Browser Extensions
Browser extensions can corrupt history:
class HistoryManager {
handleExtensionInterference() {
// Extensions (Grammarly, spell checkers) modify DOM
// This can corrupt browser history
// Solution: Always use custom history
this.disableBrowserHistory();
// Monitor for extension changes
this.watchForExternalChanges();
// Revert extension changes
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (this.isExtensionChange(mutation)) {
// Revert to model
this.revertToModel();
}
});
});
observer.observe(this.editor, {
childList: true,
subtree: true
});
}
isExtensionChange(mutation: MutationRecord): boolean {
// Detect extension-specific markers
return mutation.target.classList?.contains('grammarly-') ||
mutation.target.hasAttribute('data-grammarly') ||
mutation.target.classList?.contains('spell-check-');
}
}Best Practices
Best practices for professional history management:
- Always disable browser history: Use custom history for full control
- Never prevent events during composition: Check
isComposingbeforepreventDefault() - Record composition as single operation: Don't record intermediate updates
- Always preserve selection: Save and restore selection with each history entry
- Use transactions for atomic operations: Group related operations
- Transform selection correctly: Adjust selection when operations affect it
- Handle external changes: Monitor and revert unexpected DOM modifications
- Validate history entries: Check model consistency after undo/redo
- Use efficient snapshots: Balance memory usage with reconstruction speed
- Test across browsers: History behavior varies significantly
class ProfessionalHistoryManager {
// Complete implementation
constructor(model: DocumentModel, editor: HTMLElement) {
this.model = model;
this.editor = editor;
this.undoStack = [];
this.redoStack = [];
this.compositionHandler = new CompositionHandler();
this.init();
}
init() {
// 1. Disable browser history
this.disableBrowserHistory();
// 2. Track composition state
this.compositionHandler.init(this.editor);
// 3. Handle events
this.setupEventHandlers();
// 4. Watch for external changes
this.watchForExternalChanges();
}
handleBeforeInput(e: InputEvent) {
// NEVER prevent during composition
if (e.isComposing || this.compositionHandler.isComposing) {
return; // Let browser handle
}
// Convert to operation
const operation = this.domEventToOperation(e);
// Prevent default
e.preventDefault();
// Apply to model
this.model.apply(operation);
// Record in history
this.record([operation], this.saveSelection());
// Update DOM
this.renderModelToDOM();
// Restore selection
requestAnimationFrame(() => {
this.restoreSelection(this.calculateSelectionAfterOperation(operation));
});
}
undo() {
if (this.undoStack.length === 0) return false;
const entry = this.undoStack.pop();
// Save for redo
this.redoStack.push({
model: this.model.clone(),
selection: this.saveSelection()
});
// Restore model
this.model = entry.beforeModel;
// Update DOM
this.renderModelToDOM();
// Restore selection
requestAnimationFrame(() => {
this.restoreSelection(entry.beforeSelection);
});
return true;
}
}