UNPKG

@resin-js/core

Version:

A lightweight reactive library for enhancing native web development. Resin provides reactivity, and aims to remove common pain points found in vanilla JavaScript web development.

572 lines (569 loc) 18 kB
let effectStack = []; let batchDepth = 0; let DEBUG = false; const pendingEffects = new Set(); function resin(initialValue, options) { let value = initialValue; if (options?.persist) { const persist = options.persist; const stored = localStorage.getItem(persist.key); if (stored) { try { value = persist.deserialize ? persist.deserialize(stored) : JSON.parse(stored); } catch (err) { console.warn(`Failed to restore value for key "${persist.key}":`, err); } } } let loading = false; let error = null; let disposed = false; const subscribers = new Set(); const eventHandlers = new Map(); const validateValue = (newValue) => { if (!options?.validate) return { valid: true }; const result = options.validate(newValue); return typeof result === 'boolean' ? { valid: result } : result; }; const notify = (oldValue) => { if (disposed) { return; } if (batchDepth > 0) { subscribers.forEach((effect) => pendingEffects.add(effect)); } else { subscribers.forEach((effect) => effect()); } emitEvent('change', { value, oldValue }); const persist = options?.persist; if (persist) { try { const serialized = persist.serialize ? persist.serialize(value) : JSON.stringify(value); localStorage.setItem(persist.key, serialized); } catch (err) { console.warn(`Failed to persist value for key "${persist.key}":`, err); } } }; const transformValue = (newValue) => { if (!options?.transform) { return newValue; } return options.transform.reduce((val, fn) => fn(val), newValue); }; const emitEvent = (type, event) => { eventHandlers.get(type)?.forEach(handler => { try { handler(event); } catch (err) { console.error(`Error in ${type} handler:`, err); error = err; emitEvent('error', { error }); } }); }; queueMicrotask(() => { emitEvent('init', { value }); }); const resinInstance = { get value() { if (disposed) { throw new Error('Attempted to access disposed resin'); } const currentEffect = effectStack[effectStack.length - 1]; if (currentEffect) { subscribers.add(currentEffect); } return value; }, set value(newValue) { if (disposed) { throw new Error('Attempted to modify disposed resin'); } try { if (DEBUG) ; if (value === newValue) { return; } const transformed = transformValue(newValue); const validation = validateValue(newValue); if (!validation.valid) { const error = new Error(validation.error || 'Validation failed'); emitEvent('error', { error }); return; } value = newValue; notify(transformed); } catch (err) { error = createResinError(`Error in Resin${options?.debugName ? ` "${options.debugName}"` : ''}`, options?.debugName, err); emitEvent('error', { error }); } }, get loading() { return loading; }, get error() { return error; }, on(event, handler) { if (!eventHandlers.has(event)) { eventHandlers.set(event, new Set()); } eventHandlers.get(event).add(handler); return () => { eventHandlers.get(event)?.delete(handler); }; }, dispose() { if (disposed) { return; } disposed = true; emitEvent('dispose', { value }); subscribers.clear(); eventHandlers.clear(); }, _subscribers: subscribers, }; if (Array.isArray(initialValue)) { return enhanceArray(resinInstance); } else if (initialValue instanceof Map) { return enhanceMap(resinInstance); } return resinInstance; } /** * Groups multiple updates together. * Executes updates within the batch in a single cycle. * * @example * batch(() => { * resinInstance1.value = 1; * resinInstance2.value = 2; * }); */ function batch(fn) { batchDepth++; try { fn(); } finally { batchDepth--; if (batchDepth === 0) { pendingEffects.forEach(effect => effect()); pendingEffects.clear(); } } } /** * Creates a computed `Resin` instance that derives its value from a function. * Automatically updates when dependent `Resin` instances change. * * @example * const count = resin(2); * const doubleCount = computed(() => count.value * 2); */ function computed(fn) { const computedResin = resin(fn()); watchEffect(() => { computedResin.value = fn(); }); return computedResin; } /** * Creates a derived `Resin` instance based on multiple source `Resin` values. * Automatically recomputes when any source value changes. * * @example * const firstName = resin('John'); * const lastName = resin('Doe'); * const fullName = derive({ * from: [firstName, lastName], * compute: (first, last) => `${first} ${last}` * }); */ function derive(options) { const { from, compute } = options; const derivedResin = resin(compute(...from.map(dep => dep.value))); const effect = () => { try { derivedResin.value = compute(...from.map(dep => dep.value)); } catch (error) { console.error('Error in derived computation:', error); } }; watchEffect(effect); return { ...derivedResin, dispose() { derivedResin.dispose(); from.forEach(dep => dep._subscribers.delete(effect)); } }; } /** * Creates a reactive view of a nested property from a Resin state object. * Returns a derived Resin that updates when the source property changes. * * @param {Resin<T>} state The source Resin object * @param {P} path Dot-notation path to the nested property (e.g., 'user.profile.name') * @returns {Resin<NestedValue<T, P>>} A reactive Resin containing the value at the specified path * * @example * const user = resin({ profile: { name: 'John' } }); * const name = select(user, 'profile.name'); * console.log(name.value); // 'John' * * // When the original object changes, the selected property updates * user.value.profile.name = 'Jane'; * console.log(name.value); // 'Jane' */ function select(state, path) { const cache = new WeakMap(); if (!cache.has(state)) { cache.set(state, new Map()); } const pathCache = cache.get(state); if (pathCache.has(path)) { return pathCache.get(path); } const selected = computed(() => getValueByPath(state.value, path)); pathCache.set(path, selected); return selected; } /** * Watches a function for changes in dependent `Resin` instances, rerunning it reactively. * * @example * watchEffect(() => { * console.log(resinInstance.value); * }); */ function watchEffect(effect) { effectStack.push(effect); effect(); effectStack.pop(); } /** * Registers an effect to run independent of binding. * * @example * // * const count = resin<number>(0); * subscribe(count, (value: number) => { * if (number % 2 === 0) { * console.log('even'); * } else { * console.log('odd'); * } * }); */ function subscribe(effect) { watchEffect(effect); } function createResinError(message, resinName, value) { const error = new Error(message); error.resinName = resinName; error.value = value; return error; } function enhanceArray(baseResin) { const array = baseResin.value; const handler = { get(target, prop) { const value = target[prop]; if (typeof value === 'function') { return function (...args) { const result = Array.prototype[prop] .apply(target, args); const mutatingMethods = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ]; if (mutatingMethods.includes(prop)) { baseResin.value = [...target]; } return result; }; } return value; }, set(target, prop, value) { if (typeof prop === 'number' || prop === 'length') { target[prop] = value; baseResin.value = [...target]; } return true; } }; const enhanced = new Proxy(array, handler); enhanced.rFind = (predicate) => { return computed(() => baseResin.value.find(predicate)); }; enhanced.rFilter = (predicate) => { return computed(() => baseResin.value.filter(predicate)); }; enhanced.rMap = (transform) => { return computed(() => baseResin.value.map(transform)); }; enhanced.rSort = (compareFn) => { return computed(() => [...baseResin.value].sort(compareFn)); }; enhanced.rSlice = (start, end) => { return computed(() => baseResin.value.slice(start, end)); }; return Object.assign(baseResin, enhanced); } function enhanceMap(baseResin) { const map = baseResin.value; const handler = { get(target, prop) { const value = Reflect.get(target, prop); if (typeof value === 'function') { return function (...args) { const method = Map.prototype[prop]; const result = method.apply(this, args); const mutatingMethods = new Set([ 'set', 'delete', 'clear' ]); if (mutatingMethods.has(prop.toString())) { baseResin.value = new Map(target); } return result; }.bind(target); } return value; } }; const enhanced = new Proxy(map, handler); const methods = { rGet(key) { return computed(() => baseResin.value.get(key)); }, rEntries() { return computed(() => Array.from(baseResin.value.entries())); }, rKeys() { return computed(() => Array.from(baseResin.value.keys())); }, rValues() { return computed(() => Array.from(baseResin.value.values())); } }; return Object.assign(baseResin, enhanced, methods); } function getValueByPath(obj, path) { if (!obj) return undefined; const segments = path.split('.'); let current = obj; for (const key of segments) { if (Array.isArray(current) && /^\d+$/.test(key)) { const index = parseInt(key, 10); current = current[index]; } else { current = current[key]; } if (current === undefined || current === null) { return undefined; } } return current; } /** * Binds a Resin state to an HTML element with full binding options. * This is the core binding function that provides complete control over how state * is reflected in the DOM. For common use cases, consider using the specialized * binding functions like `bindInnerText`, `bindVisibility`, etc. * * @example * // Basic binding * const counter = resin(0); * bind(counterElement, counter, { bindInnerText: true }); * * @example * // Complex binding with multiple options * const formState = resin({ * value: '', * valid: false, * touched: false, * submitting: false * }); * * bind(formElement, formState, { * // Show element only when not submitting * if: state => !state.submitting, * * // Transform the displayed value * map: state => state.value.toUpperCase(), * * // Conditionally apply CSS classes * class: { * 'is-valid': state => state.valid && state.touched, * 'is-invalid': state => !state.valid && state.touched, * 'is-pristine': state => !state.touched * }, * * // Set attributes based on state * attr: { * 'aria-invalid': state => !state.valid, * 'disabled': state => state.submitting, * 'data-state': state => state.touched ? 'touched' : 'untouched' * } * }); * * @example * // Binding with automatic cleanup * const { dispose } = bind(element, state, { bindInnerText: true }); * * // Later when no longer needed * dispose(); * * @example * // The binding is automatically cleaned up if the element is removed from the DOM * document.body.removeChild(element); // Binding is disposed automatically */ function bind(element, resin, options = {}) { const config = { ...options, bindInnerText: options.bindInnerText !== undefined ? options.bindInnerText : false }; const originalDisplay = element.style.display; const effect = () => { const value = resin.value; if (config.if) { const shouldShow = config.if(value); element.style.display = shouldShow ? originalDisplay : 'none'; if (!shouldShow) { return; } } const displayValue = options.map ? options.map(value) : value; if (config.bindInnerText) { if (element instanceof HTMLInputElement) { element.value = String(displayValue); } else { element.textContent = String(displayValue); } } if (config.class) { Object.entries(config.class).forEach(([className, predicate]) => { if (predicate(value)) { element.classList.add(className); } else { element.classList.remove(className); } }); } if (config.attr) { Object.entries(config.attr).forEach(([attr, getter]) => { const attrValue = getter(value); if (attrValue === false) { element.removeAttribute(attr); } else if (attrValue === true) { element.setAttribute(attr, ''); } else { element.setAttribute(attr, String(attrValue)); } }); } }; watchEffect(effect); const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.removedNodes.forEach((node) => { if (node === element) { resin._subscribers.delete(effect); observer.disconnect(); } }); }); }); observer.observe(element.parentElement, { childList: true }); return { dispose() { resin._subscribers.delete(effect); observer.disconnect(); }, }; } /** * Binds an element's visibility to a Resin value using a predicate function. * When the predicate returns false, the element is hidden using display: none. * * @example * const isVisible = resin(true); * bindVisibility(myElement, isVisible); * * // With custom predicate * const count = resin(0); * bindVisibility(myElement, count, value => value > 10); */ function bindVisibility(element, resin, callback = (value) => !!value) { return bind(element, resin, { if: callback }); } /** * Binds an element's text content (or value for input elements) to a Resin value. * The element's content will automatically update when the Resin value changes. * * @example * const message = resin('Hello World'); * bindInnerText(document.querySelector('.greeting'), message); * * // Later when the value changes, the DOM updates automatically * message.value = 'Welcome!'; */ function bindInnerText(element, resin) { return bind(element, resin, { bindInnerText: true }); } /** * Binds CSS classes to an element based on conditions derived from a Resin value. * Classes are added when their predicates return true and removed when they return false. * * @example * const formState = resin({ valid: false, active: true }); * bindClass(formElement, formState, { * 'is-valid': value => value.valid, * 'is-invalid': value => !value.valid, * 'is-active': value => value.active * }); */ function bindClass(element, resin, conditionalClasses) { return bind(element, resin, { class: conditionalClasses }); } /** * Binds HTML attributes to an element based on values derived from a Resin state. * Attributes are set, updated, or removed based on the return value of their getter functions. * * @example * const buttonState = resin({ enabled: false, expanded: true }); * bindAttr(buttonElement, buttonState, { * 'disabled': value => !value.enabled, * 'aria-expanded': value => value.expanded, * 'title': value => value.enabled ? 'Click me' : 'Currently disabled' * }); */ function bindAttr(element, resin, attrConfig) { return bind(element, resin, { attr: attrConfig }); } export { batch, bind, bindAttr, bindClass, bindInnerText, bindVisibility, computed, derive, resin, select, subscribe, watchEffect }; //# sourceMappingURL=index.mjs.map