zust
Version:
A powerful, lightweight, and fully standalone state management library for React with time-travel debugging, computed values, and zero dependencies
1,005 lines (994 loc) • 32.7 kB
JavaScript
;
var react = require('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();
}
}
exports.StorageType = void 0;
(function (StorageType) {
StorageType["LOCAL"] = "local";
StorageType["SESSION"] = "session";
StorageType["CUSTOM"] = "custom";
})(exports.StorageType || (exports.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 === exports.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 exports.StorageType.LOCAL:
return hasLocalStorage ? localStorage : {
getItem: () => null,
setItem: () => { },
removeItem: () => { },
};
case exports.StorageType.SESSION:
return hasSessionStorage ? sessionStorage : {
getItem: () => null,
setItem: () => { },
removeItem: () => { },
};
default:
return hasLocalStorage ? localStorage : {
getItem: () => null,
setItem: () => { },
removeItem: () => { },
};
}
}
function createPersister(storageName, options = {}) {
const { storageType = exports.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 = react.useSyncExternalStore(engine.subscribe, engine.getState, engine.getState);
return {
...state,
...enhancedStore,
};
};
const useSelectors = (...selectors) => {
const cache = react.useRef({ state: null, snapshot: null });
const selector = react.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 react.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,
};
}
exports.LogLevel = void 0;
(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";
})(exports.LogLevel || (exports.LogLevel = {}));
exports.batch = batch;
exports.createPersistConfig = createPersistConfig;
exports.createStore = createStore;
//# sourceMappingURL=index.cjs.js.map