From 84e1dbab2410842d8b117f7ff4eff9d55a21d31a Mon Sep 17 00:00:00 2001 From: Natxo <1172351+natxocc@users.noreply.github.com> Date: Sat, 14 Mar 2026 01:02:52 +0100 Subject: [PATCH] Refactor signal and effect functions for clarity New fetch, storage, web sockets, --- sigpro.js | 291 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 220 insertions(+), 71 deletions(-) diff --git a/sigpro.js b/sigpro.js index 429ebda..542d597 100644 --- a/sigpro.js +++ b/sigpro.js @@ -28,12 +28,12 @@ const flushEffectQueue = () => { */ export const $ = (initialValue) => { const subscribers = new Set(); - + if (typeof initialValue === "function") { // Computed signal case let isDirty = true; let cachedValue; - + const computedEffect = { dependencies: new Set(), cleanupHandlers: new Set(), @@ -50,7 +50,7 @@ export const $ = (initialValue) => { // Clear old dependencies computedEffect.dependencies.forEach((dependencySet) => dependencySet.delete(computedEffect)); computedEffect.dependencies.clear(); - + const previousEffect = activeEffect; activeEffect = computedEffect; try { @@ -61,7 +61,7 @@ export const $ = (initialValue) => { } }, }; - + return () => { if (activeEffect) { subscribers.add(activeEffect); @@ -71,7 +71,7 @@ export const $ = (initialValue) => { return cachedValue; }; } - + // Regular signal case return (...args) => { if (args.length) { @@ -101,7 +101,7 @@ export const $ = (initialValue) => { * @param {Function} effectFn - The effect function to run * @returns {Function} Cleanup function to stop the effect */ -export const $$ = (effectFn) => { +export const $effect = (effectFn) => { const effect = { dependencies: new Set(), cleanupHandlers: new Set(), @@ -109,11 +109,11 @@ export const $$ = (effectFn) => { // Run cleanup handlers this.cleanupHandlers.forEach((handler) => handler()); this.cleanupHandlers.clear(); - + // Clear old dependencies this.dependencies.forEach((dependencySet) => dependencySet.delete(this)); this.dependencies.clear(); - + const previousEffect = activeEffect; activeEffect = this; try { @@ -129,12 +129,37 @@ export const $$ = (effectFn) => { this.cleanupFunction?.(); }, }; - + if (activeEffect) activeEffect.cleanupHandlers.add(() => effect.stop()); effect.run(); return () => effect.stop(); }; +/** + * Persistent signal with localStorage + * @param {string} key - Storage key + * @param {any} initialValue - Default value if none stored + * @param {Storage} [storage=localStorage] - Storage type (localStorage/sessionStorage) + * @returns {Function} Signal that persists to storage + */ +export const $$ = (key, initialValue, storage = localStorage) => { + // Load saved value + const saved = storage.getItem(key); + const signal = $(saved !== null ? JSON.parse(saved) : initialValue); + + // Auto-save on changes + $effect(() => { + const value = signal(); + if (value === undefined || value === null) { + storage.removeItem(key); + } else { + storage.setItem(key, JSON.stringify(value)); + } + }); + + return signal; +}; + /** * Tagged template literal for creating reactive HTML * @param {string[]} strings - Template strings @@ -143,15 +168,14 @@ export const $$ = (effectFn) => { */ export const html = (strings, ...values) => { const templateCache = html._templateCache ?? (html._templateCache = new WeakMap()); - + /** * Gets a node by path from root * @param {Node} root - Root node * @param {number[]} path - Path indices * @returns {Node} Target node */ - const getNodeByPath = (root, path) => - path.reduce((node, index) => node?.childNodes?.[index], root); + const getNodeByPath = (root, path) => path.reduce((node, index) => node?.childNodes?.[index], root); /** * Applies reactive text content to a node @@ -173,30 +197,28 @@ export const html = (strings, ...values) => { parent.insertBefore(endMarker, node); let lastResult; - $$(() => { + $effect(() => { let result = typeof currentValue === "function" ? currentValue() : currentValue; if (result === lastResult) return; lastResult = result; - + if (typeof result !== "object" && !Array.isArray(result)) { const textNode = startMarker.nextSibling; if (textNode !== endMarker && textNode?.nodeType === 3) { textNode.textContent = result ?? ""; } else { - while (startMarker.nextSibling !== endMarker) - parent.removeChild(startMarker.nextSibling); + while (startMarker.nextSibling !== endMarker) parent.removeChild(startMarker.nextSibling); parent.insertBefore(document.createTextNode(result ?? ""), endMarker); } return; } // Handle arrays or objects - while (startMarker.nextSibling !== endMarker) - parent.removeChild(startMarker.nextSibling); - + while (startMarker.nextSibling !== endMarker) parent.removeChild(startMarker.nextSibling); + const items = Array.isArray(result) ? result : [result]; const fragment = document.createDocumentFragment(); - items.forEach(item => { + items.forEach((item) => { if (item == null || item === false) return; const nodeItem = item instanceof Node ? item : document.createTextNode(item); fragment.appendChild(nodeItem); @@ -213,10 +235,10 @@ export const html = (strings, ...values) => { if (!cachedTemplate) { const template = document.createElement("template"); template.innerHTML = strings.join("{{part}}"); - + const dynamicNodes = []; const treeWalker = document.createTreeWalker(template.content, 133); // NodeFilter.SHOW_ALL - + /** * Gets path indices for a node * @param {Node} node - Target node @@ -226,24 +248,24 @@ export const html = (strings, ...values) => { const path = []; while (node && node !== template.content) { let index = 0; - for (let sibling = node.previousSibling; sibling; sibling = sibling.previousSibling) - index++; + for (let sibling = node.previousSibling; sibling; sibling = sibling.previousSibling) index++; path.push(index); node = node.parentNode; } return path.reverse(); }; - + let currentNode; while ((currentNode = treeWalker.nextNode())) { let isDynamic = false; - const nodeInfo = { - type: currentNode.nodeType, - path: getNodePath(currentNode), - parts: [] + const nodeInfo = { + type: currentNode.nodeType, + path: getNodePath(currentNode), + parts: [], }; - - if (currentNode.nodeType === 1) { // Element node + + if (currentNode.nodeType === 1) { + // Element node for (let i = 0; i < currentNode.attributes.length; i++) { const attribute = currentNode.attributes[i]; if (attribute.value.includes("{{part}}")) { @@ -255,44 +277,75 @@ export const html = (strings, ...values) => { // Text node isDynamic = true; } - + if (isDynamic) dynamicNodes.push(nodeInfo); } - + templateCache.set(strings, (cachedTemplate = { template, dynamicNodes })); } const fragment = cachedTemplate.template.content.cloneNode(true); let valueIndex = 0; - + // Get target nodes before applyTextContent modifies the DOM const targets = cachedTemplate.dynamicNodes.map((nodeInfo) => ({ node: getNodeByPath(fragment, nodeInfo.path), - info: nodeInfo + info: nodeInfo, })); targets.forEach(({ node, info }) => { if (!node) return; - - if (info.type === 1) { // Element node + + if (info.type === 1) { + // Element node info.parts.forEach((part) => { const currentValue = values[valueIndex++]; const attributeName = part.name; const firstChar = attributeName[0]; - + if (firstChar === "@") { - // Event listener - node.addEventListener(attributeName.slice(1), currentValue); + const [eventName, ...modifiers] = attributeName.slice(1).split("."); + + const handlerWrapper = (e) => { + if (modifiers.includes("prevent")) e.preventDefault(); + if (modifiers.includes("stop")) e.stopPropagation(); + if (modifiers.includes("self") && e.target !== node) return; + + // Debounce + if (modifiers.some((m) => m.startsWith("debounce"))) { + const ms = modifiers.find((m) => m.startsWith("debounce"))?.split(":")[1] || 300; + clearTimeout(node._debounceTimer); + node._debounceTimer = setTimeout(() => currentValue(e), ms); + return; + } + + // Once (auto-remover) + if (modifiers.includes("once")) { + node.removeEventListener(eventName, handlerWrapper); + } + + currentValue(e); + }; + + node.addEventListener(eventName, handlerWrapper, { + passive: modifiers.includes("passive"), + capture: modifiers.includes("capture"), + }); + + // Cleanup + if ($effect.onCleanup) { + $effect.onCleanup(() => node.removeEventListener(eventName, handlerWrapper)); + } } else if (firstChar === ":") { // Two-way binding const propertyName = attributeName.slice(1); const eventType = node.type === "checkbox" || node.type === "radio" ? "change" : "input"; - - $$(() => { + + $effect(() => { const value = typeof currentValue === "function" ? currentValue() : currentValue; if (node[propertyName] !== value) node[propertyName] = value; }); - + node.addEventListener(eventType, () => { const value = eventType === "change" ? node.checked : node.value; if (typeof currentValue === "function") currentValue(value); @@ -300,14 +353,14 @@ export const html = (strings, ...values) => { } else if (firstChar === "?") { // Boolean attribute const attrName = attributeName.slice(1); - $$(() => { + $effect(() => { const result = typeof currentValue === "function" ? currentValue() : currentValue; node.toggleAttribute(attrName, !!result); }); } else if (firstChar === ".") { // Property binding const propertyName = attributeName.slice(1); - $$(() => { + $effect(() => { let result = typeof currentValue === "function" ? currentValue() : currentValue; node[propertyName] = result; if (result != null && typeof result !== "object" && typeof result !== "boolean") { @@ -317,19 +370,20 @@ export const html = (strings, ...values) => { } else { // Regular attribute if (typeof currentValue === "function") { - $$(() => node.setAttribute(attributeName, currentValue())); + $effect(() => node.setAttribute(attributeName, currentValue())); } else { node.setAttribute(attributeName, currentValue); } } }); - } else if (info.type === 3) { // Text node + } else if (info.type === 3) { + // Text node const placeholderCount = node.textContent.split("{{part}}").length - 1; applyTextContent(node, values.slice(valueIndex, valueIndex + placeholderCount)); valueIndex += placeholderCount; } }); - + return fragment; }; @@ -341,28 +395,28 @@ export const html = (strings, ...values) => { */ export const $component = (tagName, setupFunction, observedAttributes = []) => { if (customElements.get(tagName)) return; - + customElements.define( tagName, class extends HTMLElement { static get observedAttributes() { return observedAttributes; } - + constructor() { super(); this._propertySignals = {}; this.cleanupFunctions = []; observedAttributes.forEach((attr) => (this._propertySignals[attr] = $(undefined))); } - + connectedCallback() { const frozenChildren = [...this.childNodes]; this.innerHTML = ""; - + observedAttributes.forEach((attr) => { const initialValue = this.hasOwnProperty(attr) ? this[attr] : this.getAttribute(attr); - + Object.defineProperty(this, attr, { get: () => this._propertySignals[attr](), set: (value) => { @@ -371,10 +425,10 @@ export const $component = (tagName, setupFunction, observedAttributes = []) => { }, configurable: true, }); - + if (initialValue !== null && initialValue !== undefined) this[attr] = initialValue; }); - + const context = { select: (selector) => this.querySelector(selector), slot: (name) => @@ -386,15 +440,15 @@ export const $component = (tagName, setupFunction, observedAttributes = []) => { host: this, onUnmount: (cleanupFn) => this.cleanupFunctions.push(cleanupFn), }; - + const result = setupFunction(this._propertySignals, context); if (result instanceof Node) this.appendChild(result); } - + attributeChangedCallback(name, oldValue, newValue) { if (this[name] !== newValue) this[name] = newValue; } - + disconnectedCallback() { this.cleanupFunctions.forEach((cleanupFn) => cleanupFn()); this.cleanupFunctions = []; @@ -403,6 +457,30 @@ export const $component = (tagName, setupFunction, observedAttributes = []) => { ); }; +/** + * Ultra-simple fetch wrapper with optional loading signal + * @param {string} url - Endpoint URL + * @param {Object} data - Data to send (automatically JSON.stringify'd) + * @param {Function} [loading] - Optional signal function to track loading state + * @returns {Promise} Parsed JSON response or null on error + */ +export const $fetch = async (url, data, loading) => { + if (loading) loading(true); + + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return await res.json(); + } catch (e) { + return null; + } finally { + if (loading) loading(false); + } +}; + /** * Creates a router for hash-based navigation * @param {Array<{path: string|RegExp, component: Function}>} routes - Route configurations @@ -414,21 +492,21 @@ export const $router = (routes) => { * @returns {string} Current path */ const getCurrentPath = () => window.location.hash.replace(/^#/, "") || "/"; - + const currentPath = $(getCurrentPath()); const container = document.createElement("div"); container.style.display = "contents"; - + window.addEventListener("hashchange", () => { const nextPath = getCurrentPath(); if (currentPath() !== nextPath) currentPath(nextPath); }); - - $$(() => { + + $effect(() => { const path = currentPath(); let matchedRoute = null; let routeParams = {}; - + for (const route of routes) { if (route.path instanceof RegExp) { const match = path.match(route.path); @@ -442,25 +520,23 @@ export const $router = (routes) => { break; } } - + const previousEffect = activeEffect; activeEffect = null; - + try { const view = matchedRoute ? matchedRoute.component(routeParams) : html`

404

`; - - container.replaceChildren( - view instanceof Node ? view : document.createTextNode(view ?? "") - ); + + container.replaceChildren(view instanceof Node ? view : document.createTextNode(view ?? "")); } finally { activeEffect = previousEffect; } }); - + return container; }; @@ -471,4 +547,77 @@ export const $router = (routes) => { $router.go = (path) => { const targetPath = path.startsWith("/") ? path : `/${path}`; if (window.location.hash !== `#${targetPath}`) window.location.hash = targetPath; -}; \ No newline at end of file +}; + +/** + * Simple WebSocket wrapper with signals + * @param {string} url - WebSocket URL + * @param {Object} options - Auto-reconnect options + * @returns {Object} WebSocket with reactive signals + */ +export const $ws = (url, options = {}) => { + const { + reconnect = true, + maxReconnect = 5, + reconnectInterval = 1000 + } = options; + + const status = $('disconnected'); + const messages = $([]); + const error = $(null); + + let ws = null; + let reconnectAttempts = 0; + let reconnectTimer = null; + + const connect = () => { + status('connecting'); + ws = new WebSocket(url); + + ws.onopen = () => { + status('connected'); + reconnectAttempts = 0; + error(null); + }; + + ws.onmessage = (e) => { + messages([...messages(), e.data]); + }; + + ws.onerror = (err) => { + error(err); + status('error'); + }; + + ws.onclose = () => { + status('disconnected'); + + if (reconnect && reconnectAttempts < maxReconnect) { + reconnectTimer = setTimeout(() => { + reconnectAttempts++; + connect(); + }, reconnectInterval * Math.pow(2, reconnectAttempts)); + } + }; + }; + + const send = (data) => { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(typeof data === 'string' ? data : JSON.stringify(data)); + } + }; + + const close = () => { + if (reconnectTimer) clearTimeout(reconnectTimer); + if (ws) { + ws.close(); + ws = null; + } + }; + + connect(); + + if ($effect?.onCleanup) $e.onCleanup(close); + + return { status, messages, error, send, close }; +};