UNPKG

zust

Version:

A powerful, lightweight, and fully standalone state management library for React with time-travel debugging, computed values, and zero dependencies

1,001 lines (991 loc) 32.5 kB
import { useRef, useCallback, useSyncExternalStore } from 'react'; const batchQueue = { updates: [], pending: false}; function flushBatchQueue() { const updates = [...batchQueue.updates]; batchQueue.updates = []; batchQueue.pending = false; updates.forEach((update) => update()); } function batch(callback) { const wasBatching = batchQueue.pending; batchQueue.pending = true; try { callback(); } finally { if (!wasBatching) { flushBatchQueue(); } } } function getPathValue(obj, path) { if (!path) return obj; const parts = path.split("."); let current = obj; for (const part of parts) { if (current === null || current === undefined) { return undefined; } if (Array.isArray(current)) { const index = parseInt(part, 10); if (isNaN(index) || index < 0 || index >= current.length) { return undefined; } current = current[index]; } else if (typeof current === "object") { current = current[part]; } else { return undefined; } } return current; } function createStoreEngine(initialState) { let state = initialState; const listeners = new Set(); const pathListeners = new Map(); const getState = () => state; const notifyListeners = (prevState, newState) => { listeners.forEach((listener) => { try { listener(newState, prevState); } catch (error) { console.error("[Zust] Error in listener:", error); } }); pathListeners.forEach((callbacks, path) => { const oldValue = getPathValue(prevState, path); const newValue = getPathValue(newState, path); if (!Object.is(oldValue, newValue)) { callbacks.forEach((callback) => { try { callback(newValue, oldValue, newState); } catch (error) { console.error(`[Zust] Error in path listener for "${path}":`, error); } }); } }); }; const setState = (partial, replace = false) => { const prevState = state; const nextPartial = typeof partial === "function" ? partial(state) : partial; const nextState = replace ? nextPartial : { ...state, ...nextPartial }; if (!Object.is(prevState, nextState)) { state = nextState; if (batchQueue.pending) { batchQueue.updates.push(() => notifyListeners(prevState, nextState)); } else { notifyListeners(prevState, nextState); } } }; const subscribe = (listener) => { if (typeof listener !== "function") { throw new Error("[Zust] Listener must be a function"); } listeners.add(listener); return () => { listeners.delete(listener); }; }; const subscribePath = (path, callback) => { if (!path || typeof path !== "string") { throw new Error("[Zust] Path must be a non-empty string"); } if (typeof callback !== "function") { throw new Error("[Zust] Callback must be a function"); } if (!pathListeners.has(path)) { pathListeners.set(path, new Set()); } const callbacks = pathListeners.get(path); if (!callbacks) { throw new Error("[Zust] Failed to get path listeners"); } callbacks.add(callback); return () => { callbacks.delete(callback); if (callbacks.size === 0) { pathListeners.delete(path); } }; }; const destroy = () => { listeners.clear(); pathListeners.clear(); }; return { getState, setState, subscribe, subscribePath, destroy, }; } const DANGEROUS_PROPS = new Set(["__proto__", "constructor", "prototype"]); const pathCache = new Map(); const MAX_CACHE_SIZE = 1000; function validatePathSegment(segment) { if (!segment || typeof segment !== "string") { throw new Error("[Zust] Invalid path segment: must be a non-empty string"); } if (DANGEROUS_PROPS.has(segment)) { throw new Error(`[Zust] Forbidden path segment: "${segment}"`); } } function parsePath(path) { if (!path || typeof path !== "string") { throw new Error("[Zust] Path must be a non-empty string"); } const cached = pathCache.get(path); if (cached) { return [...cached]; } const parts = path.split("."); parts.forEach(validatePathSegment); if (pathCache.size >= MAX_CACHE_SIZE) { const firstKey = pathCache.keys().next().value; if (firstKey) { pathCache.delete(firstKey); } } pathCache.set(path, parts); return parts; } function getNestedValue(obj, path) { if (!path) return obj; const parts = parsePath(path); let current = obj; for (const part of parts) { if (current === null || current === undefined) { return undefined; } if (Array.isArray(current)) { const index = parseInt(part, 10); if (isNaN(index)) { throw new Error(`[Zust] Invalid array index "${part}" in path "${path}". Expected a number.`); } if (index < 0 || index >= current.length) { return undefined; } current = current[index]; } else if (typeof current === "object") { current = current[part]; } else { return undefined; } } return current; } function setNestedValue(obj, path, value) { if (!path) { throw new Error("[Zust] Cannot set empty path"); } const parts = parsePath(path); if (parts.length === 1) { const key = parts[0]; if (!key) { throw new Error("[Zust] Invalid path segment"); } obj[key] = value; return; } const lastPart = parts.pop(); if (!lastPart) { throw new Error("[Zust] Invalid path: cannot extract last part"); } let current = obj; for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (!part) { throw new Error("[Zust] Invalid path segment"); } const nextPart = parts[i + 1]; if (Array.isArray(current)) { const index = parseInt(part, 10); if (isNaN(index)) { throw new Error(`[Zust] Invalid array index "${part}" in path "${path}". Expected a number.`); } while (current.length <= index) { current.push(undefined); } if (current[index] === null || current[index] === undefined) { const nextIndex = nextPart !== undefined ? parseInt(nextPart, 10) : NaN; current[index] = isNaN(nextIndex) ? {} : []; } current = current[index]; } else { if (!(part in current) || current[part] === null || current[part] === undefined) { const nextIndex = nextPart !== undefined ? parseInt(nextPart, 10) : NaN; current[part] = isNaN(nextIndex) ? {} : []; } current = current[part]; } } if (Array.isArray(current)) { const index = parseInt(lastPart, 10); if (isNaN(index)) { throw new Error(`[Zust] Invalid array index "${lastPart}" in path "${path}". Expected a number.`); } while (current.length <= index) { current.push(undefined); } current[index] = value; } else { current[lastPart] = value; } } function deleteNestedValue(obj, path) { if (!path) { return false; } const parts = parsePath(path); const lastPart = parts.pop(); if (!lastPart) { return false; } let current = obj; for (const part of parts) { if (current === null || current === undefined) { return false; } if (Array.isArray(current)) { const index = parseInt(part, 10); if (isNaN(index) || index < 0 || index >= current.length) { return false; } current = current[index]; } else if (typeof current === "object") { if (!(part in current)) { return false; } current = current[part]; } else { return false; } } if (Array.isArray(current)) { const index = parseInt(lastPart, 10); if (isNaN(index) || index < 0 || index >= current.length) { return false; } current.splice(index, 1); return true; } else if (typeof current === "object" && current !== null) { return delete current[lastPart]; } return false; } function hasPath(obj, path) { const value = getNestedValue(obj, path); return value !== undefined; } function getLastPart(path) { const parts = parsePath(path); const lastPart = parts[parts.length - 1]; if (!lastPart) { throw new Error("[Zust] Invalid path: cannot extract last part"); } return lastPart; } class HistoryManager { onRestore; past = []; future = []; currentState; config; lastCaptureTime = 0; pendingCapture = null; isRestoring = false; constructor(initialState, onRestore, config = {}) { this.onRestore = onRestore; this.currentState = initialState; this.config = { enabled: config.enabled ?? false, maxSize: config.maxSize ?? 50, captureInterval: config.captureInterval ?? 100, }; } capture(state) { if (!this.config.enabled || this.isRestoring) { return; } const now = Date.now(); if (now - this.lastCaptureTime < this.config.captureInterval) { if (this.pendingCapture) { clearTimeout(this.pendingCapture); } this.pendingCapture = setTimeout(() => { this.captureImmediate(state); this.pendingCapture = null; }, this.config.captureInterval); return; } this.captureImmediate(state); } captureImmediate(state) { try { const snapshot = { state: JSON.parse(JSON.stringify(state)), timestamp: Date.now(), }; this.past.push({ state: this.currentState, timestamp: this.lastCaptureTime, }); if (this.past.length > this.config.maxSize) { this.past.shift(); } this.future = []; this.currentState = snapshot.state; this.lastCaptureTime = snapshot.timestamp; } catch (error) { console.warn("[Zust History] Failed to capture state:", error); } } undo() { if (!this.canUndo()) { console.warn("[Zust History] Cannot undo: no history available"); return; } const snapshot = this.past.pop(); if (!snapshot) { return; } this.future.unshift({ state: this.currentState, timestamp: Date.now(), }); this.restoreState(snapshot.state); } redo() { if (!this.canRedo()) { console.warn("[Zust History] Cannot redo: no future available"); return; } const snapshot = this.future.shift(); if (!snapshot) { return; } this.past.push({ state: this.currentState, timestamp: Date.now(), }); this.restoreState(snapshot.state); } jump(steps) { if (steps === 0) return; if (steps < 0) { const actualSteps = Math.min(Math.abs(steps), this.past.length); for (let i = 0; i < actualSteps; i++) { this.undo(); } } else { const actualSteps = Math.min(steps, this.future.length); for (let i = 0; i < actualSteps; i++) { this.redo(); } } } restoreState(state) { this.isRestoring = true; try { this.currentState = state; this.onRestore(state); } finally { this.isRestoring = false; } } clear() { this.past = []; this.future = []; } size() { return this.past.length + this.future.length; } canUndo() { return this.past.length > 0; } canRedo() { return this.future.length > 0; } getTimeline() { return [ ...this.past.map((s) => s.timestamp), Date.now(), ...this.future.map((s) => s.timestamp), ]; } getAPI() { return { undo: () => this.undo(), redo: () => this.redo(), jump: (steps) => this.jump(steps), clear: () => this.clear(), size: () => this.size(), canUndo: () => this.canUndo(), canRedo: () => this.canRedo(), getTimeline: () => this.getTimeline(), }; } destroy() { if (this.pendingCapture) { clearTimeout(this.pendingCapture); this.pendingCapture = null; } this.clear(); } } class ComputedEngine { getState; computedMap = new Map(); constructor(getState, computedValues = {}) { this.getState = getState; Object.entries(computedValues).forEach(([key, config]) => { this.addComputed(key, config); }); } addComputed(key, config) { const normalized = typeof config === "function" ? { compute: config, deps: [], cache: true } : { compute: config.compute, deps: config.deps ?? [], cache: config.cache ?? true, }; this.computedMap.set(key, { config: normalized, computeCount: 0, }); } get(key) { const entry = this.computedMap.get(key); if (!entry) { return undefined; } const state = this.getState(); if (entry.config.cache && this.shouldUseCache(entry, state)) { return entry.cachedValue; } try { const value = entry.config.compute(state); entry.computeCount++; if (entry.config.cache) { entry.cachedValue = value; entry.lastDeps = this.getDependencyValues(entry.config.deps, state); } return value; } catch (error) { console.error(`[Zust Computed] Error computing "${key}":`, error); return entry.cachedValue; } } shouldUseCache(entry, state) { if (entry.cachedValue === undefined || entry.lastDeps === undefined) { return false; } if (entry.config.deps.length === 0) { return false; } const currentDeps = this.getDependencyValues(entry.config.deps, state); if (currentDeps.length !== entry.lastDeps.length) { return false; } for (let i = 0; i < currentDeps.length; i++) { if (!Object.is(currentDeps[i], entry.lastDeps[i])) { return false; } } return true; } getDependencyValues(deps, state) { return deps.map((dep) => { try { return getNestedValue(state, dep); } catch (error) { console.warn(`[Zust Computed] Failed to get dependency "${dep}":`, error); return undefined; } }); } invalidate(key) { const entry = this.computedMap.get(key); if (entry) { delete entry.cachedValue; delete entry.lastDeps; } } invalidateAll() { this.computedMap.forEach((entry) => { delete entry.cachedValue; delete entry.lastDeps; }); } getStats() { const stats = {}; this.computedMap.forEach((entry, key) => { stats[key] = { computeCount: entry.computeCount, cached: entry.cachedValue !== undefined, }; }); return stats; } shouldInvalidate(changedPath) { const toInvalidate = []; this.computedMap.forEach((entry, key) => { for (const dep of entry.config.deps) { if (this.pathMatches(dep, changedPath)) { toInvalidate.push(key); break; } } }); return toInvalidate; } pathMatches(pattern, path) { if (pattern === path) return true; if (path.startsWith(pattern + ".")) return true; return false; } defineGetters(target) { this.computedMap.forEach((_, key) => { Object.defineProperty(target, key, { get: () => this.get(key), enumerable: true, configurable: true, }); }); } destroy() { this.computedMap.clear(); } } var StorageType; (function (StorageType) { StorageType["LOCAL"] = "local"; StorageType["SESSION"] = "session"; StorageType["CUSTOM"] = "custom"; })(StorageType || (StorageType = {})); function createPersistConfig(...paths) { if (paths.length === 0) { throw new Error("[Zust] At least one path must be specified for persistence"); } return paths.reduce((config, path) => { if (!path || typeof path !== "string") { throw new Error(`[Zust] Invalid path: ${path}`); } config[path] = true; return config; }, {}); } function getStorage(storageType, customStorage) { if (storageType === StorageType.CUSTOM) { if (!customStorage) { throw new Error("[Zust] Custom storage must be provided when using StorageType.CUSTOM"); } return customStorage; } const hasLocalStorage = typeof localStorage !== "undefined"; const hasSessionStorage = typeof sessionStorage !== "undefined"; if (!hasLocalStorage && !hasSessionStorage) { return { getItem: () => null, setItem: () => { }, removeItem: () => { }, }; } switch (storageType) { case StorageType.LOCAL: return hasLocalStorage ? localStorage : { getItem: () => null, setItem: () => { }, removeItem: () => { }, }; case StorageType.SESSION: return hasSessionStorage ? sessionStorage : { getItem: () => null, setItem: () => { }, removeItem: () => { }, }; default: return hasLocalStorage ? localStorage : { getItem: () => null, setItem: () => { }, removeItem: () => { }, }; } } function createPersister(storageName, options = {}) { const { storageType = StorageType.LOCAL, customStorage, onError = (error) => console.error("[Zust Persist]", error), } = options; const storage = getStorage(storageType, customStorage); const save = async (state, config) => { try { let fieldsToSave; if (typeof config === "object") { fieldsToSave = Object.keys(config).filter(key => config[key]); } else if (config === true) { fieldsToSave = Object.keys(state).filter(key => typeof state[key] !== 'function'); } else { return; } for (const field of fieldsToSave) { const key = `${storageName}-${field}`; const value = state[field]; const serialized = JSON.stringify(value); await storage.setItem(key, serialized); } } catch (error) { onError(error); throw error; } }; const load = async () => { try { const result = {}; let hasAnyData = false; const prefix = `${storageName}-`; if ('length' in storage && 'key' in storage) { const storageWithKeys = storage; for (let i = 0; i < storageWithKeys.length; i++) { const key = storageWithKeys.key(i); if (key?.startsWith(prefix)) { const fieldName = key.substring(prefix.length); const value = await storage.getItem(key); if (value !== null) { try { result[fieldName] = JSON.parse(value); hasAnyData = true; } catch (parseError) { onError(parseError); } } } } } return hasAnyData ? result : null; } catch (error) { onError(error); throw error; } }; const clear = async () => { try { const prefix = `${storageName}-`; const keysToRemove = []; if ('length' in storage && 'key' in storage) { const storageWithKeys = storage; for (let i = 0; i < storageWithKeys.length; i++) { const key = storageWithKeys.key(i); if (key?.startsWith(prefix)) { keysToRemove.push(key); } } } for (const key of keysToRemove) { await storage.removeItem(key); } } catch (error) { onError(error); } }; return { save, load, clear }; } function shallowEqual(a, b) { if (Object.is(a, b)) return true; if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) { return false; } 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) || !Object.is(a[key], b[key])) { return false; } } return true; } function combineMiddlewares(middlewares) { if (!Array.isArray(middlewares) || middlewares.length === 0) { return (next) => next; } return middlewares.reduce((composed, current) => (next) => composed(current(next)), (next) => next); } function createStore$1(initialState, options = {}) { if (!initialState || typeof initialState !== "object" || Array.isArray(initialState)) { throw new Error("[Zust] Initial state must be a non-null object"); } const { middleware = [], computed = {}, plugins = [], history: historyConfig, persist, prefix = "zust", } = options; const persister = persist ? createPersister(prefix, {}) : null; let persistTimer = null; const PERSIST_DEBOUNCE_MS = 100; const engine = createStoreEngine(initialState); if (persister) { persister.load().then((persistedState) => { if (persistedState && Object.keys(persistedState).length > 0) { const currentState = engine.getState(); const hydratedState = { ...currentState, ...persistedState }; engine.setState(hydratedState, true); } }).catch((error) => { console.error("[Zust] Failed to load persisted state:", error); }); } const computedEngine = new ComputedEngine(engine.getState, computed); const historyManager = historyConfig?.enabled ? new HistoryManager(initialState, (state) => engine.setState(state, true), historyConfig) : null; const finalMiddleware = combineMiddlewares(middleware); const setDeep = (path, action) => { try { const currentState = engine.getState(); const newState = (typeof structuredClone !== "undefined" ? structuredClone(currentState) : JSON.parse(JSON.stringify(currentState))); const currentValue = getNestedValue(currentState, path); const value = typeof action === "function" ? action(currentValue) : action; setNestedValue(newState, path, value); const finalState = finalMiddleware((s) => s)(newState); engine.setState(finalState, true); if (persister) { if (persistTimer) { clearTimeout(persistTimer); } persistTimer = setTimeout(() => { persister.save(finalState, persist).catch((error) => { console.error("[Zust] Failed to persist state:", error); }); }, PERSIST_DEBOUNCE_MS); } if (historyManager) { historyManager.capture(finalState); } const toInvalidate = computedEngine.shouldInvalidate(path); toInvalidate.forEach((key) => computedEngine.invalidate(key)); } catch (error) { console.error(`[Zust] Failed to set deep value at path "${path}":`, error); throw error; } }; const createEnhancedStore = () => { const state = engine.getState(); const enhancedStore = { ...state, setDeep, dispatch: async (action) => { try { const currentState = engine.getState(); await action(currentState, setDeep); } catch (error) { console.error("[Zust] Error dispatching action:", error); throw error; } }, subscribe: engine.subscribe, subscribePath: engine.subscribePath, deleteDeep: (path) => { const newState = { ...engine.getState() }; const deleted = deleteNestedValue(newState, path); if (deleted) { engine.setState(newState); } return deleted; }, hasPath: (path) => hasPath(engine.getState(), path), }; computedEngine.defineGetters(enhancedStore); if (historyManager) { enhancedStore.history = historyManager.getAPI(); } return enhancedStore; }; const enhancedStore = createEnhancedStore(); plugins.forEach((plugin) => { try { if (plugin.onInit) { plugin.onInit(enhancedStore); } if (plugin.middleware) { middleware.push(plugin.middleware); } } catch (error) { console.error("[Zust] Error initializing plugin:", error); } }); const useStore = () => { const state = useSyncExternalStore(engine.subscribe, engine.getState, engine.getState); return { ...state, ...enhancedStore, }; }; const useSelectors = (...selectors) => { const cache = useRef({ state: null, snapshot: null }); const selector = useCallback((state) => { if (cache.current.state === state && cache.current.snapshot) { return cache.current.snapshot; } const result = {}; for (const path of selectors) { try { const [fullPath, alias] = path.split(":"); const key = alias ?? getLastPart(fullPath); result[key] = getNestedValue(state, fullPath); } catch (error) { console.error(`[Zust] Error selecting path "${path}":`, error); result[path] = undefined; } } if (cache.current.snapshot && shallowEqual(cache.current.snapshot, result)) { return cache.current.snapshot; } cache.current = { state, snapshot: result }; return result; }, [selectors.join(",")]); return useSyncExternalStore(engine.subscribe, () => selector(engine.getState()), () => selector(engine.getState())); }; const destroy = () => { if (persistTimer) { clearTimeout(persistTimer); } engine.destroy(); computedEngine.destroy(); if (historyManager) { historyManager.destroy(); } }; let cachedEnhancedStore = null; let lastRawState = null; const getStateWithComputed = () => { const state = engine.getState(); if (cachedEnhancedStore && lastRawState === state) { return cachedEnhancedStore; } const enhanced = { ...state, setDeep, dispatch: async (action) => { try { const currentState = engine.getState(); await action(currentState, setDeep); } catch (error) { console.error("[Zust] Error dispatching action:", error); throw error; } }, subscribe: engine.subscribe, subscribePath: engine.subscribePath, deleteDeep: (path) => { const newState = typeof structuredClone !== "undefined" ? structuredClone(engine.getState()) : JSON.parse(JSON.stringify(engine.getState())); const deleted = deleteNestedValue(newState, path); if (deleted) { engine.setState(newState, true); } return deleted; }, hasPath: (path) => hasPath(engine.getState(), path), }; computedEngine.defineGetters(enhanced); if (historyManager) { enhanced.history = historyManager.getAPI(); } cachedEnhancedStore = enhanced; lastRawState = state; return enhanced; }; return { useStore, useSelectors, getState: getStateWithComputed, setState: engine.setState, setDeep, subscribe: engine.subscribe, subscribePath: engine.subscribePath, destroy, history: historyManager?.getAPI(), }; } function convertOptions(options) { return { persist: options.persist, logging: options.logging, middleware: options.middleware, computed: options.computedValues, plugins: options.plugins, prefix: options.prefix, history: options.history, }; } function createStore(initialState, options = {}) { const engineOptions = convertOptions(options); const engine = createStore$1(initialState, engineOptions); return { useStore: engine.useStore, useSelectors: engine.useSelectors, getState: engine.getState, setState: engine.setState, setDeep: engine.setDeep, subscribe: engine.subscribe, subscribePath: engine.subscribePath, destroy: engine.destroy, history: engine.history, }; } var LogLevel; (function (LogLevel) { LogLevel[LogLevel["NONE"] = 0] = "NONE"; LogLevel[LogLevel["ERROR"] = 1] = "ERROR"; LogLevel[LogLevel["WARN"] = 2] = "WARN"; LogLevel[LogLevel["INFO"] = 3] = "INFO"; LogLevel[LogLevel["DEBUG"] = 4] = "DEBUG"; })(LogLevel || (LogLevel = {})); export { LogLevel, StorageType, batch, createPersistConfig, createStore }; //# sourceMappingURL=index.esm.js.map