Error Handling & Recovery

Strategies for handling errors and recovering from failures in model-based contenteditable editors.

Overview

Error handling is critical for maintaining editor stability. Errors can occur during model operations, DOM synchronization, user input processing, or external integrations. This guide covers error types, handling strategies, and recovery mechanisms.

Key principles:

  • Fail gracefully - never crash the editor
  • Maintain model consistency - rollback on errors
  • Provide user feedback - inform users of issues
  • Log errors for debugging - capture error context
  • Recover automatically when possible

Error Types

Common error types in editors:

Model Errors

// Invalid operation
class ModelError extends Error {
  constructor(
    message: string,
    public operation: Operation,
    public model: DocumentModel
  ) {
    super(message);
    this.name = 'ModelError';
  }
}

// Schema validation error
class SchemaError extends Error {
  constructor(
    message: string,
    public node: Node,
    public schema: Schema
  ) {
    super(message);
    this.name = 'SchemaError';
  }
}

// Position out of bounds
class PositionError extends Error {
  constructor(
    message: string,
    public path: Path,
    public model: DocumentModel
  ) {
    super(message);
    this.name = 'PositionError';
  }
}

DOM Errors

// DOM node not found
class DOMNodeError extends Error {
  constructor(
    message: string,
    public expectedNode: Node | null,
    public actualDOM: HTMLElement
  ) {
    super(message);
    this.name = 'DOMNodeError';
  }
}

// Selection sync error
class SelectionError extends Error {
  constructor(
    message: string,
    public modelSelection: Selection,
    public domSelection: Selection | null
  ) {
    super(message);
    this.name = 'SelectionError';
  }
}

Input Processing Errors

// IME composition error
class IMEError extends Error {
  constructor(
    message: string,
    public event: CompositionEvent,
    public state: CompositionState
  ) {
    super(message);
    this.name = 'IMEError';
  }
}

// Paste processing error
class PasteError extends Error {
  constructor(
    message: string,
    public clipboardData: DataTransfer,
    public targetPosition: Path
  ) {
    super(message);
    this.name = 'PasteError';
  }
}

Error Handling Strategies

Different error types require different handling strategies:

Try-Catch Blocks

class Editor {
  applyOperation(operation: Operation) {
    try {
      // Validate operation
      this.validateOperation(operation);
      
      // Apply to model
      const newModel = this.model.applyOperation(operation);
      
      // Update DOM
      this.renderer.update(newModel);
      
      // Update history
      this.history.push(operation);
      
    } catch (error) {
      // Handle error
      this.handleError(error, operation);
      
      // Re-throw if critical
      if (error instanceof CriticalError) {
        throw error;
      }
    }
  }
  
  private handleError(error: Error, operation: Operation) {
    // Log error
    this.logger.error('Operation failed', { error, operation });
    
    // Rollback if needed
    if (this.model.isDirty) {
      this.model.rollback();
    }
    
    // Notify user
    this.notifyUser('Operation could not be completed', 'error');
  }
}

Error Boundaries

class ErrorBoundary {
  private errorHandlers: Map<string, ErrorHandler> = new Map();
  
  registerHandler(errorType: string, handler: ErrorHandler) {
    this.errorHandlers.set(errorType, handler);
  }
  
  handle(error: Error, context: ErrorContext) {
    const errorType = error.constructor.name;
    const handler = this.errorHandlers.get(errorType);
    
    if (handler) {
      return handler(error, context);
    }
    
    // Default handler
    return this.defaultHandler(error, context);
  }
  
  private defaultHandler(error: Error, context: ErrorContext) {
    console.error('Unhandled error:', error, context);
    return { recovered: false, message: 'An unexpected error occurred' };
  }
}

// Usage
const boundary = new ErrorBoundary();

boundary.registerHandler('ModelError', (error, context) => {
  // Rollback model
  context.editor.model.rollback();
  return { recovered: true, message: 'Changes were reverted' };
});

boundary.registerHandler('DOMNodeError', (error, context) => {
  // Re-sync DOM
  context.editor.renderer.fullRender(context.editor.model);
  return { recovered: true, message: 'Editor was refreshed' };
});

Validation Before Application

class OperationValidator {
  validate(operation: Operation, model: DocumentModel): ValidationResult {
    // Check operation type
    if (!this.isValidOperationType(operation.type)) {
      return { valid: false, error: 'Invalid operation type' };
    }
    
    // Check path validity
    if (!this.isValidPath(operation.path, model)) {
      return { valid: false, error: 'Invalid path' };
    }
    
    // Check schema compliance
    if (!this.compliesWithSchema(operation, model.schema)) {
      return { valid: false, error: 'Operation violates schema' };
    }
    
    // Check for conflicts
    if (this.hasConflicts(operation, model)) {
      return { valid: false, error: 'Operation conflicts with current state' };
    }
    
    return { valid: true };
  }
  
  private isValidPath(path: Path, model: DocumentModel): boolean {
    try {
      const node = model.getNodeAtPath(path);
      return node !== null;
    } catch {
      return false;
    }
  }
}

// Use before applying
const validator = new OperationValidator();
const result = validator.validate(operation, editor.model);

if (!result.valid) {
  throw new ValidationError(result.error, operation);
}

Recovery Mechanisms

Recovery mechanisms restore editor state after errors:

Rollback

class Model {
  private history: ModelSnapshot[] = [];
  private currentSnapshot: ModelSnapshot;
  
  beginTransaction() {
    // Save current state
    this.history.push(this.currentSnapshot.clone());
  }
  
