/** * Optimistic State Manager * * Manages optimistic UI updates with version-based conflict resolution. * Implements the "Optimistic UI" pattern where client immediately updates * the UI before server confirms, then rolls back on conflicts. * * Key Features: * - Immediate UI feedback (no loading spinners) * - Version-based optimistic concurrency control * - Automatic conflict detection and rollback * - Pending operations queue with retry * - User-friendly conflict resolution * * Protocol: * 1. Client updates UI immediately (optimistic) * 2. Client sends update to server with version * 3. Server checks version and either: * a) Accepts: version matches, returns new state with version++ * b) Rejects: version conflict, returns current server state * 4. Client resolves: * a) On accept: commit optimistic state * b) On conflict: rollback to server state, show conflict UI */ export class OptimisticStateManager { constructor() { /** * Pending operations queue * Map */ this.pendingOperations = new Map(); /** * Rollback snapshots * Map */ this.snapshots = new Map(); /** * Conflict callbacks * Map */ this.conflictHandlers = new Map(); /** * Optimistic operation counter */ this.operationIdCounter = 0; } /** * Apply optimistic update * * Updates component state immediately without waiting for server confirmation. * Creates snapshot for potential rollback. * * @param {string} componentId - Component identifier * @param {Object} currentState - Current component state (with version) * @param {Function} optimisticUpdate - Function that returns optimistically updated state * @param {Object} metadata - Operation metadata (action, params) * @returns {Object} Optimistic state with incremented version */ applyOptimisticUpdate(componentId, currentState, optimisticUpdate, metadata = {}) { // Create snapshot before applying optimistic update if (!this.snapshots.has(componentId)) { this.createSnapshot(componentId, currentState); } // Extract version from state const currentVersion = currentState.version || 1; // Apply optimistic update const optimisticState = optimisticUpdate(currentState); // Increment version optimistically const newState = { ...optimisticState, version: currentVersion + 1 }; // Create pending operation const operation = { id: this.generateOperationId(), componentId, metadata, expectedVersion: currentVersion, optimisticState: newState, timestamp: Date.now(), status: 'pending' // pending, confirmed, failed }; // Add to pending operations queue this.addPendingOperation(componentId, operation); console.log(`[OptimisticUI] Applied optimistic update for ${componentId}`, { version: `${currentVersion} → ${currentVersion + 1}`, operationId: operation.id, pendingCount: this.getPendingOperationsCount(componentId) }); return newState; } /** * Confirm operation success * * Called when server successfully processes the operation. * Commits the optimistic state and clears snapshot if no more pending operations. * * @param {string} componentId - Component identifier * @param {string} operationId - Operation ID to confirm * @param {Object} serverState - Server-confirmed state (may differ from optimistic) * @returns {Object} Final state (server state or optimistic if versions match) */ confirmOperation(componentId, operationId, serverState) { const operation = this.getPendingOperation(componentId, operationId); if (!operation) { console.warn(`[OptimisticUI] Cannot confirm unknown operation: ${operationId}`); return serverState; } // Mark operation as confirmed operation.status = 'confirmed'; // Remove from pending queue this.removePendingOperation(componentId, operationId); console.log(`[OptimisticUI] Confirmed operation ${operationId} for ${componentId}`, { pendingCount: this.getPendingOperationsCount(componentId) }); // If no more pending operations, clear snapshot if (!this.hasPendingOperations(componentId)) { this.clearSnapshot(componentId); } return serverState; } /** * Handle version conflict * * Called when server rejects operation due to version mismatch. * Rolls back to snapshot and applies server state. * * @param {string} componentId - Component identifier * @param {string} operationId - Failed operation ID * @param {Object} serverState - Current server state * @param {Object} conflict - Conflict information * @returns {Object} Resolution result with rollback state and user notification */ handleConflict(componentId, operationId, serverState, conflict = {}) { const operation = this.getPendingOperation(componentId, operationId); if (!operation) { console.warn(`[OptimisticUI] Cannot handle conflict for unknown operation: ${operationId}`); return { state: serverState, notification: null }; } // Mark operation as failed operation.status = 'failed'; // Get snapshot for rollback const snapshot = this.snapshots.get(componentId); console.warn(`[OptimisticUI] Version conflict detected for ${componentId}`, { operationId, expectedVersion: operation.expectedVersion, serverVersion: serverState.version, metadata: operation.metadata, pendingOperationsCount: this.getPendingOperationsCount(componentId) }); // Clear all pending operations (cascade rollback) this.clearPendingOperations(componentId); // Clear snapshot this.clearSnapshot(componentId); // Call conflict handler if registered const conflictHandler = this.conflictHandlers.get(componentId); if (conflictHandler) { conflictHandler({ operation, serverState, snapshotState: snapshot?.state, conflict }); } // Create user notification const notification = { type: 'conflict', title: 'Update Conflict', message: 'Your changes conflicted with another update. The latest version has been loaded.', action: operation.metadata.action || 'unknown', canRetry: true, operation }; return { state: serverState, notification }; } /** * Create state snapshot for rollback * * @param {string} componentId - Component identifier * @param {Object} state - Current state to snapshot */ createSnapshot(componentId, state) { this.snapshots.set(componentId, { state: JSON.parse(JSON.stringify(state)), // Deep clone timestamp: Date.now() }); console.log(`[OptimisticUI] Created snapshot for ${componentId}`, { version: state.version }); } /** * Clear snapshot after all operations confirmed * * @param {string} componentId - Component identifier */ clearSnapshot(componentId) { const snapshot = this.snapshots.get(componentId); if (snapshot) { this.snapshots.delete(componentId); console.log(`[OptimisticUI] Cleared snapshot for ${componentId}`); } } /** * Get rollback snapshot * * @param {string} componentId - Component identifier * @returns {Object|null} Snapshot or null if none exists */ getSnapshot(componentId) { const snapshot = this.snapshots.get(componentId); return snapshot ? snapshot.state : null; } /** * Add pending operation * * @param {string} componentId - Component identifier * @param {Object} operation - Operation object */ addPendingOperation(componentId, operation) { if (!this.pendingOperations.has(componentId)) { this.pendingOperations.set(componentId, []); } this.pendingOperations.get(componentId).push(operation); } /** * Remove pending operation * * @param {string} componentId - Component identifier * @param {string} operationId - Operation ID to remove */ removePendingOperation(componentId, operationId) { const operations = this.pendingOperations.get(componentId); if (!operations) { return; } const index = operations.findIndex(op => op.id === operationId); if (index !== -1) { operations.splice(index, 1); } // Clean up empty arrays if (operations.length === 0) { this.pendingOperations.delete(componentId); } } /** * Get specific pending operation * * @param {string} componentId - Component identifier * @param {string} operationId - Operation ID * @returns {Object|null} Operation or null if not found */ getPendingOperation(componentId, operationId) { const operations = this.pendingOperations.get(componentId); if (!operations) { return null; } return operations.find(op => op.id === operationId) || null; } /** * Get all pending operations for component * * @param {string} componentId - Component identifier * @returns {Array} Array of pending operations */ getPendingOperations(componentId) { return this.pendingOperations.get(componentId) || []; } /** * Get pending operations count * * @param {string} componentId - Component identifier * @returns {number} Number of pending operations */ getPendingOperationsCount(componentId) { const operations = this.pendingOperations.get(componentId); return operations ? operations.length : 0; } /** * Check if component has pending operations * * @param {string} componentId - Component identifier * @returns {boolean} True if has pending operations */ hasPendingOperations(componentId) { return this.getPendingOperationsCount(componentId) > 0; } /** * Clear all pending operations for component * * @param {string} componentId - Component identifier */ clearPendingOperations(componentId) { this.pendingOperations.delete(componentId); } /** * Register conflict handler * * Allows components to provide custom conflict resolution UI. * * @param {string} componentId - Component identifier * @param {Function} handler - Conflict handler callback */ registerConflictHandler(componentId, handler) { this.conflictHandlers.set(componentId, handler); } /** * Unregister conflict handler * * @param {string} componentId - Component identifier */ unregisterConflictHandler(componentId) { this.conflictHandlers.delete(componentId); } /** * Generate unique operation ID * * @returns {string} Unique operation ID */ generateOperationId() { return `op-${++this.operationIdCounter}-${Date.now()}`; } /** * Retry failed operation * * Allows user to retry operation after conflict resolution. * * @param {string} componentId - Component identifier * @param {Object} operation - Failed operation to retry * @param {Function} retryCallback - Callback to execute retry * @returns {Promise} Retry promise */ async retryOperation(componentId, operation, retryCallback) { console.log(`[OptimisticUI] Retrying operation ${operation.id} for ${componentId}`); try { // Execute retry callback const result = await retryCallback(operation.metadata); console.log(`[OptimisticUI] Retry succeeded for ${operation.id}`); return result; } catch (error) { console.error(`[OptimisticUI] Retry failed for ${operation.id}:`, error); throw error; } } /** * Get debugging stats * * @returns {Object} Statistics about optimistic operations */ getStats() { const stats = { total_components: this.pendingOperations.size, total_pending_operations: 0, total_snapshots: this.snapshots.size, components: {} }; this.pendingOperations.forEach((operations, componentId) => { stats.total_pending_operations += operations.length; stats.components[componentId] = { pending: operations.length, has_snapshot: this.snapshots.has(componentId) }; }); return stats; } /** * Clear all state (for testing/debugging) */ clear() { this.pendingOperations.clear(); this.snapshots.clear(); this.conflictHandlers.clear(); this.operationIdCounter = 0; console.log('[OptimisticUI] Cleared all state'); } } // Create singleton instance export const optimisticStateManager = new OptimisticStateManager(); // Export for testing and direct use export default optimisticStateManager;