@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.
585 lines (581 loc) • 18.3 kB
JavaScript
;
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 });
}
exports.batch = batch;
exports.bind = bind;
exports.bindAttr = bindAttr;
exports.bindClass = bindClass;
exports.bindInnerText = bindInnerText;
exports.bindVisibility = bindVisibility;
exports.computed = computed;
exports.derive = derive;
exports.resin = resin;
exports.select = select;
exports.subscribe = subscribe;
exports.watchEffect = watchEffect;
//# sourceMappingURL=index.cjs.map