UNPKG

@furystack/shades

Version:

A lightweight UI framework for FuryStack with JSX support

444 lines 16 kB
/** * VNode-based reconciliation for Shades. * * Instead of creating real DOM elements during each render and then diffing them, * the JSX factory produces lightweight VNode descriptors. A reconciler diffs the * previous VNode tree against the new one and applies surgical DOM updates using * tracked `_el` references. */ import { SVG_NS, isSvgTag } from './svg.js'; // --------------------------------------------------------------------------- // Brands & sentinels // --------------------------------------------------------------------------- const VNODE_BRAND = 'vnode'; const VTEXT_BRAND = 'vtext'; /** * Sentinel type used as VNode.type for JSX fragments (`<>...</>`). */ export const FRAGMENT = Symbol('fragment'); /** * Sentinel type for VNodes that wrap a pre-existing real DOM node. * Used when shadeChildren (created outside render mode) flow into a VNode render. */ export const EXISTING_NODE = Symbol('existing-node'); // --------------------------------------------------------------------------- // Type guards // --------------------------------------------------------------------------- export const isVNode = (v) => typeof v === 'object' && v !== null && v._brand === VNODE_BRAND; export const isVTextNode = (v) => typeof v === 'object' && v !== null && v._brand === VTEXT_BRAND; // --------------------------------------------------------------------------- // VNode creation // --------------------------------------------------------------------------- /** * Recursively flattens raw JSX children into a flat VChild array. * - Strings and numbers become VTextNodes. * - Fragment VNodes are inlined (their children are spliced in). * - Nullish / boolean values are skipped. */ export const flattenVChildren = (raw) => { const result = []; for (const child of raw) { if (child === null || child === undefined || child === false || child === true) continue; if (typeof child === 'string') { result.push({ _brand: VTEXT_BRAND, text: child }); } else if (typeof child === 'number') { result.push({ _brand: VTEXT_BRAND, text: String(child) }); } else if (Array.isArray(child)) { result.push(...flattenVChildren(child)); } else if (isVNode(child)) { if (child.type === FRAGMENT) { result.push(...child.children); } else { result.push(child); } } else if (isVTextNode(child)) { result.push(child); } else if (child instanceof Node) { // Real DOM node from shadeChildren (created outside render mode). // Wrap it so the reconciler can track it. result.push({ _brand: VNODE_BRAND, type: EXISTING_NODE, props: {}, children: [], _el: child }); } } return result; }; /** * Creates a VNode descriptor. Used as the JSX factory during renders. * * For intrinsic elements (string type), the returned VNode includes DOM-shim * methods (`setAttribute`, `appendChild`, etc.) so that component code which * creates intermediate JSX and calls DOM methods on it continues to work. * * @param type Tag name, Shade factory function, or null (fragment) * @param props Element props / component props * @param rawChildren Varargs children (strings, VNodes, arrays, etc.) */ export const createVNode = (type, props, ...rawChildren) => { const children = flattenVChildren(rawChildren); const vnode = { _brand: VNODE_BRAND, type: type === null ? FRAGMENT : type, props: props ? { ...props } : {}, children, }; // For intrinsic elements, add DOM-shim methods so that component render code // which does `const el = <div/>; el.setAttribute(...)` still works in VNode mode. if (typeof type === 'string') { const v = vnode; v.setAttribute = (name, value) => { vnode.props[name] = value; }; v.removeAttribute = (name) => { delete vnode.props[name]; }; v.getAttribute = (name) => { return vnode.props[name] ?? null; }; v.hasAttribute = (name) => { return name in vnode.props; }; v.appendChild = (child) => { if (child instanceof Node) { vnode.children.push({ _brand: VNODE_BRAND, type: EXISTING_NODE, props: {}, children: [], _el: child }); } else if (isVNode(child) || isVTextNode(child)) { vnode.children.push(child); } return child; }; v.tagName = type.toUpperCase(); v.nodeName = type.toUpperCase(); } return vnode; }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Shallow-compares two props objects. Returns true if all keys and values match. */ export const shallowEqual = (a, b) => { if (a === b) return true; const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) { if (a[key] !== b[key]) return false; } return true; }; /** * Converts a render result (VNode | HTMLElement | string | null) into a flat * VChild array suitable for `patchChildren`. * * Real DOM elements can appear here when component state stores elements created * outside renderMode (e.g. in async callbacks like NestedRouter's `updateUrl`). */ export const toVChildArray = (renderResult) => { if (renderResult === null || renderResult === undefined) return []; if (typeof renderResult === 'string' || typeof renderResult === 'number') { return [{ _brand: VTEXT_BRAND, text: String(renderResult) }]; } if (isVNode(renderResult)) { if (renderResult.type === FRAGMENT) return renderResult.children; return [renderResult]; } // Real DOM element (from async code that ran outside renderMode) if (renderResult instanceof DocumentFragment) { return Array.from(renderResult.childNodes).map((node) => ({ _brand: VNODE_BRAND, type: EXISTING_NODE, props: {}, children: [], _el: node, })); } if (renderResult instanceof Node) { return [{ _brand: VNODE_BRAND, type: EXISTING_NODE, props: {}, children: [], _el: renderResult }]; } return []; }; // --------------------------------------------------------------------------- // Props / style application // --------------------------------------------------------------------------- const setProp = (el, key, value) => { if (key === 'ref') return; if (key === 'style' && typeof value === 'object' && value !== null) { for (const [sk, sv] of Object.entries(value)) { ; el.style[sk] = sv; } return; } if (el instanceof SVGElement) { if (key === 'className') { el.setAttribute('class', String(value)); } else if (key.startsWith('on') && typeof value === 'function') { ; el[key] = value; } else if (value === null || value === undefined || value === false) { el.removeAttribute(key); } else { // eslint-disable-next-line @typescript-eslint/no-base-to-string el.setAttribute(key, String(value)); } return; } if (key.startsWith('data-') || key.startsWith('aria-')) { el.setAttribute(key, typeof value === 'string' ? value : ''); } else { ; el[key] = value; } }; const removeProp = (el, key) => { if (key === 'ref') return; if (el instanceof SVGElement) { el.removeAttribute(key === 'className' ? 'class' : key); return; } if (key === 'style') { el.removeAttribute('style'); } else if (key.startsWith('data-') || key.startsWith('aria-')) { el.removeAttribute(key); } else if (key.startsWith('on')) { ; el[key] = null; } else { try { ; el[key] = ''; } catch { // Some properties are read-only } } }; const patchStyle = (el, oldStyle, newStyle) => { const style = el.style; const oldS = oldStyle || {}; const newS = newStyle || {}; for (const key of Object.keys(oldS)) { if (!(key in newS)) { style[key] = ''; } } for (const [key, value] of Object.entries(newS)) { if (oldS[key] !== value) { style[key] = value; } } }; /** * Applies all props to a freshly created element (initial mount). */ const applyProps = (el, props) => { for (const [key, value] of Object.entries(props)) { setProp(el, key, value); } }; /** * Diffs old and new props and applies minimal updates to a live DOM element. */ export const patchProps = (el, oldProps, newProps) => { // Remove props that no longer exist for (const key of Object.keys(oldProps)) { if (!(key in newProps)) { removeProp(el, key); } } // Add / update props for (const [key, value] of Object.entries(newProps)) { if (key === 'style') { patchStyle(el, oldProps.style, value); } else if (oldProps[key] !== value) { setProp(el, key, value); } } }; // --------------------------------------------------------------------------- // Mount (VNode tree → real DOM) // --------------------------------------------------------------------------- /** * Creates real DOM nodes from a VChild and optionally appends to a parent. * Sets `_el` on the VChild so subsequent patches can find the DOM node. * @returns The created DOM node. */ export const mountChild = (child, parent) => { if (child._brand === VTEXT_BRAND) { const text = document.createTextNode(child.text); child._el = text; if (parent) parent.appendChild(text); return text; } // Pre-existing real DOM node (from shadeChildren) if (child.type === EXISTING_NODE) { if (parent && child._el) parent.appendChild(child._el); return child._el; } // Shade component if (typeof child.type === 'function') { const factory = child.type; const el = factory(child.props, child.children); child._el = el; if (parent) parent.appendChild(el); return el; } // Intrinsic element const tag = child.type; const el = isSvgTag(tag) ? document.createElementNS(SVG_NS, tag) : document.createElement(tag); applyProps(el, child.props); child._el = el; for (const c of child.children) { mountChild(c, el); } if (parent) parent.appendChild(el); // Set ref after the element is fully created and appended const ref = child.props?.ref; if (ref) { ; ref.current = el; } return el; }; // --------------------------------------------------------------------------- // Unmount (remove real DOM) // --------------------------------------------------------------------------- /** * Removes the DOM node associated with a VChild from its parent. */ export const unmountChild = (child) => { // Clear ref before removing from DOM if (child._brand === VNODE_BRAND && child.props?.ref) { ; child.props.ref.current = null; } const node = child._el; if (node?.parentNode) { node.parentNode.removeChild(node); } }; // --------------------------------------------------------------------------- // Patch (diff old VNode tree vs new VNode tree → DOM updates) // --------------------------------------------------------------------------- /** * Patches a single old/new VChild pair. Updates the real DOM in place when * possible, or replaces the DOM node when types differ. */ const patchChild = (_parentEl, oldChild, newChild) => { // Both text nodes if (oldChild._brand === VTEXT_BRAND && newChild._brand === VTEXT_BRAND) { if (oldChild.text !== newChild.text && oldChild._el) { oldChild._el.textContent = newChild.text; } newChild._el = oldChild._el; return; } // Both element/component VNodes with the same type if (oldChild._brand === VNODE_BRAND && newChild._brand === VNODE_BRAND && oldChild.type === newChild.type) { if (oldChild.type === EXISTING_NODE) { // --- Pre-existing DOM node --- newChild._el = newChild._el || oldChild._el; if (oldChild._el !== newChild._el && oldChild._el?.parentNode) { oldChild._el.parentNode.replaceChild(newChild._el, oldChild._el); } return; } if (typeof oldChild.type === 'function') { // --- Shade component boundary --- const el = oldChild._el; newChild._el = el; const propsChanged = !shallowEqual(oldChild.props, newChild.props); // For children, reference check is enough -- if the parent re-rendered, // the children VNodes are always fresh objects, so we compare lengths // and item identity as a fast heuristic. const childrenChanged = oldChild.children.length !== newChild.children.length || oldChild.children.some((c, i) => c !== newChild.children[i]); if (propsChanged || childrenChanged) { if (propsChanged) { el.props = newChild.props; patchProps(el, oldChild.props, newChild.props); } el.shadeChildren = newChild.children; el.updateComponentSync(); } return; } // --- Intrinsic element --- const el = oldChild._el; newChild._el = el; patchProps(el, oldChild.props, newChild.props); patchChildren(el, oldChild.children, newChild.children); // Update refs: clear old ref if different, set new ref const oldRef = oldChild.props?.ref; const newRef = newChild.props?.ref; if (oldRef !== newRef) { if (oldRef) oldRef.current = null; if (newRef) newRef.current = el; } return; } // Types differ → replace const oldNode = oldChild._el; if (oldNode && oldNode.parentNode) { const newNode = mountChild(newChild, null); oldNode.parentNode.replaceChild(newNode, oldNode); } else { mountChild(newChild, _parentEl); } }; /** * Reconciles an array of old VChildren against new VChildren inside a parent * DOM element. Patches matching pairs, removes excess old children, and * mounts excess new children. * * **Note:** This uses positional (index-based) matching, not key-based * reconciliation. Reordering list items will cause all children from the * reorder point onward to be patched/replaced rather than moved. For * dynamic lists where order changes frequently, wrap each item in its own * Shade component so that the component boundary prevents unnecessary * inner-DOM churn. */ export const patchChildren = (parentEl, oldChildren, newChildren) => { const commonLen = Math.min(oldChildren.length, newChildren.length); for (let i = 0; i < commonLen; i++) { patchChild(parentEl, oldChildren[i], newChildren[i]); } // Remove excess old children (iterate backwards to avoid index issues) for (let i = oldChildren.length - 1; i >= commonLen; i--) { unmountChild(oldChildren[i]); } // Mount excess new children for (let i = commonLen; i < newChildren.length; i++) { mountChild(newChildren[i], parentEl); } }; //# sourceMappingURL=vnode.js.map