UNPKG

@profullstack/state-manager

Version:

Enhanced state manager with web component integration, persistence, and subscription management

802 lines (671 loc) 25.1 kB
/** * @profullstack/state-manager * * Enhanced state manager with web component integration, persistence, and subscription management */ import { EventEmitter } from './event-emitter.js'; import { createPersistenceManager } from './persistence.js'; import { createWebComponentIntegration } from './web-components.js'; import { createMiddlewareManager } from './middleware.js'; import logger from '../logger.js'; logger.info('Initializing state-manager module'); /** * Enhanced State Manager * @extends EventEmitter */ class StateManager extends EventEmitter { /** * Create a new StateManager * @param {Object} initialState - Initial state * @param {Object} options - Configuration options * @param {boolean} options.enablePersistence - Whether to enable persistence (default: false) * @param {string} options.persistenceKey - Key for persistence storage (default: 'app_state') * @param {Object} options.persistenceAdapter - Persistence adapter (default: localStorage) * @param {string[]} options.persistentKeys - Keys to persist (default: all) * @param {boolean} options.immutable - Whether to use immutable state (default: true) * @param {boolean} options.debug - Whether to enable debug logging (default: false) */ constructor(initialState = {}, options = {}) { super(); logger.debug('StateManager constructor called with options:', options); // Default options this.options = { enablePersistence: false, persistenceKey: 'app_state', persistenceAdapter: null, persistentKeys: null, immutable: true, debug: false, ...options }; // Initialize state this._state = this._clone(initialState); logger.debug('Initial state cloned'); // Initialize subscribers this._subscribers = new Map(); this._globalSubscribers = []; logger.debug('Subscribers initialized'); // Initialize persistence logger.debug('Initializing persistence manager'); this.persistence = createPersistenceManager({ enabled: this.options.enablePersistence, key: this.options.persistenceKey, adapter: this.options.persistenceAdapter, persistentKeys: this.options.persistentKeys }); // Initialize middleware manager logger.debug('Initializing middleware manager'); this.middleware = createMiddlewareManager(); // Initialize web component integration logger.debug('Initializing web component integration'); this.webComponents = createWebComponentIntegration(this); // Load persisted state if enabled if (this.options.enablePersistence) { logger.debug('Loading persisted state'); const persistedState = this.persistence.load(); if (persistedState) { logger.debug('Persisted state found, merging with initial state'); this._state = this._merge(this._state, persistedState); } } // Bind methods to ensure correct 'this' context this.getState = this.getState.bind(this); this.setState = this.setState.bind(this); this.resetState = this.resetState.bind(this); this.subscribe = this.subscribe.bind(this); this.unsubscribe = this.unsubscribe.bind(this); this.use = this.use.bind(this); this._log('StateManager initialized with state:', this._state); logger.info('StateManager instance created'); } /** * Get the current state or a specific part of the state * @param {string|string[]} [path] - Optional path to get a specific part of the state * @returns {any} The requested state */ getState(path) { // If no path, return the entire state if (path === undefined) { return this._clone(this._state); } // Handle array path if (Array.isArray(path)) { return this._getNestedValue(this._state, path); } // Handle string path (dot notation) if (typeof path === 'string') { const pathArray = path.split('.'); return this._getNestedValue(this._state, pathArray); } // Invalid path return undefined; } /** * Update the state * @param {Object|Function} update - Object to merge with state or function that returns an update object * @param {Object} options - Update options * @param {boolean} options.silent - Whether to suppress notifications (default: false) * @param {boolean} options.persist - Whether to persist the update (default: true if persistence is enabled) * @returns {Object} The new state */ setState(update, options = {}) { logger.debug('setState called with options:', options); // Default options const updateOptions = { silent: false, persist: this.options.enablePersistence, ...options }; // Handle function updates (for state that depends on previous state) const updateObj = typeof update === 'function' ? update(this._clone(this._state)) : update; logger.debug('Update object prepared:', typeof updateObj === 'object' ? 'object' : updateObj); // Apply middleware logger.debug('Applying beforeUpdate middleware'); const processedUpdate = this.middleware.applyMiddleware('beforeUpdate', updateObj, this._state); // Track changed paths const changedPaths = []; // Create new state by merging the update logger.debug('Merging state with update'); const newState = this._merge(this._state, processedUpdate, '', changedPaths); // If no changes, return current state if (changedPaths.length === 0) { this._log('No state changes detected'); logger.debug('No state changes detected'); return this._clone(this._state); } // Update the state this._state = newState; // Apply middleware after update logger.debug('Applying afterUpdate middleware'); this.middleware.applyMiddleware('afterUpdate', this._state, changedPaths); this._log('State updated with paths:', changedPaths); logger.debug('State updated with paths:', changedPaths); // Persist state if enabled if (updateOptions.persist && this.options.enablePersistence) { logger.debug('Persisting updated state'); this.persistence.save(this._state); } // Emit update event logger.debug('Emitting update event'); this.emit('update', this._clone(this._state), changedPaths); // Notify subscribers if not silent if (!updateOptions.silent) { logger.debug('Notifying subscribers'); this._notifySubscribers(changedPaths); } return this._clone(this._state); } /** * Reset the state to initial values * @param {Object} initialState - New initial state * @param {Object} options - Reset options * @param {boolean} options.silent - Whether to suppress notifications (default: false) * @param {boolean} options.persist - Whether to persist the reset (default: true if persistence is enabled) * @returns {Object} The new state */ resetState(initialState = {}, options = {}) { // Default options const resetOptions = { silent: false, persist: this.options.enablePersistence, ...options }; // Apply middleware const processedState = this.middleware.applyMiddleware('beforeReset', initialState); // Get all paths in current state const allPaths = this._getAllPaths(this._state); // Update the state this._state = this._clone(processedState); // Apply middleware after reset this.middleware.applyMiddleware('afterReset', this._state); this._log('State reset to:', this._state); // Persist state if enabled if (resetOptions.persist && this.options.enablePersistence) { this.persistence.save(this._state); } // Emit reset event this.emit('reset', this._clone(this._state)); // Notify subscribers if not silent if (!resetOptions.silent) { this._notifySubscribers(allPaths); } return this._clone(this._state); } /** * Subscribe to state changes * @param {Function} callback - Callback function * @param {string|string[]} [paths] - Specific state path(s) to subscribe to * @returns {Function} Unsubscribe function */ subscribe(callback, paths) { logger.debug('subscribe called with paths:', paths); if (typeof callback !== 'function') { const error = new Error('Subscriber callback must be a function'); logger.error('Subscribe error:', error); throw error; } // If no paths specified, subscribe to all state changes if (paths === undefined) { this._globalSubscribers.push(callback); this._log('Added global subscriber'); logger.debug('Added global subscriber'); // Return unsubscribe function return () => { const index = this._globalSubscribers.indexOf(callback); if (index !== -1) { this._globalSubscribers.splice(index, 1); this._log('Removed global subscriber'); logger.debug('Removed global subscriber'); } }; } // Handle array of paths or single path const pathArray = Array.isArray(paths) ? paths : [paths]; // Normalize paths (convert dot notation to arrays) const normalizedPaths = pathArray.map(path => typeof path === 'string' ? path.split('.') : path ); logger.debug('Normalized paths:', normalizedPaths.map(p => Array.isArray(p) ? p.join('.') : p)); // Add subscriber for each path normalizedPaths.forEach(path => { const pathKey = Array.isArray(path) ? path.join('.') : path; if (!this._subscribers.has(pathKey)) { this._subscribers.set(pathKey, []); } this._subscribers.get(pathKey).push(callback); this._log(`Added subscriber for path: ${pathKey}`); logger.debug(`Added subscriber for path: ${pathKey}`); }); // Return unsubscribe function return () => { normalizedPaths.forEach(path => { const pathKey = Array.isArray(path) ? path.join('.') : path; const subscribers = this._subscribers.get(pathKey); if (subscribers) { const index = subscribers.indexOf(callback); if (index !== -1) { subscribers.splice(index, 1); this._log(`Removed subscriber for path: ${pathKey}`); logger.debug(`Removed subscriber for path: ${pathKey}`); } } }); }; } /** * Unsubscribe a callback from all subscriptions * @param {Function} callback - The callback to unsubscribe */ unsubscribe(callback) { // Remove from global subscribers const globalIndex = this._globalSubscribers.indexOf(callback); if (globalIndex !== -1) { this._globalSubscribers.splice(globalIndex, 1); this._log('Removed global subscriber'); } // Remove from path-specific subscribers this._subscribers.forEach((subscribers, path) => { const index = subscribers.indexOf(callback); if (index !== -1) { subscribers.splice(index, 1); this._log(`Removed subscriber for path: ${path}`); } }); } /** * Add middleware to the state manager * @param {string} type - Middleware type ('beforeUpdate', 'afterUpdate', 'beforeReset', 'afterReset') * @param {Function} middleware - Middleware function * @returns {Function} Function to remove the middleware */ use(type, middleware) { return this.middleware.use(type, middleware); } /** * Create a selector function that memoizes the result * @param {Function} selectorFn - Selector function that takes the state and returns a derived value * @param {Function} [equalityFn] - Function to compare previous and current results * @returns {Function} Memoized selector function */ createSelector(selectorFn, equalityFn = (a, b) => a === b) { let lastState = null; let lastResult = null; return (...args) => { const state = this.getState(); // If state hasn't changed, return memoized result if (lastState && this._shallowEqual(state, lastState)) { return lastResult; } // Calculate new result const result = selectorFn(state, ...args); // If result is equal to last result, return last result if (lastResult !== null && equalityFn(result, lastResult)) { return lastResult; } // Update memoized values lastState = state; lastResult = result; return result; }; } /** * Notify subscribers of state changes * @param {string[]} changedPaths - Paths that changed * @private */ _notifySubscribers(changedPaths) { this._log('Notifying subscribers for paths:', changedPaths); logger.debug('Notifying subscribers for paths:', changedPaths); // Set to track which subscribers have been notified const notifiedSubscribers = new Set(); // First notify path-specific subscribers changedPaths.forEach(path => { // Convert array path to string const pathKey = Array.isArray(path) ? path.join('.') : path; // Get subscribers for this exact path const exactSubscribers = this._subscribers.get(pathKey); if (exactSubscribers && exactSubscribers.length > 0) { this._log(`Notifying ${exactSubscribers.length} subscribers for exact path: ${pathKey}`); logger.debug(`Notifying ${exactSubscribers.length} subscribers for exact path: ${pathKey}`); exactSubscribers.forEach(callback => { if (!notifiedSubscribers.has(callback)) { try { const value = this.getState(pathKey); callback(value, pathKey, this._clone(this._state)); notifiedSubscribers.add(callback); } catch (error) { logger.error(`Error in subscriber callback for path ${pathKey}:`, error); console.error(`Error in subscriber callback for path ${pathKey}:`, error); } } }); } // Also notify subscribers of parent paths if (typeof pathKey === 'string' && pathKey.includes('.')) { const parts = pathKey.split('.'); for (let i = 1; i < parts.length; i++) { const parentPath = parts.slice(0, -i).join('.'); const parentSubscribers = this._subscribers.get(parentPath); if (parentSubscribers && parentSubscribers.length > 0) { this._log(`Notifying ${parentSubscribers.length} subscribers for parent path: ${parentPath}`); logger.debug(`Notifying ${parentSubscribers.length} subscribers for parent path: ${parentPath}`); parentSubscribers.forEach(callback => { if (!notifiedSubscribers.has(callback)) { try { const value = this.getState(parentPath); callback(value, parentPath, this._clone(this._state)); notifiedSubscribers.add(callback); } catch (error) { logger.error(`Error in subscriber callback for parent path ${parentPath}:`, error); console.error(`Error in subscriber callback for parent path ${parentPath}:`, error); } } }); } } } }); // Then notify global subscribers if (this._globalSubscribers.length > 0) { this._log(`Notifying ${this._globalSubscribers.length} global subscribers`); logger.debug(`Notifying ${this._globalSubscribers.length} global subscribers`); this._globalSubscribers.forEach(callback => { if (!notifiedSubscribers.has(callback)) { try { callback(this._clone(this._state), changedPaths); } catch (error) { logger.error('Error in global subscriber callback:', error); console.error('Error in global subscriber callback:', error); } } }); } } /** * Get a nested value from an object using a path array * @param {Object} obj - Object to get value from * @param {string[]} path - Path to the value * @returns {any} The value at the path * @private */ _getNestedValue(obj, path) { let current = obj; for (let i = 0; i < path.length; i++) { if (current === null || current === undefined) { return undefined; } current = current[path[i]]; } return this._clone(current); } /** * Set a nested value in an object using a path array * @param {Object} obj - Object to set value in * @param {string[]} path - Path to the value * @param {any} value - Value to set * @returns {Object} New object with the value set * @private */ _setNestedValue(obj, path, value) { // Clone the object to avoid mutations const result = this._clone(obj); if (path.length === 0) { return value; } let current = result; // Navigate to the parent of the property to set for (let i = 0; i < path.length - 1; i++) { const key = path[i]; // If the current key doesn't exist or is not an object, create it if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') { current[key] = {}; } current = current[key]; } // Set the value at the final key const lastKey = path[path.length - 1]; current[lastKey] = this._clone(value); return result; } /** * Merge two objects deeply * @param {Object} target - Target object * @param {Object} source - Source object * @param {string} currentPath - Current path (for tracking changes) * @param {string[]} changedPaths - Array to collect changed paths * @returns {Object} Merged object * @private */ _merge(target, source, currentPath = '', changedPaths = []) { // If source is not an object or is null, replace target if (source === null || typeof source !== 'object' || Array.isArray(source)) { // Check if the value has changed if (!this._deepEqual(target, source)) { changedPaths.push(currentPath); } return this._clone(source); } // Clone the target to avoid mutations const result = this._clone(target) || {}; // Merge properties from source for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { const sourceProp = source[key]; const targetProp = result[key]; const propPath = currentPath ? `${currentPath}.${key}` : key; // If target property is an object and source property is an object, merge recursively if ( targetProp !== null && typeof targetProp === 'object' && !Array.isArray(targetProp) && sourceProp !== null && typeof sourceProp === 'object' && !Array.isArray(sourceProp) ) { result[key] = this._merge(targetProp, sourceProp, propPath, changedPaths); } else { // Otherwise, replace the target property if (!this._deepEqual(targetProp, sourceProp)) { changedPaths.push(propPath); } result[key] = this._clone(sourceProp); } } } return result; } /** * Get all paths in an object * @param {Object} obj - Object to get paths from * @param {string} [currentPath=''] - Current path * @param {string[]} [paths=[]] - Array to collect paths * @returns {string[]} Array of paths * @private */ _getAllPaths(obj, currentPath = '', paths = []) { // If not an object or null, add the current path if (obj === null || typeof obj !== 'object') { if (currentPath) { paths.push(currentPath); } return paths; } // Add the current path if it's not empty if (currentPath) { paths.push(currentPath); } // Recursively get paths for all properties for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { const propPath = currentPath ? `${currentPath}.${key}` : key; this._getAllPaths(obj[key], propPath, paths); } } return paths; } /** * Clone an object or value * @param {any} value - Value to clone * @returns {any} Cloned value * @private */ _clone(value) { // If not using immutable state, return the value as is if (!this.options.immutable) { return value; } // Handle null and undefined if (value === null || value === undefined) { return value; } // Handle primitive types if (typeof value !== 'object') { return value; } // Handle arrays if (Array.isArray(value)) { return value.map(item => this._clone(item)); } // Handle dates if (value instanceof Date) { return new Date(value); } // Handle regular expressions if (value instanceof RegExp) { return new RegExp(value.source, value.flags); } // Handle objects const result = {}; for (const key in value) { if (Object.prototype.hasOwnProperty.call(value, key)) { result[key] = this._clone(value[key]); } } return result; } /** * Check if two values are deeply equal * @param {any} a - First value * @param {any} b - Second value * @returns {boolean} Whether the values are equal * @private */ _deepEqual(a, b) { // Handle identical values if (a === b) { return true; } // Handle null and undefined if (a === null || b === null || a === undefined || b === undefined) { return a === b; } // Handle primitive types if (typeof a !== 'object' || typeof b !== 'object') { return a === b; } // Handle arrays if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) { return false; } for (let i = 0; i < a.length; i++) { if (!this._deepEqual(a[i], b[i])) { return false; } } return true; } // Handle dates if (a instanceof Date && b instanceof Date) { return a.getTime() === b.getTime(); } // Handle regular expressions if (a instanceof RegExp && b instanceof RegExp) { return a.source === b.source && a.flags === b.flags; } // Handle objects if (!Array.isArray(a) && !Array.isArray(b)) { const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) { return false; } for (const key of keysA) { if (!Object.prototype.hasOwnProperty.call(b, key)) { return false; } if (!this._deepEqual(a[key], b[key])) { return false; } } return true; } return false; } /** * Check if two objects are shallowly equal * @param {Object} a - First object * @param {Object} b - Second object * @returns {boolean} Whether the objects are equal * @private */ _shallowEqual(a, b) { if (a === b) { return true; } if (a === null || b === null || a === undefined || b === undefined) { return a === b; } if (typeof a !== 'object' || typeof b !== 'object') { return a === b; } const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) { return false; } for (const key of keysA) { if (!Object.prototype.hasOwnProperty.call(b, key) || a[key] !== b[key]) { return false; } } return true; } /** * Log debug messages if debug is enabled * @private */ _log(...args) { if (this.options.debug) { console.log('[StateManager]', ...args); } } } /** * Create a new StateManager * @param {Object} initialState - Initial state * @param {Object} options - Configuration options * @returns {StateManager} StateManager instance */ export function createStateManager(initialState = {}, options = {}) { logger.debug('Creating state manager with options:', options); return new StateManager(initialState, options); } // Create a default instance logger.info('Creating default state manager instance'); export const defaultStateManager = createStateManager({}, { enablePersistence: true, persistenceKey: 'app_state', debug: false }); // Export the StateManager class export { StateManager }; // Export utilities export { createPersistenceManager } from './persistence.js'; export { createWebComponentIntegration, StateMixin, createConnectedComponent } from './web-components.js'; export { createMiddlewareManager } from './middleware.js'; export { StoreConnector, createStore } from './store-connector.js'; // Default export export default defaultStateManager;