diff --git a/sigpro.ts b/sigpro.ts index 3e5dc8e..b36234b 100644 --- a/sigpro.ts +++ b/sigpro.ts @@ -1,611 +1,551 @@ -Aquí tienes el código adaptado con el nuevo sistema de efectos estilo Sigwork, más compacto, seguro y eficiente, manteniendo toda la funcionalidad de SigPro: +/* + * Sigwork - [Sig]nal-based Frontend Frame[work] + * Copyright (c) 2026 Murillo Brandão <@murillobrand> + * TypeScript version + */ -```typescript -// SigPro.ts - Versión con sistema de efectos estilo Sigwork +// ============================================================================ +// Types & Interfaces +// ============================================================================ type CleanupFn = () => void; - -interface EffectFn { - (): void; - _deps: Set>; - _children?: EffectFn[]; - _cleanup?: CleanupFn; - _isComputed?: boolean; - _subs?: Set; - _deleted?: boolean; -} - -type JSXFunction = { -

>( - tag: string, - props: (P & { children?: any }) | null, - ...children: any[] - ): HTMLElement; - -

>( - tag: (props: P, context?: any) => any, - props: (P & { children?: any }) | null, - ...children: any[] - ): any; +type EffectFn = { + (): void; + e: Set>; + c?: EffectFn[]; }; -type Owner = { cleanups: Set } | null; +type Context = { + m: CleanupFn[]; + u: CleanupFn[]; + p: Record; +}; type Signal = { - (): T; - (next: T | ((prev: T) => T)): T; - readonly [SIGNAL]: true; + get value(): T; + set value(newValue: T); }; -type Runtime = { - _isRuntime: true; - container: HTMLElement; - destroy: () => void; +type ReadonlySignal = { + get value(): T; }; -type Component = (props?: Record, children?: any[]) => any; -type Transition = { - in?: (el: HTMLElement) => void; - out?: (el: HTMLElement, done: () => void) => void; +type Component

> = ( + props: P, + context: { + children?: any[]; + emit: (event: string, ...args: any[]) => any; + } +) => Node | (() => Node) | null; + +type TransitionClasses = [string, string, string]; + +type TransitionConfig = { + enter?: TransitionClasses; + idle?: string; + leave?: TransitionClasses; }; -const SIGNAL = Symbol("signal"); +type ElementWithLifecycle = Node & { + $c?: Context; + $s?: CleanupFn; + $l?: (done: CleanupFn) => void; +}; -// Sistema de efectos estilo Sigwork -let activeEffect: EffectFn | null = null; -let currentOwner: Owner = null; +// ============================================================================ +// Helpers +// ============================================================================ + +const isFn = (v: unknown): v is Function => typeof v === 'function'; +const isNode = (v: unknown): v is Node => v instanceof Node; + +// ============================================================================ +// Signals System +// ============================================================================ + +/*----- Schedule System -----*/ let isScheduled = false; -const effectQueue = new Set(); +const queue = new Set(); -const tick = () => { - while (effectQueue.size) { - const runs = [...effectQueue]; - effectQueue.clear(); - runs.forEach(fn => fn()); - } - isScheduled = false; -}; - -const scheduleEffect = (effect: EffectFn) => { - if (effect._deleted) return; - effectQueue.add(effect); - if (!isScheduled) queueMicrotask(tick); - isScheduled = true; -}; - -const MOUNTED_NODES = new WeakMap(); - -const doc = document; -const isArr = Array.isArray; -const assign = Object.assign; -const createEl = (t: string) => doc.createElement(t); -const createText = (t: any) => doc.createTextNode(String(t ?? "")); -const isFunc = (f: any): f is Function => typeof f === "function"; -const isObj = (o: any): o is object => typeof o === "object" && o !== null; - -const runWithContext = (effect: EffectFn | null, callback: () => T): T => { - const prev = activeEffect; - activeEffect = effect; - try { - return callback(); - } finally { - activeEffect = prev; - } -}; - -const cleanupNode = (node: Node) => { - if ((node as any)._cleanups) { - (node as any)._cleanups.forEach((dispose: CleanupFn) => dispose()); - (node as any)._cleanups.clear(); - } - node.childNodes?.forEach(cleanupNode); -}; - -const trackSubscription = (subscribers: Set) => { - if (activeEffect && !activeEffect._deleted) { - subscribers.add(activeEffect); - activeEffect._deps.add(subscribers); - } -}; - -const triggerUpdate = (subscribers: Set) => { - for (const effect of subscribers) { - if (effect === activeEffect || effect._deleted) continue; - if (effect._isComputed) { - effect._dirty = true; - if (effect._subs) triggerUpdate(effect._subs); - } else { - scheduleEffect(effect); +const tick = (): void => { + while (queue.size) { + const runs = [...queue]; + queue.clear(); + runs.forEach(fn => fn()); } - } + isScheduled = false; }; -const isJavascriptURL = (url: string): boolean => { - try { - const parsed = new URL(url, location.origin); - return parsed.protocol === "javascript:"; - } catch { - return false; - } +const schedule = (fn: EffectFn): void => { + queue.add(fn); + if (!isScheduled) { + queueMicrotask(tick); + isScheduled = true; + } }; -const sanitizeURL = (url: string): string => { - if (isJavascriptURL(url)) return "#"; - return url; +/*----- Effects -----*/ +let activeEffect: EffectFn | null = null; + +export const effect = (fn: () => any, isScope: boolean = false): CleanupFn => { + let cleanup: CleanupFn | null = null; + + const run = () => { + stop(); + const prev = activeEffect; + activeEffect = run; + const result = fn(); + if (isFn(result)) cleanup = result; + activeEffect = prev; + }; + + const stop = () => { + run.e.forEach(subs => subs.delete(run)); + run.e.clear(); + if (cleanup) cleanup(); + run.c?.forEach(f => f()); + }; + + (run as EffectFn).e = new Set(); + if (isScope) (run as EffectFn).c = []; + + run(); + + if (activeEffect?.c) { + activeEffect.c.push(stop); + } + + return stop; }; -// ========== EFECTOS ESTILO SIGWORK ========== -export function effect(fn: () => any, isScope: boolean = false): CleanupFn { - let cleanup: CleanupFn | null = null; - - const run = () => { - if (run._deleted) return; - stop(); +export const scope = (fn: () => any): CleanupFn => effect(fn, true); + +const track = (subs: Set): void => { + if (activeEffect && !activeEffect.c) { + subs.add(activeEffect); + activeEffect.e.add(subs); + } +}; + +/*----- Signals -----*/ +export function signal(): Signal; +export function signal(initial: T): Signal; +export function signal(initial?: T): Signal { + let value = initial; + const subs = new Set(); + + return { + get value() { + track(subs); + return value; + }, + set value(newValue: T | undefined) { + if (newValue === value) return; + value = newValue; + subs.forEach(fn => schedule(fn)); + } + }; +} + +export const untrack = (fn: () => T): T => { const prev = activeEffect; - activeEffect = run; + activeEffect = null; const result = fn(); - if (isFunc(result)) cleanup = result; activeEffect = prev; - }; - - const stop = () => { - if (run._deleted) return; - run._deleted = true; - run._deps.forEach(subs => subs.delete(run)); - run._deps.clear(); - if (cleanup) cleanup(); - run._children?.forEach(child => child()); - }; - - run._deps = new Set>(); - run._children = []; - run._deleted = false; - - if (isScope && activeEffect) { - activeEffect._children!.push(stop); - } - - run(); - - if (currentOwner) currentOwner.cleanups.add(stop); - - return stop; -} + return result; +}; -// ========== WATCH (basado en effect) ========== -export function watch( - source: (() => T) | { value: T }, - callback: (newValue: T, oldValue: T) => any -): CleanupFn { - let first = true; - let oldValue: T; - - const getter = isFunc(source) ? source : () => (source as any).value; - - return effect(() => { - const newValue = getter(); - - if (!first) { - runWithContext(null, () => callback(newValue, oldValue)); - } else { - first = false; - } - oldValue = newValue; - }); -} - -// ========== SIGNALS ========== -export function $(initial: T | (() => T), storageKey?: string): Signal { - const subscribers = new Set(); - - if (isFunc(initial)) { - let cachedValue: T; - let isDirty = true; - let stopEffect: CleanupFn | null = null; - - const computedFn = () => { - if (stopEffect) stopEffect(); - stopEffect = effect(() => { - const newValue = (initial as () => T)(); - if (!Object.is(cachedValue, newValue) || isDirty) { - cachedValue = newValue; - isDirty = false; - triggerUpdate(subscribers); - } - }); - }; - - const signal = ((...args: [] | [T | ((prev: T) => T)]) => { - if (args.length === 0) { - trackSubscription(subscribers); - return cachedValue; - } - return cachedValue; - }) as Signal; - - signal[SIGNAL] = true; - computedFn(); - return signal; - } - - let value = initial as T; - if (storageKey) { - try { - const saved = localStorage.getItem(storageKey); - if (saved !== null) value = JSON.parse(saved); - } catch (e) { - console.warn("SigPro storage error", e); - } - } - - const signal = ((...args: [] | [T | ((prev: T) => T)]) => { - if (args.length) { - const next = isFunc(args[0]) ? (args[0] as (prev: T) => T)(value) : args[0]; - if (!Object.is(value, next)) { - value = next; - if (storageKey) { - try { - localStorage.setItem(storageKey, JSON.stringify(value)); - } catch (e) { } - } - triggerUpdate(subscribers); - } - return value; - } - trackSubscription(subscribers); - return value; - }) as Signal; - signal[SIGNAL] = true; - return signal; -} - -// ========== REACTIVE OBJECT ========== -export function $$(object: T, cache = new WeakMap()): T { - if (!isObj(object)) return object; - if (cache.has(object)) return cache.get(object); - - const keySubscribers: Record> = {}; - const proxy = new Proxy(object, { - get(target, key, receiver) { - if (activeEffect) { - const subs = keySubscribers[key] ??= new Set(); - trackSubscription(subs); - } - const value = Reflect.get(target, key, receiver); - return isObj(value) ? $$(value, cache) : value; - }, - set(target, key, value, receiver) { - if (Object.is(target[key as keyof T], value)) return true; - const success = Reflect.set(target, key, value, receiver); - if (keySubscribers[key]) triggerUpdate(keySubscribers[key]); - return success; - }, - }); - - cache.set(object, proxy); - return proxy; -} - -// ========== RENDER ========== -export function Render(renderFn: (ctx: { onCleanup: (fn: CleanupFn) => void }) => any): Runtime { - const cleanups = new Set(); - const prevOwner = currentOwner; - const container = createEl("div"); - container.style.display = "contents"; - currentOwner = { cleanups }; - - const processResult = (result: any) => { - if (!result) return; - if (result._isRuntime) { - cleanups.add(result.destroy); - container.appendChild(result.container); - } else if (isArr(result)) { - result.forEach(processResult); - } else { - container.appendChild(result instanceof Node ? result : createText(result)); - } - }; - - try { - processResult(renderFn({ onCleanup: (fn) => cleanups.add(fn) })); - } finally { - currentOwner = prevOwner; - } - - return { - _isRuntime: true, - container, - destroy: () => { - cleanups.forEach(fn => fn()); - cleanupNode(container); - container.remove(); - }, - }; -} - -// ========== TAG ========== -export function Tag(tag: string, props: any = {}, children: any = []): HTMLElement { - if (props instanceof Node || isArr(props) || !isObj(props)) { - children = props; - props = {}; - } - - const isSVG = /^(svg|path|circle|rect|line|polyline|polygon|g|defs|text|tspan|use)$/.test(tag); - const el = isSVG - ? doc.createElementNS("http://www.w3.org/2000/svg", tag) - : createEl(tag) as HTMLElement; - - (el as any)._cleanups = new Set(); - (el as any).onUnmount = (fn: CleanupFn) => (el as any)._cleanups.add(fn); - - const booleanAttrs = ["disabled", "checked", "required", "readonly", "selected", "multiple", "autofocus"]; - - const updateAttr = (name: string, value: any) => { - let safeValue = value; - if ((name === "href" || name === "src") && typeof value === "string") { - safeValue = sanitizeURL(value); - } - if (booleanAttrs.includes(name)) { - (el as any)[name] = !!safeValue; - safeValue ? el.setAttribute(name, "") : el.removeAttribute(name); - } else { - if (safeValue == null || safeValue === false) { - el.removeAttribute(name); - } else { - el.setAttribute(name, String(safeValue)); - } - } - }; - - for (const [key, val] of Object.entries(props)) { - if (key === "ref") { - if (isFunc(val)) val(el); - else if (val && typeof val === "object") (val as { current: any }).current = el; - continue; - } - - const isReactive = isFunc(val) && (val as any)[SIGNAL] === true; - if (key.startsWith("on")) { - const eventName = key.slice(2).toLowerCase().split(".")[0]; - el.addEventListener(eventName, val); - (el as any)._cleanups.add(() => el.removeEventListener(eventName, val)); - } else if (isReactive) { - (el as any)._cleanups.add(effect(() => { - const currentVal = (val as Signal)(); - if (key === "class") el.className = currentVal || ""; - else updateAttr(key, currentVal); - })); - if (["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName) && (key === "value" || key === "checked")) { - const ev = key === "checked" ? "change" : "input"; - const handler = (e: Event) => (val as Signal)((e.target as any)[key]); - el.addEventListener(ev, handler); - (el as any)._cleanups.add(() => el.removeEventListener(ev, handler)); - } - } else { - updateAttr(key, val); - } - } - - const appendChildNode = (child: any) => { - if (isArr(child)) return child.forEach(appendChildNode); - if (isFunc(child) && (child as any)[SIGNAL] !== true) { - const marker = createText(""); - el.appendChild(marker); - let currentNodes: Node[] = []; - (el as any)._cleanups.add(effect(() => { - const result = child(); - const nextNodes = (isArr(result) ? result : [result]).map((node: any) => - node?._isRuntime ? node.container : (node instanceof Node ? node : createText(node)) - ); - if (currentNodes.length === nextNodes.length && currentNodes.every((n, i) => n.nodeType === nextNodes[i].nodeType)) { - for (let i = 0; i < currentNodes.length; i++) { - if (currentNodes[i].nodeType === 3 && nextNodes[i].nodeType === 3) { - currentNodes[i].textContent = (nextNodes[i] as Text).textContent; - } else if (currentNodes[i] !== nextNodes[i]) { - currentNodes[i].parentNode?.replaceChild(nextNodes[i], currentNodes[i]); - cleanupNode(currentNodes[i]); - currentNodes[i] = nextNodes[i]; - } - } - } else { - currentNodes.forEach(n => { cleanupNode(n); n.remove(); }); - nextNodes.forEach(n => marker.parentNode?.insertBefore(n, marker)); - currentNodes = nextNodes; - } - })); - } else { - el.appendChild(child instanceof Node ? child : createText(child)); - } - }; - - appendChildNode(children); - return el; -} - -// ========== IF ========== -export function If( - condition: (() => boolean) | boolean, - thenVal: any, - otherwiseVal: any = null, - transition: Transition | null = null -): HTMLElement { - const marker = createText(""); - const container = Tag("div", { style: "display:contents" }, [marker]); - let currentView: Runtime | null = null; - let lastState: boolean | null = null; - - effect(() => { - const state = !!(isFunc(condition) ? condition() : condition); - if (state === lastState) return; - lastState = state; - - const dispose = () => { - if (currentView) { - currentView.destroy(); - currentView = null; - } - }; - - if (currentView && !state && transition?.out) { - transition.out(currentView.container, dispose); - } else { - dispose(); - } - - const branch = state ? thenVal : otherwiseVal; - if (branch) { - currentView = Render(() => isFunc(branch) ? branch() : branch); - container.insertBefore(currentView.container, marker); - if (state && transition?.in) transition.in(currentView.container); - } - }); - - return container; -} - -// ========== FOR ========== -export function For( - source: (() => T[]) | T[], - renderFn: (item: T, index: number) => any, - keyFn?: (item: T, index: number) => string | number, - tag: string = "div", - props: Record = { style: "display:contents" } -): HTMLElement { - const marker = createText(""); - const container = Tag(tag, props, [marker]); - let viewCache = new Map void }>(); - - effect(() => { - const items = (isFunc(source) ? source() : source) || []; - const nextCache = new Map(); - const order: (string | number)[] = []; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - const key = keyFn ? keyFn(item, i) : i; - let view = viewCache.get(key); - - if (!view) { - const result = renderFn(item, i); - view = result instanceof Node - ? { container: result, destroy: () => { cleanupNode(result); result.remove(); } } - : Render(() => result); - } - - viewCache.delete(key); - nextCache.set(key, view); - order.push(key); - } - - viewCache.forEach(v => v.destroy()); - viewCache = nextCache; - - let anchor = marker; - for (let i = order.length - 1; i >= 0; i--) { - const view = nextCache.get(order[i]); - if (view.container.nextSibling !== anchor) { - container.insertBefore(view.container, anchor); - } - anchor = view.container; - } - }); - - return container; -} - -// ========== ROUTER ========== -export const Router = Object.assign( - (routes: any[]) => { - const currentPath = $(Router.path()); - window.addEventListener("hashchange", () => currentPath(Router.path())); - const outlet = Tag("div", { class: "router-outlet" }); - let currentView: Runtime | null = null; - - watch(currentPath, async () => { - const path = currentPath(); - const route = routes.find(r => { - const rParts = r.path.split("/").filter(Boolean); - const pParts = path.split("/").filter(Boolean); - return rParts.length === pParts.length && rParts.every((part, i) => part.startsWith(":") || part === pParts[i]); - }) || routes.find(r => r.path === "*"); - - if (route) { - let comp = route.component; - if (isFunc(comp) && comp.toString().includes('import')) { - comp = (await comp()).default || (await comp()); - } - const params: Record = {}; - route.path.split("/").filter(Boolean).forEach((part: string, i: number) => { - if (part.startsWith(":")) params[part.slice(1)] = path.split("/").filter(Boolean)[i]; - }); - Router.params(params); - if (currentView) currentView.destroy(); - currentView = Render(() => isFunc(comp) ? comp(params) : comp); - outlet.replaceChildren(currentView.container); - } +export const computed = (fn: () => T): ReadonlySignal => { + const sig = signal(); + effect(() => { + sig.value = fn(); }); - return outlet; - }, - { - params: $({}), - to: (path: string) => { window.location.hash = path.replace(/^#?\/?/, "#/"); }, - back: () => window.history.back(), - path: () => window.location.hash.replace(/^#/, "") || "/", - } -); + return { + get value() { + return sig.value; + } + }; +}; -// ========== MOUNT ========== -export function Mount(component: Component | (() => any), target: string | HTMLElement): Runtime | undefined { - const targetEl = typeof target === "string" ? doc.querySelector(target) : target; - if (!targetEl) return; - if (MOUNTED_NODES.has(targetEl)) MOUNTED_NODES.get(targetEl)!.destroy(); - const instance = Render(isFunc(component) ? component : () => component); - targetEl.replaceChildren(instance.container); - MOUNTED_NODES.set(targetEl, instance); - return instance; -} +const reactiveCache = new WeakMap(); -// ========== EXPORTS ========== -const sigPro = { $, $$, effect, watch, Render, Tag, h: Tag, If, For, Router, Mount }; +export const reactive = (obj: T): T => { + if (reactiveCache.has(obj)) { + return reactiveCache.get(obj) as T; + } + + const subs: Record> = {}; + + const proxy = new Proxy(obj, { + get(target, key: string | symbol, receiver) { + track(subs[key] ??= new Set()); + const val = Reflect.get(target, key, receiver); + return (val && typeof val === 'object') ? reactive(val) : val; + }, + set(target, key: string | symbol, val, receiver) { + if (Object.is(target[key as keyof T], val)) return true; + const success = Reflect.set(target, key, val, receiver); + if (subs[key]) { + subs[key].forEach(fn => schedule(fn)); + if (!isScheduled) { + queueMicrotask(tick); + isScheduled = true; + } + } + return success; + } + }); + + reactiveCache.set(obj, proxy); + return proxy; +}; -if (typeof window !== "undefined") { - Object.assign(window, sigPro); - const tags = "div span p h1 h2 h3 h4 h5 h6 br hr section article aside nav main header footer address ul ol li dl dt dd a em strong small i b u mark time sub sup pre code blockquote details summary dialog form label input textarea select button option fieldset legend table thead tbody tfoot tr th td caption img video audio canvas svg iframe picture source progress meter".split(" "); - tags.forEach(tag => { - const helper = tag[0].toUpperCase() + tag.slice(1); - if (!(helper in window)) (window as any)[helper] = (p?: any, c?: any) => Tag(tag, p, c); - }); - window.SigPro = Object.freeze(sigPro); -} +type WatchSource = (() => T) | { value: T }; -export default sigPro; -``` +export const watch = ( + source: WatchSource, + callback: (newValue: T, oldValue: T) => any +): CleanupFn => { + let first = true; + let oldValue: T; + + return effect(() => { + const newValue = isFn(source) ? source() : source.value; + if (!first) { + untrack(() => callback(newValue, oldValue)); + } else { + first = false; + } + oldValue = newValue; + }); +}; -## Cambios principales: +// ============================================================================ +// Rendering System +// ============================================================================ -1. **Reemplacé `Watch` por `effect`** (25 líneas, estilo Sigwork) -2. **Añadí `watch` como helper** (12 líneas, con untrack automático) -3. **Misma seguridad de memoria** (scopes anidados, cleanups automáticos) -4. **Misma eficiencia** (queueMicrotask, scheduling) -5. **Todo el resto de SigPro intacto** (Router, For, If, Tag, etc.) +// Component management +let context: Context | null = null; -## Uso: +export const onMount = (fn: CleanupFn): void => { + context?.m.push(fn); +}; -```javascript -// Effect automático (como Sigwork) -effect(() => { - console.log(count(), user().name, theme()); - // Suscribe a todo lo que lees -}); +export const onUnmount = (fn: CleanupFn): void => { + context?.u.push(fn); +}; -// Watch específico (con untrack automático) -watch(count, (newVal, oldVal) => { - console.log(newVal, oldVal); - // Solo suscribe a count -}); +export const provide = (key: string | symbol, value: any): void => { + if (context) context.p[key] = value; +}; -// Router sin re-renders -watch(currentPath, (path) => { - updateRoute(path); // No causa re-renders del componente padre -}); -``` \ No newline at end of file +export const inject = (key: string | symbol, defaultValue?: any): any => { + if (context && key in context.p) return context.p[key]; + return defaultValue; +}; + +// Rendering +type HFunction = { +

