@furystack/shades
Version:
A lightweight UI framework for FuryStack with JSX support
499 lines (443 loc) • 17 kB
text/typescript
/**
* 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 type { ChildrenList } from './models/children-list.js'
import type { RefObject } from './models/render-options.js'
import { SVG_NS, isSvgTag } from './svg.js'
// ---------------------------------------------------------------------------
// Brands & sentinels
// ---------------------------------------------------------------------------
const VNODE_BRAND = 'vnode' as const
const VTEXT_BRAND = 'vtext' as const
/**
* Sentinel type used as VNode.type for JSX fragments (`<>...</>`).
*/
export const FRAGMENT: unique symbol = 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: unique symbol = Symbol('existing-node')
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* A lightweight descriptor for a DOM element or Shade component.
*/
export type VNode = {
_brand: typeof VNODE_BRAND
type: string | ((...args: unknown[]) => unknown) | typeof FRAGMENT | typeof EXISTING_NODE
props: Record<string, unknown>
children: VChild[]
_el?: Node
}
/**
* A lightweight descriptor for a DOM text node.
*/
export type VTextNode = {
_brand: typeof VTEXT_BRAND
text: string
_el?: Text
}
/**
* A single child in a VNode tree -- either an element/component or a text node.
*/
export type VChild = VNode | VTextNode
// ---------------------------------------------------------------------------
// Type guards
// ---------------------------------------------------------------------------
export const isVNode = (v: unknown): v is VNode =>
typeof v === 'object' && v !== null && (v as VNode)._brand === VNODE_BRAND
export const isVTextNode = (v: unknown): v is VTextNode =>
typeof v === 'object' && v !== null && (v as VTextNode)._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: unknown[]): VChild[] => {
const result: VChild[] = []
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: string | ((...args: unknown[]) => unknown) | null,
props: Record<string, unknown> | null,
...rawChildren: unknown[]
): VNode => {
const children = flattenVChildren(rawChildren)
const vnode: 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 as unknown as Record<string, unknown>
v.setAttribute = (name: string, value: string) => {
vnode.props[name] = value
}
v.removeAttribute = (name: string) => {
delete vnode.props[name]
}
v.getAttribute = (name: string) => {
return (vnode.props[name] as string) ?? null
}
v.hasAttribute = (name: string) => {
return name in vnode.props
}
v.appendChild = (child: unknown) => {
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: Record<string, unknown>, b: Record<string, unknown>): boolean => {
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: unknown): VChild[] => {
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: [] as VChild[],
_el: node,
}))
}
if (renderResult instanceof Node) {
return [{ _brand: VNODE_BRAND, type: EXISTING_NODE, props: {}, children: [], _el: renderResult }]
}
return []
}
// ---------------------------------------------------------------------------
// Props / style application
// ---------------------------------------------------------------------------
const setProp = (el: Element, key: string, value: unknown): void => {
if (key === 'ref') return
if (key === 'style' && typeof value === 'object' && value !== null) {
for (const [sk, sv] of Object.entries(value as Record<string, string>)) {
;((el as HTMLElement).style as unknown as Record<string, string>)[sk] = sv
}
return
}
if (el instanceof SVGElement) {
if (key === 'className') {
el.setAttribute('class', String(value))
} else if (key.startsWith('on') && typeof value === 'function') {
;(el as unknown as Record<string, unknown>)[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 as unknown as Record<string, unknown>)[key] = value
}
}
const removeProp = (el: Element, key: string): void => {
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 as unknown as Record<string, unknown>)[key] = null
} else {
try {
;(el as unknown as Record<string, unknown>)[key] = ''
} catch {
// Some properties are read-only
}
}
}
const patchStyle = (
el: Element,
oldStyle: Record<string, string> | undefined,
newStyle: Record<string, string> | undefined,
): void => {
const style = (el as HTMLElement).style as unknown as Record<string, string>
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: Element, props: Record<string, unknown>): void => {
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: Element, oldProps: Record<string, unknown>, newProps: Record<string, unknown>): void => {
// 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 as Record<string, string> | undefined, value as Record<string, string> | undefined)
} 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: VChild, parent: Node | null): Node => {
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 as Node
}
// Shade component
if (typeof child.type === 'function') {
const factory = child.type as (props: unknown, children?: ChildrenList) => JSX.Element
const el = factory(child.props, child.children as unknown as ChildrenList)
child._el = el
if (parent) parent.appendChild(el)
return el
}
// Intrinsic element
const tag = child.type as string
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 as RefObject<Element> | undefined
if (ref) {
;(ref as { current: Element | null }).current = el
}
return el
}
// ---------------------------------------------------------------------------
// Unmount (remove real DOM)
// ---------------------------------------------------------------------------
/**
* Removes the DOM node associated with a VChild from its parent.
*/
export const unmountChild = (child: VChild): void => {
// Clear ref before removing from DOM
if (child._brand === VNODE_BRAND && child.props?.ref) {
;(child.props.ref as { current: Element | null }).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: Node, oldChild: VChild, newChild: VChild): void => {
// 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 as JSX.Element
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 as unknown as ChildrenList
;(el as unknown as { updateComponentSync: () => void }).updateComponentSync()
}
return
}
// --- Intrinsic element ---
const el = oldChild._el as Element
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 as RefObject<Element> | undefined
const newRef = newChild.props?.ref as RefObject<Element> | undefined
if (oldRef !== newRef) {
if (oldRef) (oldRef as { current: Element | null }).current = null
if (newRef) (newRef as { current: Element | null }).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: Node, oldChildren: VChild[], newChildren: VChild[]): void => {
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)
}
}