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');
}