>( + tag: string, + props?: P | null, + ...children: any[] + ): ElementWithLifecycle; + +

>( + tag: Component

, + props?: P | null, + ...children: any[] + ): Node | (() => Node) | null; +}; + +const append = (parent: Node, child: any): void => { + if (child === null) return; + + if (isFn(child)) { + const anchor = document.createTextNode(''); + parent.appendChild(anchor); + let nodes: Node[] = []; + + effect(() => { + effect(() => { + const newNodes = [child()] + .flat(Infinity) + .map((node: any) => isFn(node) ? node() : node) + .flat(Infinity) + .filter((node: any) => node !== null) + .map((node: any) => isNode(node) ? node : document.createTextNode(String(node))); + + const oldNodes = nodes.filter(node => { + const keep = newNodes.includes(node); + if (!keep) remove(node); + return keep; + }); + + const oldIdxs = new Map(oldNodes.map((node, i) => [node, i])); + + for (let i = newNodes.length - 1, p = oldNodes.length - 1; i >= 0; i--) { + const node = newNodes[i]; + const ref = newNodes[i + 1] || anchor; + + if (!oldIdxs.has(node)) { + anchor.parentNode?.insertBefore(node, ref); + (node as ElementWithLifecycle).$c?.m.forEach(fn => fn()); + } else if (oldNodes[p] !== node) { + if (newNodes[i - 1] !== oldNodes[p]) { + anchor.parentNode?.insertBefore(oldNodes[p], node); + oldNodes[oldIdxs.get(node)!] = oldNodes[p]; + oldNodes[p] = node; + p--; + } + anchor.parentNode?.insertBefore(node, ref); + } else { + p--; + } + } + nodes = newNodes; + }); + }, true); + } else if (isNode(child)) { + parent.appendChild(child); + } else { + parent.appendChild(document.createTextNode(String(child))); + } +}; + +const remove = (node: Node): void => { + const el = node as ElementWithLifecycle; + el.$s?.(); + el.$l?.(() => node.remove()); + if (!el.$l) node.remove(); +}; + +const render = (fn: Function, ...data: any[]): Node => { + let node: any; + const stop = effect(() => { + node = fn(...data); + if (isFn(node)) node = node(); + }, true); + if (node) node.$s = stop; + return node; +}; + +export const h: HFunction = (tag: any, props?: any, ...children: any[]): any => { + props = props || {}; + children = children.flat(Infinity); + + // Component handling + if (isFn(tag)) { + const prev = context; + context = { m: [], u: [], p: { ...(prev?.p || {}) } }; + let el: any; + + const stop = effect(() => { + el = tag(props, { + children, + emit: (evt: string, ...args: any[]) => + props[`on${evt[0].toUpperCase()}${evt.slice(1)}`]?.(...args), + }); + return () => el.$c.u.forEach((fn: CleanupFn) => fn()); + }, true); + + if (isNode(el) || isFn(el)) { + el.$c = context; + el.$s = stop; + } + + context = prev; + return el; + } + + // Handle fragments (null tag) + if (!tag) return () => children; + + // Normal element handling + let el: ElementWithLifecycle; + let is_svg = false; + + try { + el = document.createElement(tag); + if (el instanceof HTMLUnknownElement) { + is_svg = true; + el = document.createElementNS("http://www.w3.org/2000/svg", tag); + } + } catch { + is_svg = true; + el = document.createElementNS("http://www.w3.org/2000/svg", tag); + } + + for (const key in props) { + if (key.startsWith('on')) { + const eventName = key.slice(2).toLowerCase(); + el.addEventListener(eventName, props[key]); + } else if (key === "ref") { + if (isFn(props[key])) { + props[key](el); + } else { + props[key].value = el; + } + } else if (isFn(props[key])) { + effect(() => { + if (key in el && !is_svg) { + (el as any)[key] = props[key](); + } else { + el.setAttribute(key, props[key]()); + } + }); + } else { + if (key in el && !is_svg) { + (el as any)[key] = props[key]; + } else { + el.setAttribute(key, props[key]); + } + } + } + + children.forEach((child: any) => append(el, child)); + + return el; +}; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +export const If = ( + cond: (() => boolean) | boolean, + renderFn: any, + fallback: any = null +): (() => any) => { + let cached: any; + let current: boolean | null = null; + + return () => { + const show = isFn(cond) ? cond() : cond; + if (show !== current) { + cached = show ? render(renderFn) : (isFn(fallback) ? render(fallback) : fallback); + } + current = show; + return cached; + }; +}; + +export const For = ( + list: (() => T[]) | T[] | { value: T[] }, + key: string | ((item: T, index: number) => string | number), + renderFn: (item: T, index: number) => any +): (() => any[]) => { + let cache = new Map(); + + return () => { + const next = new Map(); + const items = (isFn(list) ? list() : (list as any).value || list) as T[]; + + const nodes = items.map((item, index) => { + const idx = isFn(key) ? key(item, index) : key ? (item as any)[key] : index; + let node = cache.get(idx); + if (!node) { + node = render(renderFn, item, index); + } + next.set(idx, node); + return node; + }); + + cache = next; + return nodes; + }; +}; + +// ============================================================================ +// Built-in Components +// ============================================================================ + +type ComponentProps = { + is: string | Component | (() => string | Component); + [key: string]: any; +}; + +export const Component = ( + { is, ...props }: ComponentProps, + { children }: { children: any[] } +): (() => any) => { + return () => h( + isFn(is) ? is() : is, + props, + children + ); +}; + +export const Transition = ( + { enter: e, idle, leave: l }: TransitionConfig, + { children: [c] }: { children: any[] } +): any => { + const decorate = (el: any): any => { + if (!isNode(el)) return el; + + const addClass = (c?: string) => c && (el as HTMLElement).classList.add(...c.split(' ')); + const removeClass = (c?: string) => c && (el as HTMLElement).classList.remove(...c.split(' ')); + + if (e) { + requestAnimationFrame(() => { + addClass(e[1]); + requestAnimationFrame(() => { + addClass(e[0]); + removeClass(e[1]); + addClass(e[2]); + el.addEventListener('transitionend', () => { + removeClass(e[2]); + removeClass(e[0]); + addClass(idle); + }, { once: true }); + }); + }); + } + + if (l) { + (el as ElementWithLifecycle).$l = (done: CleanupFn) => { + removeClass(idle); + addClass(l[1]); + requestAnimationFrame(() => { + addClass(l[0]); + removeClass(l[1]); + addClass(l[2]); + el.addEventListener('transitionend', () => { + removeClass(l[2]); + removeClass(l[0]); + done(); + }, { once: true }); + }); + }; + } + + return el; + }; + + if (!c) return null; + if (isFn(c)) { + return () => decorate(c()); + } + return decorate(c); +}; + +// ============================================================================ +// App Creation +// ============================================================================ + +export default ( + target: HTMLElement, + root: Component, + props?: Record +): CleanupFn => { + const el = h(root, props); + target.appendChild(el); + (el as ElementWithLifecycle).$c?.m.forEach(fn => fn()); + return () => remove(el); +}; + +// ============================================================================ +// Re-exports +// ============================================================================ + +export type { Signal, ReadonlySignal, Component, CleanupFn }; \ No newline at end of file