UNPKG

sprae

Version:

DOM microhydration

480 lines (409 loc) 15.8 kB
import store, { _change, _signals } from "./store.js"; /** Symbol for disposal (using standard Symbol.dispose if available) */ export const _dispose = (Symbol.dispose ||= Symbol("dispose")) /** Symbol for accessing element's reactive state */ export const _state = Symbol("state") /** Symbol for enabling element effects */ export const _on = Symbol('on') /** Symbol for disabling element effects */ export const _off = Symbol('off') /** Symbol for adding child to element */ export const _add = Symbol('init') /** Directive prefix (default: ':') */ export let prefix = ':'; /** Check if element is a custom element (has hyphen in tag name) */ export const isCE = (el) => el.localName?.includes('-') /** * A reactive signal containing a value. * @template T * @typedef {Object} Signal * @property {T} value - Current value (reading subscribes, writing notifies) * @property {() => T} peek - Read without subscribing * @property {() => T} valueOf - Get value for coercion * @property {() => T} toJSON - Get value for JSON serialization * @property {() => string} toString - Get value as string */ /** * Internal effect function type. * @typedef {Object} EffectFn * @property {Set<Set<EffectFn>>} deps - Dependency sets * @property {() => void} fn - Original function */ /** * Creates a reactive signal. * @template T * @type {<T>(value: T) => Signal<T>} */ export let signal; /** * Creates a reactive effect that re-runs when dependencies change. * @type {(fn: () => void | (() => void)) => () => void} */ export let effect; /** * Creates a computed signal derived from other signals. * @template T * @type {<T>(fn: () => T) => Signal<T>} */ export let computed; /** * Batches multiple signal updates into a single notification. * @template T * @type {<T>(fn: () => T) => T} */ export let batch = (fn) => fn(); /** * Runs a function without tracking signal dependencies. * @template T * @type {<T>(fn: () => T) => T} */ export let untracked = batch; /** * Registry of directive handlers. * @type {Record<string, DirectiveHandler>} */ export let directive = {}; /** * Registry of modifier functions. * @type {Record<string, ModifierHandler>} */ export let modifier = {} let currentDir = null; let currentEl = null; /** * Formats element for error message (minimal context). * @param {Element} [el] - Element to format * @returns {string} Element hint like "<div#id.class>" */ const elHint = (el) => { if (!el?.tagName) return '' let hint = el.tagName.toLowerCase() if (el.id) hint += '#' + el.id else if (el.className) hint += '.' + el.className.split(' ')[0] return `<${hint}>` } /** * Reports an error with context. * @param {Error|string} e - Error to report * @param {string} [expr] - Expression that caused error */ const err = (e, expr, el = currentEl) => { let msg = `∴ ${e}` if (el) msg += `\n in ${elHint(el)}` if (expr) { const display = expr.length > 100 ? expr.slice(0, 80) + `… (${expr.length} chars)` : expr msg += currentDir ? `\n ${currentDir}="${display}"` : `\n ="${display}"` } console.error(msg) } /** * @callback DirectiveHandler * @param {Element} el - Target element * @param {Object} state - Reactive state object * @param {string} expr - Expression string * @param {string} [name] - Directive name with modifiers * @returns {((value: any) => void | (() => void)) | { [Symbol.dispose]: () => void } | void} */ /** * @callback ModifierHandler * @param {Function} fn - Function to modify * @param {...string} args - Modifier arguments (from dash-separated values) * @returns {Function} */ /** * @typedef {Object} SpraeState * @property {Record<string, Signal>} [_signals] - Internal signals map */ /** * Applies directives to an HTML element and manages its reactive state. * * @param {Element} [el=document.body] - The target HTML element to apply directives to. * @param {Object} [state] - Initial state values to populate the element's reactive state. * @returns {SpraeState & Object} The reactive state object associated with the element. */ const sprae = (root = document.body, state) => { // repeated call can be caused by eg. :each with new objects with old keys if (root[_state]) return Object.assign(root[_state], state) // console.group('sprae', root) // take over existing state instead of creating a clone state = store(state || {}) let el = root, fx = [], offs = [] // on/off all effects // we don't call prevOn as convention: everything defined before :else :if won't be disabled by :if // imagine <x :onx="..." :if="..."/> - when :if is false, it disables directives after :if (calls _off) but ignores :onx el[_on] = () => { if (offs) return offs offs = Array(fx.length) for (let i = 0; i < fx.length; i++) offs[i] = fx[i]() return offs } el[_off] = () => { if (!offs) return let current = offs offs = null for (let i = 0; i < current.length; i++) current[i]?.() } // destroy el[_dispose] ||= () => { el[_off]?.() if (mo?._root === el) { mo.disconnect(); mo = null } el[_off] = el[_on] = el[_dispose] = el[_add] = el[_state] = null } const add = el[_add] = (el) => { let _attrs = el.attributes, start; if (_attrs) for (let i = 0; i < _attrs.length;) { let { name, value } = _attrs[i] if (name.startsWith(prefix)) { el.removeAttribute(name) let prev = el[_state] currentDir = name; currentEl = el; // directive initializer can be redefined fx.push(start = dir(el, name.slice(prefix.length), value, state)), offs.push(start()) // stop after subsprae directives (:each, :if, :scope) that change element's state identity if (el[_state] !== prev) return } else i++ } // custom elements own their children — don't descend if (el !== root && isCE(el)) return // :if and :each replace element with text node, which tweaks .children length, but .childNodes length persists // real DOM: firstChild/nextSibling avoids array copy; frag.childNodes is already snapshot array if (el.firstChild !== undefined) { let child = el.firstChild, next while (child) (next = child.nextSibling, child.nodeType == 1 && add(child), child = next) } else for (let child of el.childNodes) child.nodeType == 1 && add(child) }; add(el); currentDir = currentEl = null; // if element was spraed by inline :with/:if/:each/etc instruction (meaning it has state placeholder) - skip, otherwise save _state // CE roots: don't claim _state — CE manages its own via connectedCallback (parent processes attrs, CE processes children) if (el[_state] === undefined && !isCE(root)) el[_state] = state // console.groupEnd() return state; } /** Package version (injected by bundler) */ sprae.version = typeof __VERSION__ !== 'undefined' ? __VERSION__ : 'dev' // directive initializer /** @type {(el: Element, name: string, expr: string, state: Object) => () => (() => void) | void} */ export let dir /** * Compiles an expression string into an evaluator function. * @type {(expr: string) => (state: Object) => any} */ export let compile /** * Parses an expression into an evaluator function, caching the result for reuse. * * @param {string} expr - The expression to parse and compile into a function. * @returns {(state: Object, cb?: (value: any) => any) => any} The compiled evaluator function for the expression. */ export const parse = (expr) => { let fn = cache[expr=expr.trim()] if (fn) return fn // static time errors try { fn = compile(expr || 'undefined') // Object.defineProperty(fn, "name", { value: `∴ ${expr}` }) } catch (e) { err(e, expr) } // run time errors return cache[expr] = function (state, cb, _out) { try { let result = fn?.call(this, state) // if cb is given (to handle async/await exprs, usually directive update) - call it with result and return a cleanup function if (cb) return result?.then ? (result.then(v => _out = cb(v)).catch(e => err(e, expr, this)), () => typeof _out === 'function' && _out()) : cb(result) else return result } catch (e) { err(e, expr, this) } } } const cache = {}; /** * @typedef {Object} SpraeConfig * @property {(expr: string) => (state: Object) => any} [compile] - Custom expression compiler * @property {string} [prefix] - Directive prefix (default: ':') * @property {<T>(value: T) => Signal<T>} [signal] - Signal factory * @property {(fn: () => void | (() => void)) => () => void} [effect] - Effect factory * @property {<T>(fn: () => T) => Signal<T>} [computed] - Computed factory * @property {<T>(fn: () => T) => T} [batch] - Batch function * @property {<T>(fn: () => T) => T} [untracked] - Untracked function * @property {(el: Element, name: string, expr: string, state: Object) => () => (() => void) | void} [dir] - Directive initializer */ /** * Configure sprae with custom signals, compiler, or prefix. * @param {SpraeConfig} config - Configuration options * @returns {void} */ export const use = (config) => ( config.compile && (compile = config.compile), config.prefix && (prefix = config.prefix), config.signal && (signal = config.signal), config.effect && (effect = config.effect), config.computed && (computed = config.computed), config.batch && (batch = config.batch), config.untracked && (untracked = config.untracked), config.dir && (dir = config.dir) ) /** * Applies modifiers to a function. * @param {Function & { target?: Element }} fn - Function to decorate * @param {string[]} mods - Modifier names with arguments (e.g., ['throttle-500', 'prevent']) * @returns {Function} Decorated function */ export const decorate = (fn, mods) => { while (mods.length) { let [name, ...params] = mods.pop().split('-'), mod = modifier[name], wrapFn if (mod) { if ((wrapFn = mod(fn, ...params)) !== fn) { for (let k in fn) wrapFn[k] ??= fn[k]; fn = wrapFn } } } return fn } /** MutationObserver reference, set by sprae.start() */ export let mo = null /** Pauses MO during DOM mutations to prevent disposing managed elements */ export const mutate = (fn) => { mo?.disconnect(); fn(); mo?.observe(mo._root, { childList: true, subtree: true }) } /** * Auto-initializes sprae on dynamically added elements. * Uses MutationObserver to detect new DOM nodes and apply directives. * * @param {Element} [root=document.body] - Root element to observe * @param {Object} [values] - Initial state values * @returns {Object} The reactive state object * * @example * ```js * // Auto-init on page load * sprae.start(document.body, { count: 0 }) * ``` */ export const start = (root = document.body, values) => { const state = store(values) sprae(root, state); mo = new MutationObserver(mutations => { for (const m of mutations) { for (const el of m.addedNodes) { // el can be spraed or removed by subsprae (like within :each/:if) if (el.nodeType === 1 && el[_state] === undefined && root.contains(el)) { // even if element has no spraeable attrs, some of its children can have root[_add](el) } } for (const el of m.removedNodes) { // Only dispose if element is truly removed from document if (el.nodeType === 1 && !root.contains(el)) el[_dispose]?.() } } }); mo._root = root mo.observe(root, { childList: true, subtree: true }); return state } /** * @typedef {Object} FragmentLike * @property {Document} ownerDocument - The owner document * @property {Node[]} childNodes - Child nodes of the fragment * @property {DocumentFragment} content - The document fragment content * @property {() => void} remove - Remove the fragment from DOM * @property {(el: Node) => void} replaceWith - Replace the fragment with an element * @property {Attr[]} attributes - Attributes from the original template * @property {(name: string) => void} removeAttribute - Remove an attribute */ /** * Creates a fragment holder from a template element with minimal API surface. * @param {HTMLTemplateElement | FragmentLike} tpl - Template element or existing fragment * @returns {FragmentLike} Fragment-like object */ export const frag = (tpl) => { if (!tpl.nodeType) return tpl // existing tpl let doc = tpl.ownerDocument, content = tpl.content.cloneNode(true), // document fragment holder of content attributes = [...tpl.attributes], ref = doc.createTextNode(''), // ensure at least one node childNodes = (content.append(ref), [...content.childNodes]) return { ownerDocument: doc, childNodes, content, remove: () => content.append(...childNodes), replaceWith(el) { if (el === ref) return ref.before(el) content.append(...childNodes) }, attributes, removeAttribute(name) { attributes.splice(attributes.findIndex(a => a.name === name), 1) }, } } /** * Converts camelCase to kebab-case. * @param {string} str - String to convert * @returns {string} Kebab-case string */ export const dashcase = (str) => str.replace(/[A-Z\u00C0-\u00D6\u00D8-\u00DE]/g, (match, i) => (i ? '-' : '') + match.toLowerCase()); /** * Sets or removes an attribute on an element. * @param {Element} el - Target element * @param {string} name - Attribute name * @param {string | boolean | null | undefined} v - Attribute value (null/false removes, true sets empty) * @returns {void} */ const camelcase = (str) => str.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); export const attr = (el, name, v) => (v == null || v === false) ? el.removeAttribute(name) : isCE(el) ? (el[camelcase(name)] = v) : el.setAttribute(name, v === true ? "" : v); /** * Converts class input to className string (like clsx/classnames). * @param {string | string[] | Record<string, boolean> | null | undefined} c - Class input * @returns {string} Space-separated class string */ export const clsx = (c) => !c ? '' : typeof c === 'string' ? c : ( Array.isArray(c) ? c.map(clsx) : Object.entries(c).reduce((s, [k, v]) => (v && s.push(k), s), []) ).join(' ') /** * Throttles a function to run at most once per tick (or custom scheduler). * Fires on leading edge, then on trailing edge if called during throttle. * @template {Function} T * @param {T} fn - Function to throttle * @param {number|Function} [ms] - Delay in ms or scheduler function (default: microtask) * @returns {T} Throttled function */ export const throttle = (fn, ms) => { let _planned = 0, _depth = 0, arg, schedule = typeof ms === 'function' ? ms : ms ? (fn) => setTimeout(fn, ms) : queueMicrotask; const throttled = (e) => { arg = e if (!_planned++) fn(arg), schedule(() => { let dirty = _planned > 1 _planned = 0 if (dirty) { if (++_depth > 50) { _depth = 0; console.error('∴ Reactive loop detected'); return } throttled(arg) } else _depth = 0 }); } return throttled; } /** * Debounces a function to run after a delay since the last call. * @template {Function} T * @param {T} fn - Function to debounce * @param {number|Function} [ms] - Delay in ms or scheduler function (default: microtask) * @param {boolean} [immediate=false] - Fire on leading edge instead of trailing * @returns {T} Debounced function */ export const debounce = (fn, ms, immediate) => { let schedule = typeof ms === 'function' ? ms : ms ? (fn) => setTimeout(fn, ms) : queueMicrotask; return immediate ? ((_blocked) => (arg) => !_blocked && (fn(arg), _blocked = 1, schedule(() => _blocked = 0)))() : ((_count = 0) => (arg, _c = ++_count) => schedule(() => _c == _count && fn(arg)))() } export * from './store.js'; export default sprae