UNPKG

hydro-js

Version:

A lightweight reactive library

1,622 lines (1,476 loc) 57.6 kB
declare global { interface Window { $: Document["querySelector"]; $$: Document["querySelectorAll"]; } interface Number { setter(val: any): void; } interface String { setter(val: any): void; } interface Symbol { setter(val: any): void; } interface Boolean { setter(val: any): void; } interface BigInt { setter(val: any): void; } interface Object { setter(val: any): void; } interface Navigator { scheduling: { isInputPending(IsInputPendingOptions?: isInputPendingOptions): boolean; }; } } type isInputPendingOptions = { includeContinuous: boolean; }; export interface hydroObject extends Record<PropertyKey, any> { isProxy: boolean; asyncUpdate: boolean; observe: (key: PropertyKey, fn: Function) => any; getObservers: () => Map<string, Set<Function>>; unobserve: (key?: PropertyKey, handler?: Function) => undefined; } type nodeChanges = Array<[number, number, string | undefined, hydroObject]>; // Circular reference type nodeToChangeMap = Map< Element | Text | nodeChanges, Element | Text | nodeChanges >; interface keyToNodeMap extends Map<string, nodeToChangeMap> {} interface EventObject { event: EventListener; options: AddEventListenerOptions; } type reactiveObject<T> = T & hydroObject & ((setter: any) => void); type eventFunctions = Record<string, EventListener | EventObject>; const enum Placeholder { attribute = "attribute", text = "text", string = "string", isProxy = "isProxy", asyncUpdate = "asyncUpdate", function = "function", template = "template", event = "event", options = "options", observe = "observe", getObservers = "getObservers", unobserve = "unobserve", twoWay = "two-way", change = "change", radio = "radio", checkbox = "checkbox", dummy = "-dummy", reactiveKey = "hydro-reactive-", } // Safari Polyfills window.requestIdleCallback = /* c8 ignore next 4 */ window.requestIdleCallback || ((cb: Function, _: any, start = window.performance.now()) => window.setTimeout(cb, 0, { didTimeout: false, timeRemaining: () => Math.max(0, 5 - (window.performance.now() - start)), })); // Safari Polyfills END const range = document.createRange(); range.selectNodeContents( range.createContextualFragment(`<${Placeholder.template}>`).lastChild! ); const parser = range.createContextualFragment.bind(range); const allNodeChanges = new WeakMap<Text | Element, nodeChanges>(); // Maps a Node against an array of changes. An array is necessary because a node can have multiple variables for one text / attribute. const elemEventFunctions = new WeakMap<Element, Array<EventListener>>(); // Stores event functions in order to compare Elements against each other. const reactivityMap = new WeakMap<hydroObject, keyToNodeMap>(); // Maps Proxy Objects to another Map(proxy-key, node). const bindMap = new WeakMap<hydroObject, Array<Element>>(); // Bind an Element to data. If the data is being unset, the DOM Element disappears too. const tmpSwap = new WeakMap<hydroObject, keyToNodeMap>(); // Take over keyToNodeMap if the new value is a hydro Proxy. Save old reactivityMap entry here, in case for a swap operation. const onRenderMap = new WeakMap<ReturnType<typeof html>, Function>(); // Lifecycle Hook that is being called after rendering const onCleanupMap = new WeakMap<ReturnType<typeof html>, Function>(); // Lifecycle Hook that is being called when unmount function is being called const fragmentToElements = new WeakMap<DocumentFragment, Array<ChildNode>>(); // Used to retreive Elements from DocumentFragment after it has been rendered – for diffing const hydroToReactive = new WeakMap<hydroObject, reactiveObject<any>>(); // Used for internal mapping from hydroKeys to the the Proxy created by the reactive function const _boundFunctions = Symbol("boundFunctions"); // Cache for bound functions in Proxy, so that we create the bound version of each function only once const reactiveSymbol = Symbol("reactive"); const keysSymbol = Symbol("keys"); const viewElementsEventFunctions = new Map<string, eventFunctions>(); const isServerSideCached = isServerSide(); let globalSchedule = true; // Decides whether to schedule rendering and updating (async) let reuseElements = true; // Reuses Elements when rendering let insertBeforeDiffing = false; // Makes sense in Chrome only let shouldSetReactivity = true; let viewElements = false; let ignoreIsConnected = false; const reactivityRegex = new RegExp( isServerSideCached ? `\\{\\{([^]*?)\\}\\}|${Placeholder.reactiveKey}([a-zA-Z0-9_.-]+)` : `\\{\\{([^]*?)\\}\\}` ); const HTML_FIND_INVALID = /<(\/?)(html|head|body)(>|\s.*?>)/g; const newLineRegex = /\n/g; const propChainRegex = /[\.\[\]]/; const onEventRegex = /^on/; // https://html.spec.whatwg.org/#attributes-3 // if value for bool attr is falsy, then remove attr // INFO: draggable and spellcheck are actually using booleans as string! Also, hidden is not really a bool attr, but is making use of the empty string too. Might consider to add 'translate' (yes and no as string) const boolAttrSet = new Set([ "allowfullscreen", "alpha", "async", "autofocus", "autoplay", "checked", "controls", "draggable", "default", "defer", "disabled", "formnovalidate", "hidden", "inert", "ismap", "itemscope", "loop", "multiple", "muted", "nomodule", "novalidate", "open", "playsinline", "readonly", "required", "reversed", "selected", "shadowrootclonable", "shadowrootcustomelementregistry", "shadowrootdelegatesfocus", "shadowrootserializable", "spellcheck", ]); let lastSwapElem: null | Element = null; let internReset = false; const primitiveTypes = new Set([ "number", "string", "symbol", "boolean", "bigint", ]); function isObject(obj: object | unknown): obj is Record<string, any> { return obj != null && typeof obj === "object"; } function isFunction(func: Function | unknown): func is Function { return typeof func === Placeholder.function; } function isTextNode(node: Node): node is Text { return (node as Text).splitText !== undefined; } function isNode(node: Node): node is Node { return node instanceof window.Node; } function isDocumentFragment(node: Node): node is DocumentFragment { return node.nodeType === 11; } function isEventObject(obj: object | unknown): obj is EventObject { return ( isObject(obj) && Placeholder.event in obj && Placeholder.options in obj ); } function isProxy(hydroObject: any): hydroObject is hydroObject { return Reflect.get(hydroObject, Placeholder.isProxy); } function isPromise(obj: any): obj is Promise<any> { return isObject(obj) && typeof obj.then === "function"; } function isServerSide() { return ( window.navigator.userAgent.includes("Node.js") || window.navigator.userAgent.includes("Deno") || window.navigator.userAgent.includes("Bun") || window.navigator.userAgent.includes("HappyDOM") || window.navigator.userAgent.includes("jsdom") ); } function randomText() { const randomChars = "abcdefghijklmnopqrstuvwxyz0123456789"; let result = ""; for (let i = 0; i < 6; i++) { result += randomChars.charAt( Math.floor(Math.random() * randomChars.length) ); } return result; // return Math.random().toString(32).slice(2); } function setGlobalSchedule(willSchedule: boolean): void { globalSchedule = willSchedule; setHydroRecursive(hydro); } function setReuseElements(willReuse: boolean): void { reuseElements = willReuse; } function setInsertDiffing(willInsert: boolean): void { insertBeforeDiffing = willInsert; } function setShouldSetReactivity(willSet: boolean): void { shouldSetReactivity = willSet; } function setIgnoreIsConnected(ignore: boolean): void { ignoreIsConnected = ignore; } function setHydroRecursive(obj: hydroObject) { Reflect.set(obj, Placeholder.asyncUpdate, globalSchedule); for (const value of Object.values(obj)) { if (isObject(value) && isProxy(value)) { setHydroRecursive(value); } } } function setAttribute(node: Element, key: string, val: any): boolean { const isBoolAttr = boolAttrSet.has(key); if (isBoolAttr && !val) { node.removeAttribute(key); return false; } node.setAttribute( key, isFunction(val) && Reflect.has(val, reactiveSymbol) ? val : isBoolAttr ? "" : val ); return true; } function addEventListener( node: Element, eventName: string, obj: EventObject | EventListener ) { node.addEventListener( eventName, isFunction(obj) ? obj : obj.event, isFunction(obj) ? {} : obj.options ); } function html( htmlArray: TemplateStringsArray, ...variables: Array<any> ): Element | DocumentFragment | Text { const eventFunctions: eventFunctions = {}; // Temporarily store a mapping for string -> function, because eventListener have to be registered after the Element's creation let insertNodes: Node[] = []; // Nodes, that will be added after the parsing const resolvedVariables: Array<string> = Array.from({ length: variables.length, }); for (let i = 0; i < variables.length; i++) { const variable = variables[i]; const template = `<${Placeholder.template} id="lbInsertNodes"></${Placeholder.template}>`; if (isNode(variable)) { insertNodes.push(variable); resolvedVariables[i] = template; } else if ( primitiveTypes.has(typeof variable) || Reflect.has(variable, reactiveSymbol) ) { resolvedVariables[i] = String(variable); } else if (isFunction(variable) || isEventObject(variable)) { const funcName = randomText(); Reflect.set(eventFunctions, funcName, variable); viewElements && Reflect.set(viewElementsEventFunctions, funcName, variable); resolvedVariables[i] = funcName; } else if (Array.isArray(variable)) { for (let index = 0; index < variable.length; index++) { const item = variable[index]; if (isNode(item)) { insertNodes.push(item); variable[index] = template; } } resolvedVariables[i] = variable.join(""); } else if (isObject(variable)) { let result = ""; for (const [key, value] of Object.entries(variable)) { if (isFunction(value) || isEventObject(value)) { const funcName = randomText(); Reflect.set(eventFunctions, funcName, value); viewElements && Reflect.set(viewElementsEventFunctions, funcName, value); result += `${key}="${funcName}"`; } else { result += `${key}="${value}"`; } } resolvedVariables[i] = result; } } // Find elements <html|head|body>, as they cannot be created by the parser. Replace them by fake Custom Elements and replace them afterwards. let DOMString = String.raw(htmlArray, ...resolvedVariables).trim(); DOMString = DOMString.replace( HTML_FIND_INVALID, `<$1$2${Placeholder.dummy}$3` ); const DOM = parser(DOMString); // Delay Element iteration and manipulation after the elements have been added to the DOM. if (!viewElements) { fillDOM(DOM, insertNodes, eventFunctions); } // Return DocumentFragment if (DOM.childNodes.length > 1) return DOM; // Return empty Text Node if (!DOM.firstChild) return document.createTextNode(""); // Return Element | Text return DOM.firstChild as Element | Text; } function fillDOM( elem: ReturnType<typeof html>, insertNodes: Node[], eventFunctions: eventFunctions ) { const root = document.createNodeIterator( elem, window.NodeFilter.SHOW_ELEMENT, { acceptNode(element: Element) { return element.localName.endsWith(Placeholder.dummy) ? window.NodeFilter.FILTER_ACCEPT : window.NodeFilter.FILTER_REJECT; }, } ); const nodes = []; let currentNode; while ((currentNode = root.nextNode())) { nodes.push(currentNode as Element); } for (const node of nodes) { const tag = node.localName.replace(Placeholder.dummy, ""); const replacement = document.createElement(tag); /* c8 ignore next 3 */ for (const key of node.getAttributeNames()) { replacement.setAttribute(key, node.getAttribute(key)!); } replacement.append(...node.childNodes); node.replaceWith(replacement); } // Insert HTML Elements, which were stored in insertNodes if (!isTextNode(elem)) { for (const template of elem.querySelectorAll("template[id^=lbInsertNodes]")) template.replaceWith(insertNodes.shift()!); } if (shouldSetReactivity) setReactivity(elem, eventFunctions); } /* c8 ignore start */ type FragmentCase = { children: ReturnType<typeof h>[] }; function h( name: string | ((...args: any[]) => ReturnType<typeof h>) | FragmentCase, props: Record<keyof any, any> | null, ...children: Array<any> ): ReturnType<typeof html> { if (isFunction(name)) return name({ ...props, children }); const elem = typeof name === Placeholder.string ? document.createElement( name as string, props?.hasOwnProperty("is") ? { is: props["is"] } : undefined ) : document.createDocumentFragment(); for (const i in props) { i in elem && !boolAttrSet.has(i) ? //@ts-ignore (elem[i] = props[i]) : setAttribute(elem as HTMLElement, i, props[i]); } if (isDocumentFragment(elem)) { children = (name as FragmentCase).children; } elem.append( ...(children.some((i) => Array.isArray(i)) ? children.map(getChildren).flat() : children) ); if (!viewElements) { setReactivity(elem); } return elem; } function getChildren(child: unknown) { return isObject(child) && !isNode(child as Node) ? Object.values(child) : child; } /* c8 ignore end */ function setReactivity( DOM: ReturnType<typeof html>, eventFunctions?: eventFunctions | typeof viewElementsEventFunctions ) { if (isTextNode(DOM)) { setReactivitySingle(DOM); return; } const elems = document.createNodeIterator( DOM, window.NodeFilter.SHOW_ELEMENT ); let elem; while ((elem = elems.nextNode() as Element)) { for (const key of elem.getAttributeNames()) { // Set functions const val = elem.getAttribute(key)!; if (eventFunctions && key.startsWith("on")) { const eventName = key.replace(onEventRegex, ""); const event = Reflect.get(eventFunctions, val); if (!event) { setReactivitySingle(elem, key, val); continue; } elem.removeAttribute(key); if (isEventObject(event)) { elem.addEventListener(eventName, event.event, event.options); if (elemEventFunctions.has(elem)) { elemEventFunctions.get(elem)!.push(event.event); } else { elemEventFunctions.set(elem, [event.event]); } } else { elem.addEventListener(eventName, event); if (elemEventFunctions.has(elem)) { elemEventFunctions.get(elem)!.push(event); } else { elemEventFunctions.set(elem, [event]); } } } else { setReactivitySingle(elem, key, val); } } let childNode = elem.firstChild; while (childNode) { if ( isTextNode(childNode) && (childNode.nodeValue?.includes("{{") || (isServerSideCached && childNode.nodeValue?.includes(Placeholder.reactiveKey))) ) { setReactivitySingle(childNode); } childNode = childNode.nextSibling; } } } function setReactivitySingle(node: Text): void; // TS function overload function setReactivitySingle(node: Element, key: string, val: string): void; // TS function overload function setReactivitySingle( node: Element | Text, key?: string, val?: string ): void { let attr_OR_text: string, match: RegExpMatchArray | null; if (!key) { attr_OR_text = node.nodeValue!; // nodeValue is (always) defined on Text Nodes } else { attr_OR_text = val!; if (attr_OR_text === "") { // e.g. checked attribute or two-way attribute attr_OR_text = key; if ( attr_OR_text.startsWith("{{") || (isServerSideCached && attr_OR_text.startsWith(Placeholder.reactiveKey)) ) { (node as Element).removeAttribute(attr_OR_text); } } } while ((match = attr_OR_text.match(reactivityRegex))) { // attr_OR_text will be altered in every iteration const [hydroMatch, hydroCurlyPath, hydroPath] = match; const properties = (hydroCurlyPath ?? hydroPath) .trim() .replace(newLineRegex, "") .split(propChainRegex) .filter(Boolean); const [resolvedValue, resolvedObj] = resolveObject(properties); let lastProp = properties[properties.length - 1]; const start = match.index!; let end: number = start + String(resolvedValue).length; if (isNode(resolvedValue)) { node.nodeValue = attr_OR_text.replace(hydroMatch, ""); node.after(resolvedValue); setTraces( start, end, resolvedValue as Element | Text, lastProp, resolvedObj, key ); return; } // Set Text or set Attribute if (isTextNode(node)) { const textContent = isObject(resolvedValue) ? window.JSON.stringify(resolvedValue) : resolvedValue ?? ""; attr_OR_text = attr_OR_text.replace(hydroMatch, textContent); if (attr_OR_text != null) { node.nodeValue = attr_OR_text; } } else { if (key === "bind") { attr_OR_text = attr_OR_text.replace(hydroMatch, ""); node.removeAttribute(key); const proxy = isObject(resolvedValue) && isProxy(resolvedValue) ? resolvedValue : resolvedObj; if (bindMap.has(proxy)) { bindMap.get(proxy)!.push(node); } else { bindMap.set(proxy, [node]); } continue; } else if (key === Placeholder.twoWay) { if (node instanceof window.HTMLSelectElement) { node.value = resolvedValue; changeAttrVal(Placeholder.change, node, resolvedObj, lastProp); } else if ( node instanceof window.HTMLInputElement && node.type === Placeholder.radio ) { node.checked = node.value === resolvedValue; changeAttrVal(Placeholder.change, node, resolvedObj, lastProp); } else if ( node instanceof window.HTMLInputElement && node.type === Placeholder.checkbox ) { node.checked = resolvedValue; changeAttrVal(Placeholder.change, node, resolvedObj, lastProp, true); } else if ( node instanceof window.HTMLTextAreaElement || node instanceof window.HTMLInputElement ) { node.value = resolvedValue; changeAttrVal("input", node, resolvedObj, lastProp); } attr_OR_text = attr_OR_text.replace(hydroMatch, ""); node.toggleAttribute(Placeholder.twoWay); } else if (isFunction(resolvedValue) || isEventObject(resolvedValue)) { attr_OR_text = attr_OR_text.replace(hydroMatch, ""); node.removeAttribute(key!); addEventListener(node, key!.replace(onEventRegex, ""), resolvedValue); } else if (isObject(resolvedValue)) { // Case: setting attrs on Element - <p ${props}> for (const [subKey, subVal] of Object.entries(resolvedValue)) { attr_OR_text = attr_OR_text.replace(hydroMatch, ""); if (isFunction(subVal) || isEventObject(subVal)) { addEventListener(node, subKey.replace(onEventRegex, ""), subVal); } else { lastProp = subKey; if (setAttribute(node, subKey, subVal)) { end = start + String(subVal).length; } else { end = start; } } setTraces( start, end, node, lastProp, resolvedValue as hydroObject, subKey ); } continue; // As we set all Mappings via subKeys } else { attr_OR_text = attr_OR_text.replace(hydroMatch, resolvedValue); if ( !setAttribute( node, key!, attr_OR_text === String(resolvedValue) ? resolvedValue : attr_OR_text ) ) { attr_OR_text = attr_OR_text.replace(resolvedValue, ""); } } } setTraces(start, end, node, lastProp, resolvedObj, key); } } // Same behavior as v-model in https://v3.vuejs.org/guide/forms.html#basic-usage function changeAttrVal( eventName: string, node: HTMLTextAreaElement | HTMLInputElement | HTMLSelectElement, resolvedObj: hydroObject, lastProp: string, isChecked: boolean = false ) { node.addEventListener(eventName, changeHandler); onCleanup(() => node.removeEventListener(eventName, changeHandler), node); function changeHandler({ target }: Event) { Reflect.set( resolvedObj, lastProp, isChecked ? (target as HTMLInputElement).checked : (target as HTMLInputElement).value ); } } function setTraces( start: number, end: number, node: Text | Element, hydroKey: string, resolvedObj: hydroObject, key?: string ): void { // Set WeakMaps, that will be used to track a change for a Node but also to check if a Node has any other changes. const change: nodeChanges[number] = [start, end, key, resolvedObj]; const changeArr = [change]; if (allNodeChanges.has(node)) { allNodeChanges.get(node)!.push(change); } else { allNodeChanges.set(node, [change]); // Use own version. Otherwise changes, will lead to incorrect changes in the DOM. } if (reactivityMap.has(resolvedObj)) { const keyToNodeMap = reactivityMap.get(resolvedObj)!; const nodeToChangeMap = keyToNodeMap.get(hydroKey); if (nodeToChangeMap) { if (nodeToChangeMap.has(node)) { (nodeToChangeMap.get(node)! as nodeChanges).push(change); } else { nodeToChangeMap.set(changeArr, node); nodeToChangeMap.set(node, changeArr); } } else { keyToNodeMap.set( hydroKey, //@ts-ignore new Map([ [changeArr, node], [node, changeArr], ]) ); } } else { reactivityMap.set( resolvedObj, new Map([ [ hydroKey, //@ts-ignore new Map([ [changeArr, node], [node, changeArr], ]), ], ]) ); } } // Helper function to return a value and hydro obj from a chain of properties function resolveObject(propertyArray: Array<PropertyKey>): [any, hydroObject] { let value: any, prev: hydroObject; value = prev = hydro; for (const prop of propertyArray) { prev = value; value = Reflect.get(prev, prop); } return [value, prev]; } function compareEvents( elem: Element | Text, where: Element | Text, onlyTextChildren?: boolean ): boolean { const elemFunctions: Function[] = []; const whereFunctions: Function[] = []; if (isTextNode(elem)) { if (onRenderMap.has(elem)) elemFunctions.push(onRenderMap.get(elem)!); if (onCleanupMap.has(elem)) elemFunctions.push(onCleanupMap.get(elem)!); if (onRenderMap.has(where)) whereFunctions.push(onRenderMap.get(where)!); if (onCleanupMap.has(where)) whereFunctions.push(onCleanupMap.get(where)!); return ( elemFunctions.length === whereFunctions.length && String(elemFunctions) === String(whereFunctions) ); } if (elemEventFunctions.has(elem)) { elemFunctions.push(...elemEventFunctions.get(elem)!); } if (elemEventFunctions.has(where as Element)) { whereFunctions.push(...elemEventFunctions.get(where as Element)!); } if (onRenderMap.has(elem)) elemFunctions.push(onRenderMap.get(elem)!); if (onCleanupMap.has(elem)) elemFunctions.push(onCleanupMap.get(elem)!); if (onRenderMap.has(where)) whereFunctions.push(onRenderMap.get(where)!); if (onCleanupMap.has(where)) whereFunctions.push(onCleanupMap.get(where)!); if (elemFunctions.length !== whereFunctions.length) return false; if (String(elemFunctions) !== String(whereFunctions)) return false; for (let i = 0; i < elem.childNodes.length; i++) { const elemChild = elem.childNodes[i] as Element | Text; const whereChild = where.childNodes[i] as Element | Text; if (onlyTextChildren) { if (isTextNode(elemChild)) { if (!compareEvents(elemChild, whereChild, onlyTextChildren)) { return false; } } } else if (!compareEvents(elemChild, whereChild)) { return false; } } return true; } function compare( elem: Element | DocumentFragment, where: Element | DocumentFragment | Text, onlyTextChildren?: boolean ): boolean { if (isDocumentFragment(elem) || isDocumentFragment(where)) return false; return ( elem.isEqualNode(where) && compareEvents(elem, where, onlyTextChildren) ); } function render( elem: ReturnType<typeof html> | reactiveObject<any>, where: ReturnType<typeof html> | string = "", shouldSchedule = globalSchedule ): ChildNode["remove"] { /* c8 ignore next 4 */ if (shouldSchedule) { schedule(render, elem, where, false); return unmount(elem); } // Get elem value if elem is reactiveObject if (Reflect.has(elem, reactiveSymbol)) { elem = getValue(elem); } // Store elements of documentFragment for later unmount let elemChildren: Array<ChildNode> = []; if (isDocumentFragment(elem)) { elemChildren = Array.from(elem.childNodes); fragmentToElements.set(elem, elemChildren); // For diffing later } if (!where) { document.body.append(elem); } else { if (typeof where === Placeholder.string) { const resolveStringToElement = $(where as string); if (resolveStringToElement) { where = resolveStringToElement; } else { return noop; } } if (!reuseElements) { replaceElement(elem, where as Element); } else { if (isTextNode(elem)) { replaceElement(elem, where as Element); } else if (!compare(elem, where as Element | DocumentFragment | Text)) { treeDiff( elem as Element | DocumentFragment, where as Element | DocumentFragment | Text ); } } } runLifecyle(elem, onRenderMap); for (const subElem of elemChildren) { runLifecyle(subElem as Element | Text, onRenderMap); } return unmount(isDocumentFragment(elem) ? elemChildren : elem); } function noop() {} function executeLifecycle( node: ReturnType<typeof html>, lifecyleMap: typeof onRenderMap | typeof onCleanupMap ) { if (lifecyleMap.has(node)) { const fn = lifecyleMap.get(node)!; if (globalSchedule) { schedule(fn); } else { fn(); } lifecyleMap.delete(node as Element); } } function runLifecyle( node: ReturnType<typeof html>, lifecyleMap: typeof onRenderMap | typeof onCleanupMap ) { if ( (lifecyleMap === onRenderMap && !calledOnRender) || (lifecyleMap === onCleanupMap && !calledOnCleanup) ) return; executeLifecycle(node, lifecyleMap); const elements = document.createNodeIterator( node as Node, window.NodeFilter.SHOW_ELEMENT ); let subElem; while ((subElem = elements.nextNode())) { executeLifecycle(subElem as Element, lifecyleMap); let childNode = subElem.firstChild; while (childNode) { if (isTextNode(childNode)) { executeLifecycle(childNode as Text, lifecyleMap); } childNode = childNode.nextSibling; } } } function filterTag2Elements( tag2Elements: Map<string, Array<Element>>, root: Element ) { for (const [localName, list] of tag2Elements.entries()) { // Process list in reverse to avoid index issues when splicing for (let i = list.length - 1; i >= 0; i--) { const elem = list[i]; if (root.contains(elem) || root.isSameNode(elem)) { list.splice(i, 1); } } if (list.length === 0) { tag2Elements.delete(localName); } } } function treeDiff( elem: Element | DocumentFragment, where: Element | DocumentFragment | Text ) { const elemElements = [...elem.querySelectorAll("*")]; if (!isDocumentFragment(elem)) elemElements.unshift(elem); let whereElements: typeof elemElements = []; if (!isTextNode(where)) { whereElements = [...where.querySelectorAll("*")]; if (!isDocumentFragment(where)) whereElements.unshift(where); } let template: HTMLTemplateElement | HTMLDivElement; if (insertBeforeDiffing) { template = document.createElement(isServerSideCached ? "div" : "template"); /* c8 ignore next 3 */ if (where === document.documentElement) { where.append(template); } else { if (isDocumentFragment(where)) { fragmentToElements.get(where)![0].before(template); } else { where.before(template); } } template.append(elem); } // Create Mapping for easier diffing, eg: "div" -> [...Element] const tag2Elements = new Map<string, Array<Element>>(); for (const wElem of whereElements) { /* c8 ignore next 2 */ if (insertBeforeDiffing && wElem === template!) return; if (tag2Elements.has(wElem.localName)) { tag2Elements.get(wElem.localName)!.push(wElem); } else { tag2Elements.set(wElem.localName, [wElem]); } } // Re-use any where Element if possible, then remove elem Element for (const subElem of elemElements) { const sameElements = tag2Elements!.get(subElem.localName); if (sameElements) { for (const whereElem of sameElements) { if (compare(subElem, whereElem, true)) { subElem.replaceWith(whereElem); runLifecyle(subElem, onCleanupMap); filterTag2Elements(tag2Elements, whereElem); break; } } } } if (insertBeforeDiffing) { const newElems = isDocumentFragment(elem) ? Array.from(template!.childNodes) : [elem]; if (isDocumentFragment(where)) { const oldElems = fragmentToElements.get(where)!; for (const e of newElems) oldElems[0].before(e); for (const e of oldElems) e.remove(); } else { if (where instanceof window.HTMLHtmlElement) { replaceElement(elem, where); } else { where.replaceWith(...newElems); } } template!.remove(); runLifecyle(where, onCleanupMap); } else { replaceElement(elem, where); } tag2Elements.clear(); } function replaceElement( elem: ReturnType<typeof html>, where: ReturnType<typeof html> ) { if (isDocumentFragment(where)) { const fragmentChildren = fragmentToElements.get(where)!; if (isDocumentFragment(elem)) { const fragmentElements = Array.from(elem.childNodes); for (let index = 0; index < fragmentChildren.length; index++) { const fragWhere = fragmentChildren[index]; if (index < fragmentElements.length) { render(fragmentElements[index], fragWhere as Element); } else { fragWhere.remove(); } } } else { for (let index = 0; index < fragmentChildren.length; index++) { const fragWhere = fragmentChildren[index]; if (index === 0) { render(elem, fragWhere as Element); } else { fragWhere.remove(); } } } } else if (isServerSideCached) { if ( isServerSideCached && elem instanceof window.HTMLHtmlElement && where instanceof window.HTMLHtmlElement ) { for (const key of elem.getAttributeNames()) { setAttribute(where, key, elem.getAttribute(key)); } where.replaceChildren(...elem.childNodes); } else { where.replaceWith(elem); } } else { where.replaceWith(elem); } runLifecyle(where, onCleanupMap); } function unmount<T = ReturnType<typeof html> | Array<ChildNode>>(elem: T) { if (Array.isArray(elem)) { return () => elem.forEach(removeElement); } else { return () => removeElement(elem as unknown as Text | Element); } } function removeElement(elem: Text | Element) { if (!ignoreIsConnected && elem.isConnected) { elem.remove(); runLifecyle(elem, onCleanupMap); } } /* c8 ignore next 13 */ async function schedule(fn: Function, ...args: any): Promise<void> { if ("scheduler" in window) { // @ts-ignore window.scheduler.postTask(() => fn(...args), { priority: "user-blocking" }); } else { window.requestIdleCallback(() => fn(...args)); } } function reactive<T>(initial: T): reactiveObject<T> { let key: string; do key = randomText(); while (Reflect.has(hydro, key)); Reflect.set(hydro, key, initial); Reflect.set(setter, reactiveSymbol, true); const chainKeysProxy = chainKeys(setter, [key]); if (isObject(initial)) { hydroToReactive.set(Reflect.get(hydro, key), chainKeysProxy); } return chainKeysProxy; function setter<U>(val: U) { const keys = // @ts-ignore (this && Reflect.has(this, reactiveSymbol) ? this : chainKeysProxy)[ keysSymbol.description! ]; const [resolvedValue, resolvedObj] = resolveObject(keys); const lastProp = keys[keys.length - 1]; if (isFunction(val)) { const returnVal = val(resolvedValue); const sameObject = resolvedValue === returnVal; if (sameObject) return; Reflect.set(resolvedObj, lastProp, returnVal ?? resolvedValue); } else { Reflect.set(resolvedObj, lastProp, val); } } } function chainKeys(initial: Function | any, keys: Array<PropertyKey>): any { return new Proxy(initial, { get(target, subKey, _receiver) { if (subKey === reactiveSymbol.description) return true; if (subKey === keysSymbol.description) { return keys; } if (subKey === Symbol.toPrimitive) { return () => isServerSideCached ? `${Placeholder.reactiveKey}${keys.join(".")}` : `{{${keys.join(".")}}}`; } return chainKeys(target, [...keys, subKey]) as hydroObject & ((setter: any) => void); }, }); } function getReactiveKeys(reactiveHydro: reactiveObject<any>) { const keys = reactiveHydro[keysSymbol.description!]; const lastProp = keys[keys.length - 1]; return [lastProp, keys.length === 1]; } function unset(reactiveHydro: reactiveObject<any>): void { const [lastProp, oneKey] = getReactiveKeys(reactiveHydro); if (oneKey) { Reflect.set(hydro, lastProp, null); if (hydroToReactive.has(hydro[lastProp])) { hydroToReactive.delete(hydro[lastProp]); } } else { const [_, resolvedObj] = resolveObject( reactiveHydro[keysSymbol.description!] ); Reflect.set(resolvedObj, lastProp, null); } } function setAsyncUpdate( reactiveHydro: reactiveObject<any>, asyncUpdate: boolean ) { const [_, oneKey] = getReactiveKeys(reactiveHydro); if (oneKey) { hydro.asyncUpdate = asyncUpdate; } else { const [_, resolvedObj] = resolveObject( reactiveHydro[keysSymbol.description!] ); resolvedObj.asyncUpdate = asyncUpdate; } } function observe(reactiveHydro: reactiveObject<any>, fn: Function) { const [lastProp, oneKey] = getReactiveKeys(reactiveHydro); if (oneKey) { hydro.observe(lastProp, fn); } else { const [_, resolvedObj] = resolveObject( reactiveHydro[keysSymbol.description!] ); resolvedObj.observe(lastProp, fn); } } function unobserve(reactiveHydro: reactiveObject<any>) { const [lastProp, oneKey] = getReactiveKeys(reactiveHydro); if (oneKey) { hydro.unobserve(lastProp); } else { const [_, resolvedObj] = resolveObject( reactiveHydro[keysSymbol.description!] ); resolvedObj.unobserve(lastProp); } } function ternary( condition: Function | reactiveObject<any>, trueVal: any, falseVal: any, reactiveHydro: reactiveObject<any> = condition ) { const checkCondition = (cond: any) => ( !Reflect.has(condition, reactiveSymbol) && isFunction(condition) ? condition(cond) : isPromise(cond) ? false : cond ) ? isFunction(trueVal) ? trueVal() : trueVal : isFunction(falseVal) ? falseVal() : falseVal; const ternaryValue = reactive(checkCondition(getValue(reactiveHydro))); observe(reactiveHydro, (newVal: any) => { newVal === null ? unset(ternaryValue) : ternaryValue(checkCondition(newVal)); }); return ternaryValue; } function emit( eventName: string, data: any, who: EventTarget, options: object = { bubbles: true } ) { who.dispatchEvent( new window.CustomEvent(eventName, { ...options, detail: data }) ); } let trackDeps = false; const trackProxies = new Set<hydroObject>(); const trackMap = new WeakMap<hydroObject, Set<PropertyKey>>(); const unobserveMap = new WeakMap< Function, Array<{ proxy: hydroObject; key: PropertyKey }> >(); function watchEffect(fn: Function) { trackDeps = true; fn(); trackDeps = false; const reRun = (newVal: PropertyKey) => { if (newVal !== null) fn(); }; for (const proxy of trackProxies) { if (!trackMap.has(proxy)) continue; for (const key of trackMap.get(proxy)!) { proxy.observe(key, reRun); if (unobserveMap.has(reRun)) { unobserveMap.get(reRun)!.push({ proxy, key }); } else { unobserveMap.set(reRun, [{ proxy, key }]); } } trackMap.delete(proxy); } trackProxies.clear(); return () => unobserveMap .get(reRun)! .forEach((entry) => entry.proxy.unobserve(entry.key, reRun)); } function getValue<T extends object>(reactiveHydro: T): T { const [resolvedValue] = resolveObject( Reflect.get(reactiveHydro, keysSymbol.description!) as PropertyKey[] ); return resolvedValue; } let calledOnRender = false; function onRender( fn: Function, elem: ReturnType<typeof html>, ...args: Array<any> ) { calledOnRender = true; onRenderMap.set(elem, args.length ? fn.bind(fn, ...args) : fn); } let calledOnCleanup = false; function onCleanup( fn: Function, elem: ReturnType<typeof html>, ...args: Array<any> ) { calledOnCleanup = true; onCleanupMap.set(elem, args.length ? fn.bind(fn, ...args) : fn); } // Core of the library function generateProxy(obj?: Record<PropertyKey, unknown>): hydroObject { const handlers = Symbol("handlers"); // For observer pattern const boundFunctions = new WeakMap<Function, Function>(); const proxy = new Proxy(obj ?? {}, { // If receiver is a getter, then it is the object on which the search first started for the property|key -> Proxy set(target, key, val, receiver) { if (trackDeps) { trackProxies.add(receiver); if (trackMap.has(receiver)) { trackMap.get(receiver)!.add(key); } else { trackMap.set(receiver, new Set([key])); } } let returnSet = true; let oldVal = Reflect.get(target, key, receiver); if (oldVal === val) return returnSet; // Reset Path - mostly GC if (val === null) { // Remove entry from reactitivyMap underlying Map if (reactivityMap.has(receiver)) { const key2NodeMap = reactivityMap.get(receiver)!; key2NodeMap.delete(String(key)); if (key2NodeMap.size === 0) { reactivityMap.delete(receiver); } } // Inform the Observers about null change and unobserve const observer = Reflect.get(target, handlers, receiver); if (observer.has(key)) { let set = observer.get(key); for (const handler of set) { handler(null, oldVal); } set.clear(); receiver.unobserve(key); } // If oldVal is a Proxy - clean it if (isObject(oldVal) && isProxy(oldVal)) { reactivityMap.delete(oldVal); if (bindMap.has(oldVal)) { bindMap.get(oldVal)!.forEach(removeElement); bindMap.delete(oldVal); } } else { if (bindMap.has(receiver)) { bindMap.get(receiver)!.forEach(removeElement); bindMap.delete(receiver); } } // Remove item from array /* c8 ignore next 4 */ if (!internReset && Array.isArray(receiver)) { receiver.splice(Number(key), 1); return returnSet; } return Reflect.deleteProperty(receiver, key); } // Set the value if (isPromise(val)) { val .then((value) => { // No Reflect in order to trigger the Getter receiver[key] = value; }) .catch((e) => { console.error(e); receiver[key] = null; }); returnSet = Reflect.set(target, key, val, receiver); return returnSet; } else if (isNode(val)) { returnSet = Reflect.set(target, key, val, receiver); } else if (isObject(val) && !isProxy(val)) { returnSet = Reflect.set(target, key, generateProxy(val), receiver); // Recursively set properties to Proxys too for (const [subKey, subVal] of Object.entries(val)) { if (isObject(subVal) && !isProxy(subVal)) { Reflect.set(val, subKey, generateProxy(subVal)); } } } else { if ( !reuseElements && Array.isArray(receiver) && receiver.includes(oldVal) && receiver.includes(val) && /* c8 ignore start */ bindMap.has(val) ) { const [elem] = bindMap.get(val)!; if (lastSwapElem !== elem) { const [oldElem] = bindMap.get(oldVal)!; lastSwapElem = oldElem; const prevElem = elem.previousSibling!; const prevOldElem = oldElem.previousSibling!; // Move it in the array too without triggering the proxy set const index = receiver.findIndex((i) => i === val); receiver.splice(Number(key), 1, val); receiver.splice(index, 1, oldVal); prevElem.after(oldElem); prevOldElem.after(elem); } return true; } else { /* c8 ignore end */ returnSet = Reflect.set(target, key, val, receiver); } } const newVal = Reflect.get(target, key, receiver); // Check if DOM needs to be updated // oldVal can be Proxy value too if (reactivityMap.has(oldVal)) { checkReactivityMap(oldVal, key, newVal, oldVal); } else if (reactivityMap.has(receiver)) { checkReactivityMap(receiver, key, newVal, oldVal); } // current val (before setting) is a proxy - take over its keyToNodeMap if (isObject(val) && isProxy(val)) { if (reactivityMap.has(oldVal)) { // Store old reactivityMap if it is a swap operation reuseElements && tmpSwap.set(oldVal, reactivityMap.get(oldVal)!); if (tmpSwap.has(val)) { reactivityMap.set(oldVal, tmpSwap.get(val)!); tmpSwap.delete(val); } else { reactivityMap.set(oldVal, reactivityMap.get(val)!); } } } // Inform the Observers if (returnSet) { Reflect.get(target, handlers, receiver) .get(key) ?.forEach((handler: Function) => handler(newVal, oldVal)); } // If oldVal is a Proxy - clean it !reuseElements && oldVal && cleanProxy(oldVal); return returnSet; }, // fix proxy bugs, e.g Map get(target, prop, receiver) { if (trackDeps) { trackProxies.add(receiver); if (trackMap.has(receiver)) { trackMap.get(receiver)!.add(prop); } else { trackMap.set(receiver, new Set([prop])); } } const value = Reflect.get(target, prop, receiver); if (!isFunction(value)) { return value; } if (!boundFunctions.has(value)) { boundFunctions.set(value, value.bind(target)); } return boundFunctions.get(value); }, } as ProxyHandler<hydroObject>); Reflect.defineProperty(proxy, Placeholder.isProxy, { value: true, }); Reflect.defineProperty(proxy, Placeholder.asyncUpdate, { value: globalSchedule, writable: true, }); Reflect.defineProperty(proxy, handlers, { value: new Map<PropertyKey, Set<Function>>(), }); Reflect.defineProperty(proxy, Placeholder.observe, { value(key: PropertyKey, handler: Function) { const map = Reflect.get(proxy, handlers) as Map< PropertyKey, Set<Function> >; if (map.has(key)) { map.get(key)!.add(handler); } else { map.set(key, new Set([handler])); } }, configurable: true, }); Reflect.defineProperty(proxy, Placeholder.getObservers, { value() { return Reflect.get(proxy, handlers); }, configurable: true, }); Reflect.defineProperty(proxy, Placeholder.unobserve, { value(key: PropertyKey, handler: Function) { const map = Reflect.get(proxy, handlers) as Map< PropertyKey, Set<Function> >; if (key) { if (map.has(key)) { if (handler == null) { map.delete(key); } else { const set = map.get(key); if (set?.has(handler)) { set.delete(handler); } } } /* c8 ignore next 3 */ } else { map.clear(); } }, configurable: true, }); if (!obj) Reflect.defineProperty(proxy, _boundFunctions, { value: boundFunctions, }); return proxy as hydroObject; } function cleanProxy(proxy: any) { if (isObject(proxy) && isProxy(proxy)) { reactivityMap.delete(proxy); /* c8 ignore next 4 */ if (bindMap.has(proxy)) { bindMap.get(proxy)!.forEach(removeElement); bindMap.delete(proxy); } } } function checkReactivityMap(obj: any, key: PropertyKey, val: any, oldVal: any) { const keyToNodeMap = reactivityMap.get(obj)!; const nodeToChangeMap = keyToNodeMap.get(String(key)); if (nodeToChangeMap) { /* c8 ignore next 5 */ if (Reflect.get(obj, Placeholder.asyncUpdate)) { schedule(updateDOM, nodeToChangeMap, val, oldVal); } else { updateDOM(nodeToChangeMap, val, oldVal); } } if (isObject(val)) { const entries = Object.entries(val); for (const [subKey, subVal] of entries) { const subOldVal = (isObject(oldVal) && Reflect.get(oldVal, subKey)) || oldVal; const nodeToChangeMap = keyToNodeMap.get(subKey); if (nodeToChangeMap) { /* c8 ignore next 5 */ if (Reflect.get(obj, Placeholder.asyncUpdate)) { schedule(updateDOM, nodeToChangeMap, subVal, subOldVal); } else { updateDOM(nodeToChangeMap, subVal, subOldVal); } } } } } function updateDOM(nodeToChangeMap: nodeToChangeMap, val: any, oldVal: any) { nodeToChangeMap.forEach((entry) => { // Circular reference in order to keep Memory low if (isNode(entry as Text)) { /* c8 ignore next 5 */ if (!ignoreIsConnected && !(entry as Node).isConnected) { const tmpChange = nodeToChangeMap.get(entry)!; nodeToChangeMap.delete(entry); nodeToChangeMap.delete(tmpChange); } return; // Continue in forEach } // For each change of the node update either attribute or textContent for (const change of entry as nodeChanges) { const node = nodeToChangeMap.get(entry) as Element | Text; const [start, end, key] = change; let useStartEnd = false; if (isNode(val) && (!isServerSideCached || val !== node)) { replaceElement(val as Element, node); if (isServerSideCached || val !== node) { nodeToChangeMap.delete(node); if (!isDocumentFragment(val)) { nodeToChangeMap.set(val as Element, entry); nodeToChangeMap.set(entry, val as Element); } } } else if (isTextNode(node)) { useStartEnd = true; let text = node.nodeValue!; node.nodeValue = text.substring(0, start) + String(val) + text.substring(end); } else { if (key === Placeholder.twoWay) {