  rollback() {
    if (this.history.length > 0) {
      this.currentSnapshot = this.history.pop()!;
      return true;
    }
    return false;
  }
  
  commit() {
    // Clear history for committed transaction
    this.history = [];
  }
}

// Usage in error handling
try {
  editor.model.beginTransaction();
  editor.applyOperation(operation);
  editor.model.commit();
} catch (error) {
  // Rollback on error
  editor.model.rollback();
  editor.renderer.update(editor.model);
  throw error;
}

Re-synchronization

class Editor {
  reSync() {
    try {
      // Rebuild model from DOM
      const newModel = this.parser.parse(this.element);
      
      // Validate model
      const validation = this.schema.validate(newModel);
      if (!validation.valid) {
        // Try to fix model
        const fixedModel = this.schema.fix(newModel);
        this.model = fixedModel;
      } else {
        this.model = newModel;
      }
      
      // Re-render to ensure consistency
      this.renderer.fullRender(this.model);
      
      // Restore selection if possible
      this.restoreSelection();
      
    } catch (error) {
      // Last resort: reset to empty state
      this.reset();
      this.notifyUser('Editor was reset due to an error', 'warning');
    }
  }
  
  private restoreSelection() {
    try {
      const domSelection = window.getSelection();
      if (domSelection && domSelection.rangeCount > 0) {
        const modelSelection = this.positionMapper.fromDOMSelection(domSelection);
        if (modelSelection) {
          this.selection = modelSelection;
        }
      }
    } catch {
      // Selection restoration failed, use default
      this.selection = { start: { path: [0], offset: 0 }, end: { path: [0], offset: 0 } };
    }
  }
}

Graceful Degradation

class Editor {
  applyOperation(operation: Operation) {
    try {
      // Try full operation
      return this.applyOperationFull(operation);
    } catch (error) {
      // Fallback to simpler operation
      try {
        const simplified = this.simplifyOperation(operation);
        return this.applyOperationFull(simplified);
      } catch (fallbackError) {
        // Last resort: skip operation
        this.logger.warn('Operation skipped', { operation, error, fallbackError });
        return false;
      }
    }
  }
  
  private simplifyOperation(operation: Operation): Operation {
    // Remove complex parts, keep only essential changes
    switch (operation.type) {
      case 'composite':
        // Return first operation only
        return operation.operations[0] || operation;
      case 'applyFormat':
        // Skip format, just insert/delete
        return { type: 'insertText', path: operation.path, text: '' };
      default:
        return operation;
    }
  }
}

Error Logging

Comprehensive error logging helps with debugging and monitoring:

interface ErrorLog {
  timestamp: number;
  error: Error;
  context: {
    operation?: Operation;
    model?: DocumentModel;
    selection?: Selection;
    userAction?: string;
    browser?: string;
    url?: string;
  };
  stack?: string;
  recovered: boolean;
}

class ErrorLogger {
  private logs: ErrorLog[] = [];
  
  log(error: Error, context: ErrorContext, recovered: boolean = false) {
    const log: ErrorLog = {
      timestamp: Date.now(),
      error,
      context: {
        operation: context.operation,
        model: context.model ? this.serializeModel(context.model) : undefined,
        selection: context.selection,
        userAction: context.userAction,
        browser: navigator.userAgent,
        url: window.location.href,
      },
      stack: error.stack,
      recovered,
    };
    
    this.logs.push(log);
    
    // Send to monitoring service in production
    if (process.env.NODE_ENV === 'production') {
      this.sendToMonitoring(log);
    }
    
    // Console in development
    if (process.env.NODE_ENV === 'development') {
      console.error('Editor error:', log);
    }
  }
  
  private sendToMonitoring(log: ErrorLog) {
    // Send to error tracking service (e.g., Sentry, LogRocket)
    // fetch('/api/errors', { method: 'POST', body: JSON.stringify(log) });
  }
  
  getRecentErrors(limit: number = 10): ErrorLog[] {
    return this.logs.slice(-limit);
  }
}

User Feedback

Users should be informed about errors in a non-intrusive way:

class ErrorNotifier {
  private notificationElement: HTMLElement | null = null;
  
  notify(message: string, type: 'error' | 'warning' | 'info' = 'error') {
    // Create or update notification
    if (!this.notificationElement) {
      this.notificationElement = this.createNotificationElement();
      document.body.appendChild(this.notificationElement);
    }
    
    this.notificationElement.textContent = message;
    this.notificationElement.className = `error-notification error-notification-` + type;
    
    // Show notification
    this.notificationElement.style.display = 'block';
    
    // Auto-hide after 5 seconds
    setTimeout(() => {
      this.hide();
    }, 5000);
  }
  
  private createNotificationElement(): HTMLElement {
    const element = document.createElement('div');
    element.className = 'error-notification';
    element.style.cssText = `
      position: fixed;
      bottom: 20px;
      right: 20px;
      padding: 12px 16px;
      background: #f44336;
      color: white;
      border-radius: 4px;
      box-shadow: 0 2px 8px rgba(0,0,0,0.2);
      z-index: 10000;
      display: none;
    `;
    return element;
  }
  
  hide() {
    if (this.notificationElement) {
      this.notificationElement.style.display = 'none';
    }
  }
}

// Usage
const notifier = new ErrorNotifier();

try {
  editor.applyOperation(operation);
} catch (error) {
  notifier.notify('Could not complete operation. Changes were reverted.', 'error');
}