Update sigpro.js nre $.page

This commit is contained in:
Natxo
2026-03-16 18:31:06 +01:00
committed by GitHub
parent 29b5d2e674
commit 8db375b861

View File

@@ -1,14 +1,7 @@
// Global state for tracking the current reactive effect // Global state for tracking the current reactive effect
let activeEffect = null; let activeEffect = null;
// Queue for batched effect updates
const effectQueue = new Set(); const effectQueue = new Set();
let isFlushScheduled = false; let isFlushScheduled = false;
/**
* Flushes all pending effects in the queue
* Executes all queued jobs and clears the queue
*/
let flushCount = 0; let flushCount = 0;
const flushEffectQueue = () => { const flushEffectQueue = () => {
@@ -39,43 +32,40 @@ const flushEffectQueue = () => {
* @param {any} initialValue - Initial value or getter function * @param {any} initialValue - Initial value or getter function
* @returns {Function} Signal getter/setter function * @returns {Function} Signal getter/setter function
*/ */
export const $ = (initialValue) => { const $ = (initialValue) => {
const subscribers = new Set(); const subscribers = new Set();
let signal;
if (typeof initialValue === "function") { if (typeof initialValue === "function") {
// 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(),
markDirty: () => { markDirty: () => {
if (!isDirty) { if (!isDirty) {
isDirty = true; isDirty = true;
subscribers.forEach((subscriber) => { subscribers.forEach((sub) => {
if (subscriber.markDirty) subscriber.markDirty(); if (sub.markDirty) sub.markDirty();
effectQueue.add(subscriber); effectQueue.add(sub);
}); });
if (!isFlushScheduled && effectQueue.size) {
isFlushScheduled = true;
queueMicrotask(flushEffectQueue);
}
} }
}, },
run: () => { run: () => {
// Clear old dependencies computedEffect.dependencies.forEach((dep) => dep.delete(computedEffect));
computedEffect.dependencies.forEach((dependencySet) => dependencySet.delete(computedEffect));
computedEffect.dependencies.clear(); computedEffect.dependencies.clear();
const prev = activeEffect;
const previousEffect = activeEffect;
activeEffect = computedEffect; activeEffect = computedEffect;
try { try { cachedValue = initialValue(); }
cachedValue = initialValue(); finally { activeEffect = prev; isDirty = false; }
} finally {
activeEffect = previousEffect;
isDirty = false;
}
}, },
}; };
return () => { signal = () => {
if (activeEffect) { if (activeEffect) {
subscribers.add(activeEffect); subscribers.add(activeEffect);
activeEffect.dependencies.add(subscribers); activeEffect.dependencies.add(subscribers);
@@ -83,32 +73,34 @@ export const $ = (initialValue) => {
if (isDirty) computedEffect.run(); if (isDirty) computedEffect.run();
return cachedValue; return cachedValue;
}; };
} } else {
signal = (...args) => {
// Regular signal case if (args.length) {
return (...args) => { const next = typeof args[0] === "function" ? args[0](initialValue) : args[0];
if (args.length) { if (!Object.is(initialValue, next)) {
const nextValue = typeof args[0] === "function" ? args[0](initialValue) : args[0]; initialValue = next;
if (!Object.is(initialValue, nextValue)) { subscribers.forEach((sub) => {
initialValue = nextValue; if (sub.markDirty) sub.markDirty();
subscribers.forEach((subscriber) => { effectQueue.add(sub);
if (subscriber.markDirty) subscriber.markDirty(); });
effectQueue.add(subscriber); if (!isFlushScheduled && effectQueue.size) {
}); isFlushScheduled = true;
if (!isFlushScheduled && effectQueue.size) { queueMicrotask(flushEffectQueue);
isFlushScheduled = true; }
queueMicrotask(flushEffectQueue);
} }
} }
} if (activeEffect) {
if (activeEffect) { subscribers.add(activeEffect);
subscribers.add(activeEffect); activeEffect.dependencies.add(subscribers);
activeEffect.dependencies.add(subscribers); }
} return initialValue;
return initialValue; };
}; }
return signal;
}; };
let currentPageCleanups = null;
/** /**
* Creates a reactive effect that runs when dependencies change * Creates a reactive effect that runs when dependencies change
* @param {Function} effectFn - The effect function to run * @param {Function} effectFn - The effect function to run
@@ -119,31 +111,27 @@ const $e = (effectFn) => {
dependencies: new Set(), dependencies: new Set(),
cleanupHandlers: new Set(), cleanupHandlers: new Set(),
run() { run() {
// Run cleanup handlers this.cleanupHandlers.forEach((h) => h());
this.cleanupHandlers.forEach((handler) => handler());
this.cleanupHandlers.clear(); this.cleanupHandlers.clear();
this.dependencies.forEach((dep) => dep.delete(this));
// Clear old dependencies
this.dependencies.forEach((dependencySet) => dependencySet.delete(this));
this.dependencies.clear(); this.dependencies.clear();
const previousEffect = activeEffect; const prev = activeEffect;
activeEffect = this; activeEffect = this;
try { try {
const result = effectFn(); const res = effectFn();
if (typeof result === "function") this.cleanupFunction = result; if (typeof res === "function") this.cleanupHandlers.add(res);
} finally { } finally { activeEffect = prev; }
activeEffect = previousEffect;
}
}, },
stop() { stop() {
this.cleanupHandlers.forEach((handler) => handler()); this.cleanupHandlers.forEach((h) => h());
this.dependencies.forEach((dependencySet) => dependencySet.delete(this)); this.dependencies.forEach((dep) => dep.delete(this));
this.cleanupFunction?.();
}, },
}; };
if (currentPageCleanups) currentPageCleanups.push(() => effect.stop());
if (activeEffect) activeEffect.cleanupHandlers.add(() => effect.stop()); if (activeEffect) activeEffect.cleanupHandlers.add(() => effect.stop());
effect.run(); effect.run();
return () => effect.stop(); return () => effect.stop();
}; };
@@ -195,22 +183,11 @@ const $s = (key, initialValue, storage = localStorage) => {
* @returns {DocumentFragment} Reactive document fragment * @returns {DocumentFragment} Reactive document fragment
* @see {@link https://developer.mozilla.org/es/docs/Glossary/Cross-site_scripting} * @see {@link https://developer.mozilla.org/es/docs/Glossary/Cross-site_scripting}
*/ */
export const html = (strings, ...values) => { 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
* @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
* @param {Node} node - Target node
* @param {any[]} values - Values to insert
*/
const applyTextContent = (node, values) => { const applyTextContent = (node, values) => {
const parts = node.textContent.split("{{part}}"); const parts = node.textContent.split("{{part}}");
const parent = node.parentNode; const parent = node.parentNode;
@@ -225,12 +202,19 @@ export const html = (strings, ...values) => {
parent.insertBefore(startMarker, node); parent.insertBefore(startMarker, node);
parent.insertBefore(endMarker, node); parent.insertBefore(endMarker, node);
let lastResult; if (typeof currentValue === "function") {
$e(() => { let lastResult;
let result = typeof currentValue === "function" ? currentValue() : currentValue; $e(() => {
if (result === lastResult) return; const result = currentValue();
lastResult = result; if (result === lastResult) return;
lastResult = result;
updateContent(result);
});
} else {
updateContent(currentValue);
}
function updateContent(result) {
if (typeof result !== "object" && !Array.isArray(result)) { if (typeof result !== "object" && !Array.isArray(result)) {
const textNode = startMarker.nextSibling; const textNode = startMarker.nextSibling;
const safeText = String(result ?? ""); const safeText = String(result ?? "");
@@ -241,40 +225,32 @@ export const html = (strings, ...values) => {
while (startMarker.nextSibling !== endMarker) parent.removeChild(startMarker.nextSibling); while (startMarker.nextSibling !== endMarker) parent.removeChild(startMarker.nextSibling);
parent.insertBefore(document.createTextNode(safeText), endMarker); parent.insertBefore(document.createTextNode(safeText), endMarker);
} }
return; } else {
while (startMarker.nextSibling !== endMarker) parent.removeChild(startMarker.nextSibling);
const items = Array.isArray(result) ? result : [result];
const fragment = document.createDocumentFragment();
items.forEach((item) => {
if (item == null || item === false) return;
const nodeItem = item instanceof Node ? item : document.createTextNode(item);
fragment.appendChild(nodeItem);
});
parent.insertBefore(fragment, endMarker);
} }
}
// Handle arrays or objects
while (startMarker.nextSibling !== endMarker) parent.removeChild(startMarker.nextSibling);
const items = Array.isArray(result) ? result : [result];
const fragment = document.createDocumentFragment();
items.forEach((item) => {
if (item == null || item === false) return;
const nodeItem = item instanceof Node ? item : document.createTextNode(item);
fragment.appendChild(nodeItem);
});
parent.insertBefore(fragment, endMarker);
});
} }
}); });
node.remove(); node.remove();
}; };
// Get or create template from cache
let cachedTemplate = templateCache.get(strings); let cachedTemplate = templateCache.get(strings);
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);
/**
* Gets path indices for a node
* @param {Node} node - Target node
* @returns {number[]} Path indices
*/
const getNodePath = (node) => { const getNodePath = (node) => {
const path = []; const path = [];
while (node && node !== template.content) { while (node && node !== template.content) {
@@ -296,7 +272,6 @@ export const html = (strings, ...values) => {
}; };
if (currentNode.nodeType === 1) { 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}}")) {
@@ -305,7 +280,6 @@ export const html = (strings, ...values) => {
} }
} }
} else if (currentNode.nodeType === 3 && currentNode.textContent.includes("{{part}}")) { } else if (currentNode.nodeType === 3 && currentNode.textContent.includes("{{part}}")) {
// Text node
isDynamic = true; isDynamic = true;
} }
@@ -318,7 +292,6 @@ export const html = (strings, ...values) => {
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
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,
@@ -328,7 +301,6 @@ export const html = (strings, ...values) => {
if (!node) return; if (!node) return;
if (info.type === 1) { 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;
@@ -342,7 +314,6 @@ export const html = (strings, ...values) => {
if (modifiers.includes("stop")) e.stopPropagation(); if (modifiers.includes("stop")) e.stopPropagation();
if (modifiers.includes("self") && e.target !== node) return; if (modifiers.includes("self") && e.target !== node) return;
// Debounce
if (modifiers.some((m) => m.startsWith("debounce"))) { if (modifiers.some((m) => m.startsWith("debounce"))) {
const ms = modifiers.find((m) => m.startsWith("debounce"))?.split(":")[1] || 300; const ms = modifiers.find((m) => m.startsWith("debounce"))?.split(":")[1] || 300;
clearTimeout(node._debounceTimer); clearTimeout(node._debounceTimer);
@@ -350,7 +321,6 @@ export const html = (strings, ...values) => {
return; return;
} }
// Once (auto-remover)
if (modifiers.includes("once")) { if (modifiers.includes("once")) {
node.removeEventListener(eventName, handlerWrapper); node.removeEventListener(eventName, handlerWrapper);
} }
@@ -362,44 +332,52 @@ export const html = (strings, ...values) => {
passive: modifiers.includes("passive"), passive: modifiers.includes("passive"),
capture: modifiers.includes("capture"), capture: modifiers.includes("capture"),
}); });
// Cleanup
if ($e.onCleanup) {
$e.onCleanup(() => node.removeEventListener(eventName, handlerWrapper));
}
} else if (firstChar === ":") { } else if (firstChar === ":") {
// 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";
$e(() => { if (typeof currentValue === "function") {
const value = typeof currentValue === "function" ? currentValue() : currentValue; $e(() => {
if (node[propertyName] !== value) node[propertyName] = value; const value = currentValue();
}); if (node[propertyName] !== value) node[propertyName] = value;
});
} else {
node[propertyName] = currentValue;
}
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);
}); });
} else if (firstChar === "?") { } else if (firstChar === "?") {
// Boolean attribute
const attrName = attributeName.slice(1); const attrName = attributeName.slice(1);
$e(() => {
const result = typeof currentValue === "function" ? currentValue() : currentValue; if (typeof currentValue === "function") {
node.toggleAttribute(attrName, !!result); $e(() => {
}); const result = currentValue();
node.toggleAttribute(attrName, !!result);
});
} else {
node.toggleAttribute(attrName, !!currentValue);
}
} else if (firstChar === ".") { } else if (firstChar === ".") {
// Property binding
const propertyName = attributeName.slice(1); const propertyName = attributeName.slice(1);
$e(() => {
let result = typeof currentValue === "function" ? currentValue() : currentValue; if (typeof currentValue === "function") {
node[propertyName] = result; $e(() => {
if (result != null && typeof result !== "object" && typeof result !== "boolean") { const result = currentValue();
node.setAttribute(propertyName, result); node[propertyName] = result;
if (result != null && typeof result !== "object" && typeof result !== "boolean") {
node.setAttribute(propertyName, result);
}
});
} else {
node[propertyName] = currentValue;
if (currentValue != null && typeof currentValue !== "object" && typeof currentValue !== "boolean") {
node.setAttribute(propertyName, currentValue);
} }
}); }
} else { } else {
// Regular attribute
if (typeof currentValue === "function") { if (typeof currentValue === "function") {
$e(() => node.setAttribute(attributeName, currentValue())); $e(() => node.setAttribute(attributeName, currentValue()));
} else { } else {
@@ -408,7 +386,6 @@ export const html = (strings, ...values) => {
} }
}); });
} else if (info.type === 3) { } 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;
@@ -418,6 +395,48 @@ export const html = (strings, ...values) => {
return fragment; return fragment;
}; };
/**
* Creates a page with automatic cleanup
* @param {Function} setupFunction - Page setup function that receives props
* @returns {Function} A function that creates page instances with props
*/
const $p = (setupFunction) => {
const tagName = "page-" + Math.random().toString(36).substring(2, 9);
customElements.define(
tagName,
class extends HTMLElement {
connectedCallback() {
this.style.display = "contents";
this._cleanups = [];
currentPageCleanups = this._cleanups;
try {
const result = setupFunction({
params: JSON.parse(this.getAttribute("params") || "{}"),
onUnmount: (fn) => this._cleanups.push(fn),
});
this.appendChild(result instanceof Node ? result : document.createTextNode(String(result)));
} finally {
currentPageCleanups = null;
}
}
disconnectedCallback() {
this._cleanups.forEach((fn) => fn());
this._cleanups = [];
this.innerHTML = "";
}
},
);
return (props = {}) => {
const el = document.createElement(tagName);
el.setAttribute("params", JSON.stringify(props));
return el;
};
};
/** /**
* Creates a custom web component with reactive properties * Creates a custom web component with reactive properties
* @param {string} tagName - Custom element tag name * @param {string} tagName - Custom element tag name
@@ -519,153 +538,52 @@ const $f = async (url, data, loading) => {
} }
}; };
const sanitizePath = (path) => {
// Eliminar cualquier intento de HTML/JavaScript
return String(path).replace(/[<>"']/g, "");
};
/** /**
* 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
* @returns {HTMLDivElement} Router container element * @returns {HTMLDivElement} Router container element
*/ */
const $r = (routes) => { const $r = (routes) => {
/**
* Gets current path from hash
* @returns {string} Current path
*/
const getCurrentPath = () => window.location.hash.replace(/^#/, "") || "/"; const getCurrentPath = () => window.location.hash.replace(/^#/, "") || "/";
const currentPath = $(getCurrentPath());
const container = document.createElement("div"); const container = document.createElement("div");
container.style.display = "contents"; container.style.display = "contents";
window.addEventListener("hashchange", () => { const render = () => {
const nextPath = sanitizePath(getCurrentPath()); const path = getCurrentPath();
if (currentPath() !== nextPath) currentPath(nextPath); let matchedRoute = routes.find(r => r.path instanceof RegExp ? path.match(r.path) : r.path === path);
});
$e(() => {
const path = currentPath();
let matchedRoute = null;
let routeParams = {}; let routeParams = {};
for (const route of routes) { if (matchedRoute?.path instanceof RegExp) {
if (route.path instanceof RegExp) { const m = path.match(matchedRoute.path);
const match = path.match(route.path); routeParams = m.groups || { id: m[1] };
if (match) {
matchedRoute = route;
routeParams = match.groups || { id: match[1] };
break;
}
} else if (route.path === path) {
matchedRoute = route;
break;
}
} }
const previousEffect = activeEffect; const view = matchedRoute ? matchedRoute.component(routeParams) : Object.assign(document.createElement("h1"), { textContent: "404" });
activeEffect = null; container.replaceChildren(view instanceof Node ? view : document.createTextNode(String(view ?? "")));
};
try {
const view = matchedRoute ? matchedRoute.component(routeParams) : Object.assign(document.createElement("h1"), { textContent: "404" });
container.replaceChildren(view instanceof Node ? view : document.createTextNode(view ?? ""));
} finally {
activeEffect = previousEffect;
}
});
window.addEventListener("hashchange", render);
render();
return container; return container;
}; };
/**
* Navigates to a specific route
* @param {string} path - Target path
*/
$r.go = (path) => { $r.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
*/
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) => {
const data = e.data;
messages([...messages(), 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 ($e?.onCleanup) $e.onCleanup(close);
return { status, messages, error, send, close };
};
/* Can customize the name of your functions */ /* Can customize the name of your functions */
$.effect = $e; $.effect = $e;
$.page = $p;
$.component = $c; $.component = $c;
$.fetch = $f; $.fetch = $f;
$.router = $r; $.router = $r;
$.ws = $ws;
$.storage = $s; $.storage = $s;
if (typeof window !== "undefined") {
window.$ = $;
}
export { $, html };