/** * State Management Module * * Provides centralized, reactive state management for client-side state. * Features: * - Reactive state store (similar to Redux/Vuex) * - State persistence (localStorage, sessionStorage) * - State synchronization across tabs (BroadcastChannel) * - Integration with LiveComponents * - Time-travel debugging support */ import { Logger } from '../../core/logger.js'; /** * StateManager - Centralized state management */ export class StateManager { constructor(config = {}) { this.state = config.initialState || {}; this.subscribers = new Map(); // Map> this.middleware = []; this.history = []; // For time-travel debugging this.maxHistorySize = config.maxHistorySize || 50; this.enableHistory = config.enableHistory ?? false; // Persistence configuration this.persistence = { enabled: config.persistence?.enabled ?? false, storage: config.persistence?.storage || 'localStorage', // 'localStorage' | 'sessionStorage' key: config.persistence?.key || 'app-state', paths: config.persistence?.paths || [] // Only persist these paths }; // Cross-tab synchronization this.sync = { enabled: config.sync?.enabled ?? false, channel: null }; // Load persisted state if (this.persistence.enabled) { this.loadPersistedState(); } // Initialize cross-tab sync if (this.sync.enabled && typeof BroadcastChannel !== 'undefined') { this.initCrossTabSync(); } Logger.info('[StateManager] Initialized', { persistence: this.persistence.enabled, sync: this.sync.enabled, history: this.enableHistory }); } /** * Create a new StateManager instance */ static create(config = {}) { return new StateManager(config); } /** * Get current state */ getState() { return this.state; } /** * Get state at a specific path */ get(path, defaultValue = undefined) { const keys = path.split('.'); let value = this.state; for (const key of keys) { if (value === null || value === undefined || typeof value !== 'object') { return defaultValue; } value = value[key]; } return value !== undefined ? value : defaultValue; } /** * Set state at a specific path */ set(path, value) { const keys = path.split('.'); const newState = { ...this.state }; let current = newState; // Navigate/create nested structure for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) { current[key] = {}; } else { current[key] = { ...current[key] }; } current = current[key]; } // Set the value const lastKey = keys[keys.length - 1]; const oldValue = this.get(path); current[lastKey] = value; // Apply middleware const action = { type: 'SET', path, value, oldValue }; const processedAction = this.applyMiddleware(action); if (processedAction === null) { return; // Middleware blocked the action } // Update state this.state = newState; // Save to history if (this.enableHistory) { this.addToHistory(action); } // Persist if enabled if (this.persistence.enabled && this.shouldPersist(path)) { this.persistState(); } // Sync across tabs if (this.sync.enabled) { this.broadcastStateChange(action); } // Notify subscribers this.notifySubscribers(path, value, oldValue); Logger.debug('[StateManager] State updated', { path, value }); } /** * Dispatch an action (similar to Redux) */ dispatch(action) { if (typeof action === 'function') { // Thunk support return action(this.dispatch.bind(this), this.getState.bind(this)); } if (!action.type) { throw new Error('Action must have a type property'); } // Apply middleware const processedAction = this.applyMiddleware(action); if (processedAction === null) { return; // Middleware blocked the action } // Execute action (reducer pattern) const newState = this.reducer(this.state, processedAction); if (newState !== this.state) { const oldState = this.state; this.state = newState; // Save to history if (this.enableHistory) { this.addToHistory(processedAction); } // Persist if enabled if (this.persistence.enabled) { this.persistState(); } // Sync across tabs if (this.sync.enabled) { this.broadcastStateChange(processedAction); } // Notify all subscribers this.notifyAllSubscribers(); } Logger.debug('[StateManager] Action dispatched', processedAction); } /** * Default reducer (can be overridden) */ reducer(state, action) { // Default reducer handles SET actions if (action.type === 'SET' && action.path) { const keys = action.path.split('.'); const newState = { ...state }; let current = newState; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) { current[key] = {}; } else { current[key] = { ...current[key] }; } current = current[key]; } current[keys[keys.length - 1]] = action.value; return newState; } return state; } /** * Subscribe to state changes */ subscribe(path, callback) { if (!this.subscribers.has(path)) { this.subscribers.set(path, new Set()); } this.subscribers.get(path).add(callback); // Return unsubscribe function return () => { const callbacks = this.subscribers.get(path); if (callbacks) { callbacks.delete(callback); if (callbacks.size === 0) { this.subscribers.delete(path); } } }; } /** * Subscribe to all state changes */ subscribeAll(callback) { return this.subscribe('*', callback); } /** * Notify subscribers of a state change */ notifySubscribers(path, newValue, oldValue) { // Notify path-specific subscribers const pathCallbacks = this.subscribers.get(path); if (pathCallbacks) { pathCallbacks.forEach(callback => { try { callback(newValue, oldValue, path); } catch (error) { Logger.error('[StateManager] Subscriber error', error); } }); } // Notify wildcard subscribers const wildcardCallbacks = this.subscribers.get('*'); if (wildcardCallbacks) { wildcardCallbacks.forEach(callback => { try { callback(this.state, path); } catch (error) { Logger.error('[StateManager] Subscriber error', error); } }); } // Notify parent path subscribers const pathParts = path.split('.'); for (let i = pathParts.length - 1; i > 0; i--) { const parentPath = pathParts.slice(0, i).join('.'); const parentCallbacks = this.subscribers.get(parentPath); if (parentCallbacks) { parentCallbacks.forEach(callback => { try { callback(this.get(parentPath), parentPath); } catch (error) { Logger.error('[StateManager] Subscriber error', error); } }); } } } /** * Notify all subscribers (for full state changes) */ notifyAllSubscribers() { this.subscribers.forEach((callbacks, path) => { callbacks.forEach(callback => { try { if (path === '*') { callback(this.state); } else { const value = this.get(path); callback(value, path); } } catch (error) { Logger.error('[StateManager] Subscriber error', error); } }); }); } /** * Add middleware */ use(middleware) { if (typeof middleware !== 'function') { throw new Error('Middleware must be a function'); } this.middleware.push(middleware); } /** * Apply middleware chain */ applyMiddleware(action) { let processedAction = action; for (const middleware of this.middleware) { processedAction = middleware(processedAction, this.getState.bind(this)); if (processedAction === null) { return null; // Middleware blocked the action } } return processedAction; } /** * Load persisted state */ loadPersistedState() { try { const storage = this.persistence.storage === 'sessionStorage' ? sessionStorage : localStorage; const stored = storage.getItem(this.persistence.key); if (stored) { const parsed = JSON.parse(stored); this.state = { ...this.state, ...parsed }; Logger.info('[StateManager] Loaded persisted state'); } } catch (error) { Logger.error('[StateManager] Failed to load persisted state', error); } } /** * Persist state */ persistState() { try { const storage = this.persistence.storage === 'sessionStorage' ? sessionStorage : localStorage; const stateToPersist = this.persistence.paths.length > 0 ? this.getPersistedPaths() : this.state; storage.setItem(this.persistence.key, JSON.stringify(stateToPersist)); Logger.debug('[StateManager] State persisted'); } catch (error) { Logger.error('[StateManager] Failed to persist state', error); } } /** * Get only paths that should be persisted */ getPersistedPaths() { const result = {}; for (const path of this.persistence.paths) { const value = this.get(path); if (value !== undefined) { const keys = path.split('.'); let current = result; for (let i = 0; i < keys.length - 1; i++) { if (!(keys[i] in current)) { current[keys[i]] = {}; } current = current[keys[i]]; } current[keys[keys.length - 1]] = value; } } return result; } /** * Check if path should be persisted */ shouldPersist(path) { if (this.persistence.paths.length === 0) { return true; // Persist all if no paths specified } return this.persistence.paths.some(p => path.startsWith(p)); } /** * Initialize cross-tab synchronization */ initCrossTabSync() { try { this.sync.channel = new BroadcastChannel('state-manager-sync'); this.sync.channel.addEventListener('message', (event) => { if (event.data.type === 'STATE_CHANGE') { this.handleRemoteStateChange(event.data.action); } }); Logger.info('[StateManager] Cross-tab sync initialized'); } catch (error) { Logger.error('[StateManager] Failed to initialize cross-tab sync', error); this.sync.enabled = false; } } /** * Broadcast state change to other tabs */ broadcastStateChange(action) { if (this.sync.channel) { try { this.sync.channel.postMessage({ type: 'STATE_CHANGE', action, timestamp: Date.now() }); } catch (error) { Logger.error('[StateManager] Failed to broadcast state change', error); } } } /** * Handle remote state change from another tab */ handleRemoteStateChange(action) { // Apply the action without broadcasting (to avoid loops) const wasSyncEnabled = this.sync.enabled; this.sync.enabled = false; if (action.type === 'SET' && action.path) { this.set(action.path, action.value); } else { this.dispatch(action); } this.sync.enabled = wasSyncEnabled; } /** * Add action to history */ addToHistory(action) { this.history.push({ action, state: JSON.parse(JSON.stringify(this.state)), timestamp: Date.now() }); // Limit history size if (this.history.length > this.maxHistorySize) { this.history.shift(); } } /** * Get history */ getHistory() { return [...this.history]; } /** * Time-travel to a specific history point */ timeTravel(index) { if (index < 0 || index >= this.history.length) { throw new Error('Invalid history index'); } const historyPoint = this.history[index]; this.state = JSON.parse(JSON.stringify(historyPoint.state)); this.notifyAllSubscribers(); Logger.info('[StateManager] Time-traveled to history point', index); } /** * Reset state to initial state */ reset() { this.state = {}; this.history = []; this.notifyAllSubscribers(); if (this.persistence.enabled) { const storage = this.persistence.storage === 'sessionStorage' ? sessionStorage : localStorage; storage.removeItem(this.persistence.key); } Logger.info('[StateManager] State reset'); } /** * Destroy state manager */ destroy() { // Unsubscribe all this.subscribers.clear(); // Close cross-tab sync if (this.sync.channel) { this.sync.channel.close(); this.sync.channel = null; } // Clear history this.history = []; Logger.info('[StateManager] Destroyed'); } } /** * Create a global state manager instance */ let globalStateManager = null; /** * Get or create global state manager */ export function getGlobalStateManager(config = {}) { if (!globalStateManager) { globalStateManager = StateManager.create(config); } return globalStateManager; } /** * Create a scoped state manager (for component-specific state) */ export function createScopedStateManager(config = {}) { return StateManager.create(config); }