@arnelirobles/rnxjs
Version:
Minimalist Vanilla JS component system with reactive data binding.
246 lines (212 loc) • 8.23 kB
JavaScript
/**
* Creates a reactive state object using ES6 Proxy
* Automatically notifies subscribers when state changes
* @param {Object} initialState - Initial state object
* @returns {Proxy} - Reactive state proxy with subscribe/unsubscribe methods
*/
export function createReactiveState(initialState = {}) {
// Input validation
if (typeof initialState !== 'object' || initialState === null) {
throw new TypeError('[rnxJS] createReactiveState: initialState must be an object');
}
const subscribers = new Map();
const proxyCache = new WeakMap(); // Cache proxies to avoid recreating them
const visitedObjects = new WeakSet(); // Prevent circular reference infinite loops
const unsubscribeFunctions = new Set(); // Track all unsubscribe functions for cleanup
/**
* Subscribe to changes on a specific property path
* @param {string} path - Dot-notation path (e.g., 'user.email')
* @param {Function} callback - Called with new value when path changes
* @returns {Function} - Unsubscribe function
*/
function subscribe(path, callback) {
if (typeof path !== 'string' || !path) {
console.warn('[rnxJS] subscribe: path must be a non-empty string');
return () => { };
}
if (typeof callback !== 'function') {
console.warn('[rnxJS] subscribe: callback must be a function');
return () => { };
}
if (!subscribers.has(path)) {
subscribers.set(path, new Set());
}
subscribers.get(path).add(callback);
// Return unsubscribe function
const unsubscribe = () => {
const pathSubscribers = subscribers.get(path);
if (pathSubscribers) {
pathSubscribers.delete(callback);
if (pathSubscribers.size === 0) {
subscribers.delete(path);
}
}
unsubscribeFunctions.delete(unsubscribe);
};
unsubscribeFunctions.add(unsubscribe);
return unsubscribe;
}
/**
* Unsubscribe all listeners
*/
function unsubscribeAll() {
subscribers.clear();
unsubscribeFunctions.clear();
}
/**
* Destroy the reactive state and cleanup all resources
*/
function destroy() {
unsubscribeAll();
proxyCache.clear?.(); // Clear cache if supported
}
/**
* Notify all subscribers for a given path
* @param {string} path - Property path that changed
* @param {*} value - New value
*/
function notify(path, value) {
try {
// Notify exact path subscribers
if (subscribers.has(path)) {
subscribers.get(path).forEach(callback => {
try {
callback(value);
} catch (error) {
console.error(`[rnxJS] Error in subscriber for path "${path}":`, error);
}
});
}
// Notify parent path subscribers (e.g., 'user' when 'user.email' changes)
const parts = path.split('.');
for (let i = parts.length - 1; i > 0; i--) {
const parentPath = parts.slice(0, i).join('.');
if (subscribers.has(parentPath)) {
const parentValue = getNestedValue(state, parentPath);
subscribers.get(parentPath).forEach(callback => {
try {
callback(parentValue);
} catch (error) {
console.error(`[rnxJS] Error in subscriber for path "${parentPath}":`, error);
}
});
}
}
} catch (error) {
console.error(`[rnxJS] Error notifying subscribers for path "${path}":`, error);
}
}
/**
* Get nested property value from object
* @param {Object} obj - Source object
* @param {string} path - Dot-notation path
* @returns {*} - Property value or undefined
*/
function getNestedValue(obj, path) {
try {
return path.split('.').reduce((current, key) => current?.[key], obj);
} catch (error) {
console.error(`[rnxJS] Error getting nested value for path "${path}":`, error);
return undefined;
}
}
/**
* Array methods that mutate the array - need to trigger reactivity
*/
const arrayMutatorMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
/**
* Create reactive proxy for nested objects and arrays
* @param {Object} target - Object to make reactive
* @param {string} basePath - Current path prefix
* @returns {Proxy} - Reactive proxy
*/
function createReactiveProxy(target, basePath = '') {
// Handle non-object values
if (typeof target !== 'object' || target === null) {
return target;
}
// Check if we've already created a proxy for this object (performance optimization)
if (proxyCache.has(target)) {
return proxyCache.get(target);
}
// Prevent circular reference infinite loops
if (visitedObjects.has(target)) {
console.warn(`[rnxJS] Circular reference detected at path "${basePath}". Skipping proxy creation.`);
return target;
}
visitedObjects.add(target);
// Special handling for arrays
const isArray = Array.isArray(target);
// Recursively wrap nested objects
const handler = {
get(obj, prop) {
const value = obj[prop];
// Skip for Symbols and built-in properties
if (typeof prop !== 'string') {
return value;
}
const currentPath = basePath ? `${basePath}.${prop}` : prop;
// Wrap array mutator methods to trigger reactivity
if (isArray && arrayMutatorMethods.includes(prop)) {
return function (...args) {
const result = Array.prototype[prop].apply(obj, args);
// Notify subscribers that the array changed
notify(basePath, obj);
return result;
};
}
// Return nested proxy for objects and arrays
if (typeof value === 'object' && value !== null) {
return createReactiveProxy(value, currentPath);
}
return value;
},
set(obj, prop, value) {
// Skip for Symbols
if (typeof prop !== 'string') {
obj[prop] = value;
return true;
}
const oldValue = obj[prop];
const currentPath = basePath ? `${basePath}.${prop}` : prop;
// Only update and notify if value actually changed
if (oldValue !== value) {
obj[prop] = value;
notify(currentPath, value);
}
return true;
}
};
const proxy = new Proxy(target, handler);
proxyCache.set(target, proxy);
return proxy;
}
// Create the reactive state
const state = createReactiveProxy(initialState);
// Attach utility methods to the state object
Object.defineProperty(state, 'subscribe', {
value: subscribe,
enumerable: false,
writable: false,
configurable: false
});
Object.defineProperty(state, 'getNestedValue', {
value: getNestedValue,
enumerable: false,
writable: false,
configurable: false
});
Object.defineProperty(state, '$unsubscribeAll', {
value: unsubscribeAll,
enumerable: false,
writable: false,
configurable: false
});
Object.defineProperty(state, '$destroy', {
value: destroy,
enumerable: false,
writable: false,
configurable: false
});
return state;
}