Refactor signal and effect functions for clarity

New fetch, storage, web sockets,
This commit is contained in:
Natxo
2026-03-14 01:02:52 +01:00
committed by GitHub
parent 9cb34ade07
commit 84e1dbab24

291
sigpro.js
View File

@@ -28,12 +28,12 @@ const flushEffectQueue = () => {
*/ */
export const $ = (initialValue) => { export const $ = (initialValue) => {
const subscribers = new Set(); const subscribers = new Set();
if (typeof initialValue === "function") { if (typeof initialValue === "function") {
// Computed signal case // Computed signal case
let isDirty = true; let isDirty = true;
let cachedValue; let cachedValue;
const computedEffect = { const computedEffect = {
dependencies: new Set(), dependencies: new Set(),
cleanupHandlers: new Set(), cleanupHandlers: new Set(),
@@ -50,7 +50,7 @@ export const $ = (initialValue) => {
// Clear old dependencies // Clear old dependencies
computedEffect.dependencies.forEach((dependencySet) => dependencySet.delete(computedEffect)); computedEffect.dependencies.forEach((dependencySet) => dependencySet.delete(computedEffect));
computedEffect.dependencies.clear(); computedEffect.dependencies.clear();
const previousEffect = activeEffect; const previousEffect = activeEffect;
activeEffect = computedEffect; activeEffect = computedEffect;
try { try {
@@ -61,7 +61,7 @@ export const $ = (initialValue) => {
} }
}, },
}; };
return () => { return () => {
if (activeEffect) { if (activeEffect) {
subscribers.add(activeEffect); subscribers.add(activeEffect);
@@ -71,7 +71,7 @@ export const $ = (initialValue) => {
return cachedValue; return cachedValue;
}; };
} }
// Regular signal case // Regular signal case
return (...args) => { return (...args) => {
if (args.length) { if (args.length) {
@@ -101,7 +101,7 @@ export const $ = (initialValue) => {
* @param {Function} effectFn - The effect function to run * @param {Function} effectFn - The effect function to run
* @returns {Function} Cleanup function to stop the effect * @returns {Function} Cleanup function to stop the effect
*/ */
export const $$ = (effectFn) => { export const $effect = (effectFn) => {
const effect = { const effect = {
dependencies: new Set(), dependencies: new Set(),
cleanupHandlers: new Set(), cleanupHandlers: new Set(),
@@ -109,11 +109,11 @@ export const $$ = (effectFn) => {
// Run cleanup handlers // Run cleanup handlers
this.cleanupHandlers.forEach((handler) => handler()); this.cleanupHandlers.forEach((handler) => handler());
this.cleanupHandlers.clear(); this.cleanupHandlers.clear();
// Clear old dependencies // Clear old dependencies
this.dependencies.forEach((dependencySet) => dependencySet.delete(this)); this.dependencies.forEach((dependencySet) => dependencySet.delete(this));
this.dependencies.clear(); this.dependencies.clear();
const previousEffect = activeEffect; const previousEffect = activeEffect;
activeEffect = this; activeEffect = this;
try { try {
@@ -129,12 +129,37 @@ export const $$ = (effectFn) => {
this.cleanupFunction?.(); this.cleanupFunction?.();
}, },
}; };
if (activeEffect) activeEffect.cleanupHandlers.add(() => effect.stop()); if (activeEffect) activeEffect.cleanupHandlers.add(() => effect.stop());
effect.run(); effect.run();
return () => effect.stop(); 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 * Tagged template literal for creating reactive HTML
* @param {string[]} strings - Template strings * @param {string[]} strings - Template strings
@@ -143,15 +168,14 @@ export const $$ = (effectFn) => {
*/ */
export const html = (strings, ...values) => { export const html = (strings, ...values) => {
const templateCache = html._templateCache ?? (html._templateCache = new WeakMap()); const templateCache = html._templateCache ?? (html._templateCache = new WeakMap());
/** /**
* Gets a node by path from root * Gets a node by path from root
* @param {Node} root - Root node * @param {Node} root - Root node
* @param {number[]} path - Path indices * @param {number[]} path - Path indices
* @returns {Node} Target node * @returns {Node} Target node
*/ */
const getNodeByPath = (root, path) => const getNodeByPath = (root, path) => path.reduce((node, index) => node?.childNodes?.[index], root);
path.reduce((node, index) => node?.childNodes?.[index], root);
/** /**
* Applies reactive text content to a node * Applies reactive text content to a node
@@ -173,30 +197,28 @@ export const html = (strings, ...values) => {
parent.insertBefore(endMarker, node); parent.insertBefore(endMarker, node);
let lastResult; let lastResult;
$$(() => { $effect(() => {
let result = typeof currentValue === "function" ? currentValue() : currentValue; let result = typeof currentValue === "function" ? currentValue() : currentValue;
if (result === lastResult) return; if (result === lastResult) return;
lastResult = result; lastResult = result;
if (typeof result !== "object" && !Array.isArray(result)) { if (typeof result !== "object" && !Array.isArray(result)) {
const textNode = startMarker.nextSibling; const textNode = startMarker.nextSibling;
if (textNode !== endMarker && textNode?.nodeType === 3) { if (textNode !== endMarker && textNode?.nodeType === 3) {
textNode.textContent = result ?? ""; textNode.textContent = result ?? "";
} else { } else {
while (startMarker.nextSibling !== endMarker) while (startMarker.nextSibling !== endMarker) parent.removeChild(startMarker.nextSibling);
parent.removeChild(startMarker.nextSibling);
parent.insertBefore(document.createTextNode(result ?? ""), endMarker); parent.insertBefore(document.createTextNode(result ?? ""), endMarker);
} }
return; return;
} }
// Handle arrays or objects // Handle arrays or objects
while (startMarker.nextSibling !== endMarker) while (startMarker.nextSibling !== endMarker) parent.removeChild(startMarker.nextSibling);
parent.removeChild(startMarker.nextSibling);
const items = Array.isArray(result) ? result : [result]; const items = Array.isArray(result) ? result : [result];
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
items.forEach(item => { items.forEach((item) => {
if (item == null || item === false) return; if (item == null || item === false) return;
const nodeItem = item instanceof Node ? item : document.createTextNode(item); const nodeItem = item instanceof Node ? item : document.createTextNode(item);
fragment.appendChild(nodeItem); fragment.appendChild(nodeItem);
@@ -213,10 +235,10 @@ export const html = (strings, ...values) => {
if (!cachedTemplate) { if (!cachedTemplate) {
const template = document.createElement("template"); const template = document.createElement("template");
template.innerHTML = strings.join("{{part}}"); template.innerHTML = strings.join("{{part}}");
const dynamicNodes = []; const dynamicNodes = [];
const treeWalker = document.createTreeWalker(template.content, 133); // NodeFilter.SHOW_ALL const treeWalker = document.createTreeWalker(template.content, 133); // NodeFilter.SHOW_ALL
/** /**
* Gets path indices for a node * Gets path indices for a node
* @param {Node} node - Target node * @param {Node} node - Target node
@@ -226,24 +248,24 @@ export const html = (strings, ...values) => {
const path = []; const path = [];
while (node && node !== template.content) { while (node && node !== template.content) {
let index = 0; let index = 0;
for (let sibling = node.previousSibling; sibling; sibling = sibling.previousSibling) for (let sibling = node.previousSibling; sibling; sibling = sibling.previousSibling) index++;
index++;
path.push(index); path.push(index);
node = node.parentNode; node = node.parentNode;
} }
return path.reverse(); return path.reverse();
}; };
let currentNode; let currentNode;
while ((currentNode = treeWalker.nextNode())) { while ((currentNode = treeWalker.nextNode())) {
let isDynamic = false; let isDynamic = false;
const nodeInfo = { const nodeInfo = {
type: currentNode.nodeType, type: currentNode.nodeType,
path: getNodePath(currentNode), path: getNodePath(currentNode),
parts: [] parts: [],
}; };
if (currentNode.nodeType === 1) { // Element node if (currentNode.nodeType === 1) {
// Element node
for (let i = 0; i < currentNode.attributes.length; i++) { for (let i = 0; i < currentNode.attributes.length; i++) {
const attribute = currentNode.attributes[i]; const attribute = currentNode.attributes[i];
if (attribute.value.includes("{{part}}")) { if (attribute.value.includes("{{part}}")) {
@@ -255,44 +277,75 @@ export const html = (strings, ...values) => {
// Text node // Text node
isDynamic = true; isDynamic = true;
} }
if (isDynamic) dynamicNodes.push(nodeInfo); if (isDynamic) dynamicNodes.push(nodeInfo);
} }
templateCache.set(strings, (cachedTemplate = { template, dynamicNodes })); templateCache.set(strings, (cachedTemplate = { template, dynamicNodes }));
} }
const fragment = cachedTemplate.template.content.cloneNode(true); const fragment = cachedTemplate.template.content.cloneNode(true);
let valueIndex = 0; let valueIndex = 0;
// Get target nodes before applyTextContent modifies the DOM // Get target nodes before applyTextContent modifies the DOM
const targets = cachedTemplate.dynamicNodes.map((nodeInfo) => ({ const targets = cachedTemplate.dynamicNodes.map((nodeInfo) => ({
node: getNodeByPath(fragment, nodeInfo.path), node: getNodeByPath(fragment, nodeInfo.path),
info: nodeInfo info: nodeInfo,
})); }));
targets.forEach(({ node, info }) => { targets.forEach(({ node, info }) => {
if (!node) return; if (!node) return;
if (info.type === 1) { // Element node if (info.type === 1) {
// Element node
info.parts.forEach((part) => { info.parts.forEach((part) => {
const currentValue = values[valueIndex++]; const currentValue = values[valueIndex++];
const attributeName = part.name; const attributeName = part.name;
const firstChar = attributeName[0]; const firstChar = attributeName[0];
if (firstChar === "@") { if (firstChar === "@") {
// Event listener const [eventName, ...modifiers] = attributeName.slice(1).split(".");
node.addEventListener(attributeName.slice(1), currentValue);
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 === ":") { } else if (firstChar === ":") {
// Two-way binding // Two-way binding
const propertyName = attributeName.slice(1); const propertyName = attributeName.slice(1);
const eventType = node.type === "checkbox" || node.type === "radio" ? "change" : "input"; const eventType = node.type === "checkbox" || node.type === "radio" ? "change" : "input";
$$(() => { $effect(() => {
const value = typeof currentValue === "function" ? currentValue() : currentValue; const value = typeof currentValue === "function" ? currentValue() : currentValue;
if (node[propertyName] !== value) node[propertyName] = value; if (node[propertyName] !== value) node[propertyName] = value;
}); });
node.addEventListener(eventType, () => { node.addEventListener(eventType, () => {
const value = eventType === "change" ? node.checked : node.value; const value = eventType === "change" ? node.checked : node.value;
if (typeof currentValue === "function") currentValue(value); if (typeof currentValue === "function") currentValue(value);
@@ -300,14 +353,14 @@ export const html = (strings, ...values) => {
} else if (firstChar === "?") { } else if (firstChar === "?") {
// Boolean attribute // Boolean attribute
const attrName = attributeName.slice(1); const attrName = attributeName.slice(1);
$$(() => { $effect(() => {
const result = typeof currentValue === "function" ? currentValue() : currentValue; const result = typeof currentValue === "function" ? currentValue() : currentValue;
node.toggleAttribute(attrName, !!result); node.toggleAttribute(attrName, !!result);
}); });
} else if (firstChar === ".") { } else if (firstChar === ".") {
// Property binding // Property binding
const propertyName = attributeName.slice(1); const propertyName = attributeName.slice(1);
$$(() => { $effect(() => {
let result = typeof currentValue === "function" ? currentValue() : currentValue; let result = typeof currentValue === "function" ? currentValue() : currentValue;
node[propertyName] = result; node[propertyName] = result;
if (result != null && typeof result !== "object" && typeof result !== "boolean") { if (result != null && typeof result !== "object" && typeof result !== "boolean") {
@@ -317,19 +370,20 @@ export const html = (strings, ...values) => {
} else { } else {
// Regular attribute // Regular attribute
if (typeof currentValue === "function") { if (typeof currentValue === "function") {
$$(() => node.setAttribute(attributeName, currentValue())); $effect(() => node.setAttribute(attributeName, currentValue()));
} else { } else {
node.setAttribute(attributeName, currentValue); 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; const placeholderCount = node.textContent.split("{{part}}").length - 1;
applyTextContent(node, values.slice(valueIndex, valueIndex + placeholderCount)); applyTextContent(node, values.slice(valueIndex, valueIndex + placeholderCount));
valueIndex += placeholderCount; valueIndex += placeholderCount;
} }
}); });
return fragment; return fragment;
}; };
@@ -341,28 +395,28 @@ export const html = (strings, ...values) => {
*/ */
export const $component = (tagName, setupFunction, observedAttributes = []) => { export const $component = (tagName, setupFunction, observedAttributes = []) => {
if (customElements.get(tagName)) return; if (customElements.get(tagName)) return;
customElements.define( customElements.define(
tagName, tagName,
class extends HTMLElement { class extends HTMLElement {
static get observedAttributes() { static get observedAttributes() {
return observedAttributes; return observedAttributes;
} }
constructor() { constructor() {
super(); super();
this._propertySignals = {}; this._propertySignals = {};
this.cleanupFunctions = []; this.cleanupFunctions = [];
observedAttributes.forEach((attr) => (this._propertySignals[attr] = $(undefined))); observedAttributes.forEach((attr) => (this._propertySignals[attr] = $(undefined)));
} }
connectedCallback() { connectedCallback() {
const frozenChildren = [...this.childNodes]; const frozenChildren = [...this.childNodes];
this.innerHTML = ""; this.innerHTML = "";
observedAttributes.forEach((attr) => { observedAttributes.forEach((attr) => {
const initialValue = this.hasOwnProperty(attr) ? this[attr] : this.getAttribute(attr); const initialValue = this.hasOwnProperty(attr) ? this[attr] : this.getAttribute(attr);
Object.defineProperty(this, attr, { Object.defineProperty(this, attr, {
get: () => this._propertySignals[attr](), get: () => this._propertySignals[attr](),
set: (value) => { set: (value) => {
@@ -371,10 +425,10 @@ export const $component = (tagName, setupFunction, observedAttributes = []) => {
}, },
configurable: true, configurable: true,
}); });
if (initialValue !== null && initialValue !== undefined) this[attr] = initialValue; if (initialValue !== null && initialValue !== undefined) this[attr] = initialValue;
}); });
const context = { const context = {
select: (selector) => this.querySelector(selector), select: (selector) => this.querySelector(selector),
slot: (name) => slot: (name) =>
@@ -386,15 +440,15 @@ export const $component = (tagName, setupFunction, observedAttributes = []) => {
host: this, host: this,
onUnmount: (cleanupFn) => this.cleanupFunctions.push(cleanupFn), onUnmount: (cleanupFn) => this.cleanupFunctions.push(cleanupFn),
}; };
const result = setupFunction(this._propertySignals, context); const result = setupFunction(this._propertySignals, context);
if (result instanceof Node) this.appendChild(result); if (result instanceof Node) this.appendChild(result);
} }
attributeChangedCallback(name, oldValue, newValue) { attributeChangedCallback(name, oldValue, newValue) {
if (this[name] !== newValue) this[name] = newValue; if (this[name] !== newValue) this[name] = newValue;
} }
disconnectedCallback() { disconnectedCallback() {
this.cleanupFunctions.forEach((cleanupFn) => cleanupFn()); this.cleanupFunctions.forEach((cleanupFn) => cleanupFn());
this.cleanupFunctions = []; 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<Object|null>} 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 * Creates a router for hash-based navigation
* @param {Array<{path: string|RegExp, component: Function}>} routes - Route configurations * @param {Array<{path: string|RegExp, component: Function}>} routes - Route configurations
@@ -414,21 +492,21 @@ export const $router = (routes) => {
* @returns {string} Current path * @returns {string} Current path
*/ */
const getCurrentPath = () => window.location.hash.replace(/^#/, "") || "/"; const getCurrentPath = () => window.location.hash.replace(/^#/, "") || "/";
const currentPath = $(getCurrentPath()); const currentPath = $(getCurrentPath());
const container = document.createElement("div"); const container = document.createElement("div");
container.style.display = "contents"; container.style.display = "contents";
window.addEventListener("hashchange", () => { window.addEventListener("hashchange", () => {
const nextPath = getCurrentPath(); const nextPath = getCurrentPath();
if (currentPath() !== nextPath) currentPath(nextPath); if (currentPath() !== nextPath) currentPath(nextPath);
}); });
$$(() => { $effect(() => {
const path = currentPath(); const path = currentPath();
let matchedRoute = null; let matchedRoute = null;
let routeParams = {}; let routeParams = {};
for (const route of routes) { for (const route of routes) {
if (route.path instanceof RegExp) { if (route.path instanceof RegExp) {
const match = path.match(route.path); const match = path.match(route.path);
@@ -442,25 +520,23 @@ export const $router = (routes) => {
break; break;
} }
} }
const previousEffect = activeEffect; const previousEffect = activeEffect;
activeEffect = null; activeEffect = null;
try { try {
const view = matchedRoute const view = matchedRoute
? matchedRoute.component(routeParams) ? matchedRoute.component(routeParams)
: html` : html`
<h1>404</h1> <h1>404</h1>
`; `;
container.replaceChildren( container.replaceChildren(view instanceof Node ? view : document.createTextNode(view ?? ""));
view instanceof Node ? view : document.createTextNode(view ?? "")
);
} finally { } finally {
activeEffect = previousEffect; activeEffect = previousEffect;
} }
}); });
return container; return container;
}; };
@@ -471,4 +547,77 @@ export const $router = (routes) => {
$router.go = (path) => { $router.go = (path) => {
const targetPath = path.startsWith("/") ? path : `/${path}`; const targetPath = path.startsWith("/") ? path : `/${path}`;
if (window.location.hash !== `#${targetPath}`) window.location.hash = targetPath; if (window.location.hash !== `#${targetPath}`) window.location.hash = targetPath;
}; };
/**
* 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 };
};