@furystack/shades
Version:
A lightweight UI framework for FuryStack with JSX support
444 lines • 16 kB
JavaScript
/**
* 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