UNPKG

eleva

Version:

A minimalist and lightweight, pure vanilla JavaScript frontend runtime framework.

742 lines (654 loc) 22.2 kB
"use strict"; /** * @class 🏪 StorePlugin * @classdesc A powerful reactive state management plugin for Eleva.js that enables sharing * reactive data across the entire application. The Store plugin provides a centralized, * reactive data store that can be accessed from any component's setup function. * * Core Features: * - Centralized reactive state management using Eleva's signal system * - Global state accessibility through component setup functions * - Namespace support for organizing store modules * - Built-in persistence with localStorage/sessionStorage support * - Action-based state mutations with validation * - Subscription system for reactive updates * - DevTools integration for debugging * - Plugin architecture for extensibility * * @example * // Install the plugin * const app = new Eleva("myApp"); * app.use(StorePlugin, { * state: { * user: { name: "John", email: "john@example.com" }, * counter: 0, * todos: [] * }, * actions: { * increment: (state) => state.counter.value++, * addTodo: (state, todo) => state.todos.value.push(todo), * setUser: (state, user) => state.user.value = user * }, * persistence: { * enabled: true, * key: "myApp-store", * storage: "localStorage" * } * }); * * // Use store in components * app.component("Counter", { * setup({ store }) { * return { * count: store.state.counter, * increment: () => store.dispatch("increment"), * user: store.state.user * }; * }, * template: (ctx) => ` * <div> * <p>Hello ${ctx.user.value.name}!</p> * <p>Count: ${ctx.count.value}</p> * <button onclick="ctx.increment()">+</button> * </div> * ` * }); */ export const StorePlugin = { /** * Unique identifier for the plugin * @type {string} */ name: "store", /** * Plugin version * @type {string} */ version: "1.0.0-rc.1", /** * Plugin description * @type {string} */ description: "Reactive state management for sharing data across the entire Eleva application", /** * Installs the plugin into the Eleva instance * * @param {Object} eleva - The Eleva instance * @param {Object} options - Plugin configuration options * @param {Object} [options.state={}] - Initial state object * @param {Object} [options.actions={}] - Action functions for state mutations * @param {Object} [options.namespaces={}] - Namespaced modules for organizing store * @param {Object} [options.persistence] - Persistence configuration * @param {boolean} [options.persistence.enabled=false] - Enable state persistence * @param {string} [options.persistence.key="eleva-store"] - Storage key * @param {"localStorage" | "sessionStorage"} [options.persistence.storage="localStorage"] - Storage type * @param {Array<string>} [options.persistence.include] - State keys to persist (if not provided, all state is persisted) * @param {Array<string>} [options.persistence.exclude] - State keys to exclude from persistence * @param {boolean} [options.devTools=false] - Enable development tools integration * @param {Function} [options.onError=null] - Error handler function * * @example * // Basic installation * app.use(StorePlugin, { * state: { count: 0, user: null }, * actions: { * increment: (state) => state.count.value++, * setUser: (state, user) => state.user.value = user * } * }); * * // Advanced installation with persistence and namespaces * app.use(StorePlugin, { * state: { theme: "light" }, * namespaces: { * auth: { * state: { user: null, token: null }, * actions: { * login: (state, { user, token }) => { * state.user.value = user; * state.token.value = token; * }, * logout: (state) => { * state.user.value = null; * state.token.value = null; * } * } * } * }, * persistence: { * enabled: true, * include: ["theme", "auth.user"] * } * }); */ install(eleva, options = {}) { const { state = {}, actions = {}, namespaces = {}, persistence = {}, devTools = false, onError = null, } = options; /** * Store instance that manages all state and provides the API * @private */ class Store { constructor() { this.state = {}; this.actions = {}; this.subscribers = new Set(); this.mutations = []; this.persistence = { enabled: false, key: "eleva-store", storage: "localStorage", include: null, exclude: null, ...persistence, }; this.devTools = devTools; this.onError = onError; this._initializeState(state, actions); this._initializeNamespaces(namespaces); this._loadPersistedState(); this._setupDevTools(); } /** * Initializes the root state and actions * @private */ _initializeState(initialState, initialActions) { // Create reactive signals for each state property Object.entries(initialState).forEach(([key, value]) => { this.state[key] = new eleva.signal(value); }); // Set up actions this.actions = { ...initialActions }; } /** * Initializes namespaced modules * @private */ _initializeNamespaces(namespaces) { Object.entries(namespaces).forEach(([namespace, module]) => { const { state: moduleState = {}, actions: moduleActions = {} } = module; // Create namespace object if it doesn't exist if (!this.state[namespace]) { this.state[namespace] = {}; } if (!this.actions[namespace]) { this.actions[namespace] = {}; } // Initialize namespaced state Object.entries(moduleState).forEach(([key, value]) => { this.state[namespace][key] = new eleva.signal(value); }); // Set up namespaced actions this.actions[namespace] = { ...moduleActions }; }); } /** * Loads persisted state from storage * @private */ _loadPersistedState() { if (!this.persistence.enabled || typeof window === "undefined") { return; } try { const storage = window[this.persistence.storage]; const persistedData = storage.getItem(this.persistence.key); if (persistedData) { const data = JSON.parse(persistedData); this._applyPersistedData(data); } } catch (error) { if (this.onError) { this.onError(error, "Failed to load persisted state"); } else { console.warn( "[StorePlugin] Failed to load persisted state:", error ); } } } /** * Applies persisted data to the current state * @private */ _applyPersistedData(data, currentState = this.state, path = "") { Object.entries(data).forEach(([key, value]) => { const fullPath = path ? `${path}.${key}` : key; if (this._shouldPersist(fullPath)) { if ( currentState[key] && typeof currentState[key] === "object" && "value" in currentState[key] ) { // This is a signal, update its value currentState[key].value = value; } else if ( typeof value === "object" && value !== null && currentState[key] ) { // This is a nested object, recurse this._applyPersistedData(value, currentState[key], fullPath); } } }); } /** * Determines if a state path should be persisted * @private */ _shouldPersist(path) { const { include, exclude } = this.persistence; if (include && include.length > 0) { return include.some((includePath) => path.startsWith(includePath)); } if (exclude && exclude.length > 0) { return !exclude.some((excludePath) => path.startsWith(excludePath)); } return true; } /** * Saves current state to storage * @private */ _saveState() { if (!this.persistence.enabled || typeof window === "undefined") { return; } try { const storage = window[this.persistence.storage]; const dataToSave = this._extractPersistedData(); storage.setItem(this.persistence.key, JSON.stringify(dataToSave)); } catch (error) { if (this.onError) { this.onError(error, "Failed to save state"); } else { console.warn("[StorePlugin] Failed to save state:", error); } } } /** * Extracts data that should be persisted * @private */ _extractPersistedData(currentState = this.state, path = "") { const result = {}; Object.entries(currentState).forEach(([key, value]) => { const fullPath = path ? `${path}.${key}` : key; if (this._shouldPersist(fullPath)) { if (value && typeof value === "object" && "value" in value) { // This is a signal, extract its value result[key] = value.value; } else if (typeof value === "object" && value !== null) { // This is a nested object, recurse const nestedData = this._extractPersistedData(value, fullPath); if (Object.keys(nestedData).length > 0) { result[key] = nestedData; } } } }); return result; } /** * Sets up development tools integration * @private */ _setupDevTools() { if ( !this.devTools || typeof window === "undefined" || !window.__ELEVA_DEVTOOLS__ ) { return; } window.__ELEVA_DEVTOOLS__.registerStore(this); } /** * Dispatches an action to mutate the state * @param {string} actionName - The name of the action to dispatch (supports namespaced actions like "auth.login") * @param {any} payload - The payload to pass to the action * @returns {Promise<any>} The result of the action */ async dispatch(actionName, payload) { try { const action = this._getAction(actionName); if (!action) { const error = new Error(`Action "${actionName}" not found`); if (this.onError) { this.onError(error, actionName); } throw error; } const mutation = { type: actionName, payload, timestamp: Date.now(), }; // Record mutation for devtools this.mutations.push(mutation); if (this.mutations.length > 100) { this.mutations.shift(); // Keep only last 100 mutations } // Execute the action const result = await action.call(null, this.state, payload); // Save state if persistence is enabled this._saveState(); // Notify subscribers this.subscribers.forEach((callback) => { try { callback(mutation, this.state); } catch (error) { if (this.onError) { this.onError(error, "Subscriber callback failed"); } } }); // Notify devtools if ( this.devTools && typeof window !== "undefined" && window.__ELEVA_DEVTOOLS__ ) { window.__ELEVA_DEVTOOLS__.notifyMutation(mutation, this.state); } return result; } catch (error) { if (this.onError) { this.onError(error, `Action dispatch failed: ${actionName}`); } throw error; } } /** * Gets an action by name (supports namespaced actions) * @private */ _getAction(actionName) { const parts = actionName.split("."); let current = this.actions; for (const part of parts) { if (current[part] === undefined) { return null; } current = current[part]; } return typeof current === "function" ? current : null; } /** * Subscribes to store mutations * @param {Function} callback - Callback function to call on mutations * @returns {Function} Unsubscribe function */ subscribe(callback) { if (typeof callback !== "function") { throw new Error("Subscribe callback must be a function"); } this.subscribers.add(callback); // Return unsubscribe function return () => { this.subscribers.delete(callback); }; } /** * Gets a deep copy of the current state values (not signals) * @returns {Object} The current state values */ getState() { return this._extractPersistedData(); } /** * Replaces the entire state (useful for testing or state hydration) * @param {Object} newState - The new state object */ replaceState(newState) { this._applyPersistedData(newState); this._saveState(); } /** * Clears persisted state from storage */ clearPersistedState() { if (!this.persistence.enabled || typeof window === "undefined") { return; } try { const storage = window[this.persistence.storage]; storage.removeItem(this.persistence.key); } catch (error) { if (this.onError) { this.onError(error, "Failed to clear persisted state"); } } } /** * Registers a new namespaced module at runtime * @param {string} namespace - The namespace for the module * @param {Object} module - The module definition * @param {Object} module.state - The module's initial state * @param {Object} module.actions - The module's actions */ registerModule(namespace, module) { if (this.state[namespace] || this.actions[namespace]) { console.warn(`[StorePlugin] Module "${namespace}" already exists`); return; } // Initialize the module this.state[namespace] = {}; this.actions[namespace] = {}; const namespaces = { [namespace]: module }; this._initializeNamespaces(namespaces); this._saveState(); } /** * Unregisters a namespaced module * @param {string} namespace - The namespace to unregister */ unregisterModule(namespace) { if (!this.state[namespace] && !this.actions[namespace]) { console.warn(`[StorePlugin] Module "${namespace}" does not exist`); return; } delete this.state[namespace]; delete this.actions[namespace]; this._saveState(); } /** * Creates a new reactive state property at runtime * @param {string} key - The state key * @param {*} initialValue - The initial value * @returns {Object} The created signal */ createState(key, initialValue) { if (this.state[key]) { return this.state[key]; // Return existing state } this.state[key] = new eleva.signal(initialValue); this._saveState(); return this.state[key]; } /** * Creates a new action at runtime * @param {string} name - The action name * @param {Function} actionFn - The action function */ createAction(name, actionFn) { if (typeof actionFn !== "function") { throw new Error("Action must be a function"); } this.actions[name] = actionFn; } } // Create the store instance const store = new Store(); // Store the original mount method to override it const originalMount = eleva.mount; /** * Override the mount method to inject store context into components */ eleva.mount = async (container, compName, props = {}) => { // Get the component definition const componentDef = typeof compName === "string" ? eleva._components.get(compName) || compName : compName; if (!componentDef) { return await originalMount.call(eleva, container, compName, props); } // Create a wrapped component that injects store into setup const wrappedComponent = { ...componentDef, async setup(ctx) { // Inject store into the context with enhanced API ctx.store = { // Core store functionality state: store.state, dispatch: store.dispatch.bind(store), subscribe: store.subscribe.bind(store), getState: store.getState.bind(store), // Module management registerModule: store.registerModule.bind(store), unregisterModule: store.unregisterModule.bind(store), // Utilities for dynamic state/action creation createState: store.createState.bind(store), createAction: store.createAction.bind(store), // Access to signal constructor for manual state creation signal: eleva.signal, }; // Call original setup if it exists const originalSetup = componentDef.setup; const result = originalSetup ? await originalSetup(ctx) : {}; return result; }, }; // Call original mount with wrapped component return await originalMount.call( eleva, container, wrappedComponent, props ); }; // Override _mountComponents to ensure child components also get store context const originalMountComponents = eleva._mountComponents; eleva._mountComponents = async (container, children, childInstances) => { // Create wrapped children with store injection const wrappedChildren = {}; for (const [selector, childComponent] of Object.entries(children)) { const componentDef = typeof childComponent === "string" ? eleva._components.get(childComponent) || childComponent : childComponent; if (componentDef && typeof componentDef === "object") { wrappedChildren[selector] = { ...componentDef, async setup(ctx) { // Inject store into the context with enhanced API ctx.store = { // Core store functionality state: store.state, dispatch: store.dispatch.bind(store), subscribe: store.subscribe.bind(store), getState: store.getState.bind(store), // Module management registerModule: store.registerModule.bind(store), unregisterModule: store.unregisterModule.bind(store), // Utilities for dynamic state/action creation createState: store.createState.bind(store), createAction: store.createAction.bind(store), // Access to signal constructor for manual state creation signal: eleva.signal, }; // Call original setup if it exists const originalSetup = componentDef.setup; const result = originalSetup ? await originalSetup(ctx) : {}; return result; }, }; } else { wrappedChildren[selector] = childComponent; } } // Call original _mountComponents with wrapped children return await originalMountComponents.call( eleva, container, wrappedChildren, childInstances ); }; // Expose store instance and utilities on the Eleva instance eleva.store = store; /** * Expose utility methods on the Eleva instance * @namespace eleva.store */ eleva.createAction = (name, actionFn) => { store.actions[name] = actionFn; }; eleva.dispatch = (actionName, payload) => { return store.dispatch(actionName, payload); }; eleva.getState = () => { return store.getState(); }; eleva.subscribe = (callback) => { return store.subscribe(callback); }; // Store original methods for cleanup eleva._originalMount = originalMount; eleva._originalMountComponents = originalMountComponents; }, /** * Uninstalls the plugin from the Eleva instance * * @param {Object} eleva - The Eleva instance * * @description * Restores the original Eleva methods and removes all plugin-specific * functionality. This method should be called when the plugin is no * longer needed. * * @example * // Uninstall the plugin * StorePlugin.uninstall(app); */ uninstall(eleva) { // Restore original mount method if (eleva._originalMount) { eleva.mount = eleva._originalMount; delete eleva._originalMount; } // Restore original _mountComponents method if (eleva._originalMountComponents) { eleva._mountComponents = eleva._originalMountComponents; delete eleva._originalMountComponents; } // Remove store instance and utility methods if (eleva.store) { delete eleva.store; } if (eleva.createAction) { delete eleva.createAction; } if (eleva.dispatch) { delete eleva.dispatch; } if (eleva.getState) { delete eleva.getState; } if (eleva.subscribe) { delete eleva.subscribe; } }, };