diff --git a/hibrido.js b/hibrido.js new file mode 100644 index 0000000..1579e6a --- /dev/null +++ b/hibrido.js @@ -0,0 +1,468 @@ +type EffectFn = () => void; +type CleanupFn = () => void; + +let currentEffect: EffectFn | null = null; +const effectStack: EffectFn[] = []; + +let pendingBatch = false; +const batchQueue = new Set < EffectFn > (); + +function flushBatch() { + const effects = Array.from(batchQueue); + batchQueue.clear(); + pendingBatch = false; + effects.forEach(e => e()); +} + +function scheduleEffect(effect: EffectFn) { + batchQueue.add(effect); + if (!pendingBatch) { + pendingBatch = true; + queueMicrotask(flushBatch); + } +} + +function cleanupEffect(effect: EffectFn & { deps: Set> }) { + effect.deps.forEach(dep => dep.delete(effect)); + effect.deps.clear(); +} + +export interface Signal { + (): T; + (value: T | ((prev: T) => T)): T; + _subs: Set> }>; + _value: T; + _storageKey?: string; +} + +function isFunction(v: any): v is (...args: any[]) => T { + return typeof v === 'function'; +} + +export function $(initialValue: T, storageKey?: string): Signal { + let value: T = initialValue; + const subs = new Set < EffectFn & { deps: Set < Set < EffectFn >> } > (); + + if (storageKey) { + try { + const saved = localStorage.getItem(storageKey); + if (saved !== null) value = JSON.parse(saved); + } catch (e) { console.warn("Storage error", e); } + } + + const signal = ((arg?: T | ((prev: T) => T)): T => { + if (arguments.length === 0) { + if (currentEffect) { + subs.add(currentEffect as any); + (currentEffect as any).deps.add(subs); + } + return value; + } else { + const next = isFunction(arg) ? (arg as (prev: T) => T)(value) : arg; + if (Object.is(value, next)) return value; + value = next as T; + if (storageKey) localStorage.setItem(storageKey, JSON.stringify(value)); + for (const eff of Array.from(subs)) { + if (eff && !eff._deleted) scheduleEffect(eff); + } + return value; + } + }) as Signal; + signal._subs = subs; + signal._value = initialValue; + if (storageKey) signal._storageKey = storageKey; + return signal; +} + +export function Watch(effectFn: () => void, deps?: Array<() => any>): () => void { + const runner = (() => { + if (runner._deleted) return; + cleanupEffect(runner); + effectStack.push(runner); + const prev = currentEffect; + currentEffect = runner; + try { + effectFn(); + } finally { + currentEffect = prev; + effectStack.pop(); + } + }) as EffectFn & { deps: Set>; _deleted?: boolean; _cleanups?: Set<() => void> }; + + runner.deps = new Set(); + runner._deleted = false; + + runner(); + + return () => { + runner._deleted = true; + cleanupEffect(runner); + if (runner._cleanups) runner._cleanups.forEach(fn => fn()); + }; +} + +export function computed(fn: () => T): Signal { + const sig = $(undefined as T); + let isDirty = true; + let cached: T; + const stop = Watch(() => { + const newVal = fn(); + if (isDirty || !Object.is(cached, newVal)) { + cached = newVal; + isDirty = false; + sig(cached); + } + }); + (sig as any)._stop = stop; + return sig; +} + +const proxyCache = new WeakMap < object, any> (); + +export function $$(obj: T): T { + if (typeof obj !== 'object' || obj === null) return obj; + if (proxyCache.has(obj)) return proxyCache.get(obj); + + const listeners = new Map < string | symbol, Set> } >> (); + + const proxy = new Proxy(obj, { + get(target, p, receiver) { + if (currentEffect) { + let set = listeners.get(p); + if (!set) { + set = new Set(); + listeners.set(p, set); + } + set.add(currentEffect as any); + (currentEffect as any).deps.add(set); + } + const val = Reflect.get(target, p, receiver); + if (typeof val === 'object' && val !== null) return $$(val); + return val; + }, + set(target, p, value, receiver) { + const old = Reflect.get(target, p, receiver); + if (Object.is(old, value)) return true; + const res = Reflect.set(target, p, value, receiver); + const set = listeners.get(p); + if (set) { + for (const eff of Array.from(set)) { + if (!eff._deleted) scheduleEffect(eff); + } + } + return res; + }, + deleteProperty(target, p) { + const had = Reflect.has(target, p); + const res = Reflect.deleteProperty(target, p); + if (had && res) { + const set = listeners.get(p); + if (set) { + for (const eff of Array.from(set)) scheduleEffect(eff); + } + } + return res; + } + }); + proxyCache.set(obj, proxy); + return proxy; +} + +let currentOwner: { cleanups: Set } | null = null; + +function runWithOwner(owner: { cleanups: Set }, fn: () => T): T { + const prev = currentOwner; + currentOwner = owner; + try { + return fn(); + } finally { + currentOwner = prev; + } +} + +function addCleanup(fn: CleanupFn) { + if (currentOwner) currentOwner.cleanups.add(fn); +} + +export interface Runtime { + container: HTMLElement | DocumentFragment; + destroy: () => void; +} + +export function Render(renderFn: (ctx: { onCleanup: (fn: CleanupFn) => void }) => any): Runtime { + const cleanups = new Set < CleanupFn > (); + const container = document.createElement('div'); + container.style.display = 'contents'; + let currentNodes: Node[] = []; + + const processResult = (result: any): Node[] => { + if (result == null) return []; + if (Array.isArray(result)) { + return result.flatMap(processResult); + } + if (result._isRuntime) { + cleanups.add(result.destroy); + return [result.container]; + } + if (result instanceof Node) return [result]; + return [document.createTextNode(String(result))]; + }; + + const render = () => { + for (const node of currentNodes) { + cleanupNode(node); + node.remove(); + } + currentNodes = []; + let result: any; + runWithOwner({ cleanups }, () => { + result = renderFn({ onCleanup: addCleanup }); + }); + currentNodes = processResult(result); + container.append(...currentNodes); + }; + + render(); + + return { + _isRuntime: true, + container, + destroy: () => { + cleanups.forEach(fn => fn()); + for (const node of currentNodes) cleanupNode(node); + container.remove(); + } + }; +} + +function cleanupNode(node: Node) { + if ((node as any)._cleanups) { + (node as any)._cleanups.forEach((fn: CleanupFn) => fn()); + (node as any)._cleanups.clear(); + } + node.childNodes.forEach(child => cleanupNode(child)); +} + +const booleanAttributes = new Set(["disabled", "checked", "required", "readonly", "selected", "multiple", "autofocus"]); + +function sanitizeURL(url: string): string { + const s = String(url).toLowerCase(); + if (s.includes('javascript:')) return '#'; + return url; +} + +export function Tag(tag: string, propsOrChildren?: any, children?: any): HTMLElement { + let props: Record = {}; + let childArray: any[] = []; + + if (propsOrChildren === undefined || propsOrChildren instanceof Node || Array.isArray(propsOrChildren) || typeof propsOrChildren !== 'object') { + childArray = propsOrChildren ?? []; + } else { + props = propsOrChildren; + childArray = children ?? []; + } + + const isSVG = /^(svg|path|circle|rect|line|polyline|polygon|g|defs|text|tspan|use)$/.test(tag); + const el = isSVG + ? document.createElementNS("http://www.w3.org/2000/svg", tag) + : document.createElement(tag); + + const cleanups = new Set < CleanupFn > (); + (el as any)._cleanups = cleanups; + + const updateAttribute = (name: string, value: any) => { + if (name === 'href' || name === 'src') { + value = sanitizeURL(value); + } + if (booleanAttributes.has(name)) { + const bool = !!value; + if (bool) el.setAttribute(name, ''); + else el.removeAttribute(name); + (el as any)[name] = bool; + } else if (value === undefined || value === null) { + el.removeAttribute(name); + } else { + el.setAttribute(name, String(value)); + } + }; + + for (const [key, val] of Object.entries(props)) { + if (key === 'ref') { + if (typeof val === 'function') val(el); + else if (val && typeof val === 'object') val.current = el; + continue; + } + if (key.startsWith('on')) { + const eventName = key.slice(2).toLowerCase(); + const handler = val; + el.addEventListener(eventName, handler); + cleanups.add(() => el.removeEventListener(eventName, handler)); + continue; + } + if ((tag === 'input' || tag === 'textarea' || tag === 'select') && + (key === 'value' || key === 'checked') && + typeof val === 'function' && (val as any)._subs) { + const eventType = key === 'checked' ? 'change' : 'input'; + const handler = (e: Event) => { + const target = e.target as HTMLInputElement; + const newValue = key === 'checked' ? target.checked : target.value; + (val as Signal)(newValue); + }; + el.addEventListener(eventType, handler); + cleanups.add(() => el.removeEventListener(eventType, handler)); + } + if (typeof val === 'function' && !(val as any)._isSignal) { + if ((val as any)._subs) { + const dispose = Watch(() => { + const current = val(); + if (key === 'class') { + el.className = current || ''; + } else if (key === 'style' && typeof current === 'object') { + Object.assign(el.style, current); + } else { + updateAttribute(key, current); + } + }); + cleanups.add(dispose); + const init = val(); + if (key === 'class') el.className = init || ''; + else if (key === 'style' && typeof init === 'object') Object.assign(el.style, init); + else updateAttribute(key, init); + } else { + updateAttribute(key, val); + } + } else { + updateAttribute(key, val); + } + } + + const appendChildNode = (child: any) => { + if (child == null || child === false || child === true) return; + if (Array.isArray(child)) { + child.forEach(appendChildNode); + return; + } + if (typeof child === 'function' && (child as any)._subs) { + const marker = document.createComment('sig'); + el.appendChild(marker); + let currentNodes: Node[] = []; + const dispose = Watch(() => { + const value = child(); + const newNodes: Node[] = []; + const process = (v: any) => { + if (v == null) return; + if (Array.isArray(v)) v.forEach(process); + else if (v._isRuntime) { + newNodes.push(v.container); + cleanups.add(v.destroy); + } else if (v instanceof Node) newNodes.push(v); + else newNodes.push(document.createTextNode(String(v))); + }; + process(value); + for (const n of currentNodes) { + cleanupNode(n); + n.remove(); + } + for (const n of newNodes) { + marker.parentNode?.insertBefore(n, marker); + } + currentNodes = newNodes; + }); + cleanups.add(dispose); + } else if (child && child._isRuntime) { + el.appendChild(child.container); + cleanups.add(child.destroy); + } else if (child instanceof Node) { + el.appendChild(child); + } else { + el.appendChild(document.createTextNode(String(child))); + } + }; + + appendChildNode(childArray); + return el; +} + +export const Router = { + params: $({} as Record), + currentPath: $('/'), + routes: [] as Array<{ path: string; component: any }>, + + init(routes: Array<{ path: string; component: any }>) { + this.routes = routes; + const update = () => { + const hash = window.location.hash.slice(1) || '/'; + this.currentPath(hash); + }; + window.addEventListener('hashchange', update); + update(); + return this.outlet(); + }, + + outlet() { + const outlet = Tag('div'); + let currentView: Runtime | null = null; + Watch(() => { + const path = this.currentPath(); + const route = this.routes.find(r => { + const parts = r.path.split('/').filter(Boolean); + const pathParts = path.split('/').filter(Boolean); + if (parts.length !== pathParts.length) return false; + return parts.every((part, i) => part.startsWith(':') || part === pathParts[i]); + }) || this.routes.find(r => r.path === '*'); + if (route) { + const params: Record = {}; + const parts = route.path.split('/').filter(Boolean); + const pathParts = path.split('/').filter(Boolean); + parts.forEach((part, i) => { + if (part.startsWith(':')) params[part.slice(1)] = pathParts[i]; + }); + this.params(params); + let Comp = route.component; + if (typeof Comp === 'function' && Comp.toString().includes('import')) { + Comp = () => import(/* @vite-ignore */ route.component).then(m => m.default); + } + const view = Render(() => { + const C = typeof Comp === 'function' ? Comp : () => Comp; + return C(params); + }); + if (currentView) currentView.destroy(); + currentView = view; + outlet.innerHTML = ''; + outlet.appendChild(view.container); + } + }); + return outlet; + }, + + to(path: string) { + window.location.hash = path.startsWith('#') ? path : '#' + path; + }, + back() { window.history.back(); }, + path() { return window.location.hash.slice(1) || '/'; } +}; + +const mounted = new WeakMap < HTMLElement, Runtime> (); + +export function Mount(component: (() => any) | any, target: string | HTMLElement): Runtime { + const targetEl = typeof target === 'string' ? document.querySelector(target) : target; + if (!targetEl) throw new Error('Target not found'); + const prev = mounted.get(targetEl); + if (prev) prev.destroy(); + const instance = Render(typeof component === 'function' ? component : () => component); + targetEl.replaceChildren(instance.container); + mounted.set(targetEl, instance); + return instance; +} + +if (typeof window !== 'undefined') { + (window as any).$ = $; + (window as any).Watch = Watch; + (window as any).$$ = $$; + (window as any).Render = Render; + (window as any).Tag = Tag; + (window as any).Mount = Mount; + (window as any).Router = Router; +} + +export { $ as signal, Watch as effect, $$ as reactive, Render, Tag, Mount, Router }; \ No newline at end of file