@furystack/shades
Version:
A lightweight UI framework for FuryStack with JSX support
607 lines (508 loc) • 23 kB
text/typescript
import { describe, expect, it, vi } from 'vitest'
import { SVG_NS } from './svg.js'
import {
createVNode,
EXISTING_NODE,
flattenVChildren,
FRAGMENT,
isVNode,
isVTextNode,
mountChild,
patchChildren,
patchProps,
shallowEqual,
toVChildArray,
unmountChild,
type VChild,
type VNode,
type VTextNode,
} from './vnode.js'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const vtext = (text: string): VTextNode => ({ _brand: 'vtext', text })
const vel = (tag: string, props: Record<string, unknown> | null, ...children: VChild[]): VNode => ({
_brand: 'vnode',
type: tag,
props: props ?? {},
children,
})
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('vnode', () => {
describe('createVNode', () => {
it('should create an intrinsic element VNode', () => {
const vnode = createVNode('div', { id: 'test' }, 'hello')
expect(vnode._brand).toBe('vnode')
expect(vnode.type).toBe('div')
expect(vnode.props).toEqual({ id: 'test' })
expect(vnode.children).toHaveLength(1)
expect(vnode.children[0]).toEqual({ _brand: 'vtext', text: 'hello' })
})
it('should create a fragment VNode', () => {
const child = createVNode('p', null, 'text')
const vnode = createVNode(null, null, child)
expect(vnode.type).toBe(FRAGMENT)
expect(vnode.children).toEqual([child])
})
it('should flatten nested arrays', () => {
const vnode = createVNode('ul', null, [createVNode('li', null, 'a'), createVNode('li', null, 'b')])
expect(vnode.children).toHaveLength(2)
})
it('should skip null and boolean children', () => {
const vnode = createVNode('div', null, null, false, true, undefined, 'kept')
expect(vnode.children).toHaveLength(1)
expect((vnode.children[0] as VTextNode).text).toBe('kept')
})
it('should inline fragment children', () => {
const fragment = createVNode(null, null, 'a', 'b')
const vnode = createVNode('div', null, fragment)
expect(vnode.children).toHaveLength(2)
expect((vnode.children[0] as VTextNode).text).toBe('a')
expect((vnode.children[1] as VTextNode).text).toBe('b')
})
it('should convert numbers to text nodes', () => {
const vnode = createVNode('span', null, 42)
expect(vnode.children).toHaveLength(1)
expect((vnode.children[0] as VTextNode).text).toBe('42')
})
it('should normalize null props to empty object', () => {
const vnode = createVNode('div', null)
expect(vnode.props).toEqual({})
expect(vnode.props).not.toBeNull()
})
})
describe('flattenVChildren', () => {
it('should wrap real DOM nodes as EXISTING_NODE VNodes', () => {
const div = document.createElement('div')
const result = flattenVChildren([div])
expect(result).toHaveLength(1)
expect(isVNode(result[0])).toBe(true)
expect((result[0] as VNode).type).toBe(EXISTING_NODE)
expect((result[0] as VNode)._el).toBe(div)
})
})
describe('type guards', () => {
it('isVNode should identify VNodes', () => {
expect(isVNode(createVNode('div', null))).toBe(true)
expect(isVNode({ _brand: 'vtext', text: 'hello' })).toBe(false)
expect(isVNode(null)).toBe(false)
expect(isVNode('string')).toBe(false)
})
it('isVTextNode should identify VTextNodes', () => {
expect(isVTextNode({ _brand: 'vtext', text: 'hi' })).toBe(true)
expect(isVTextNode(createVNode('div', null))).toBe(false)
})
})
describe('shallowEqual', () => {
it('should return true for identical references', () => {
const obj = { a: 1 }
expect(shallowEqual(obj, obj)).toBe(true)
})
it('should return true for equal props', () => {
expect(shallowEqual({ a: 1, b: 'x' }, { a: 1, b: 'x' })).toBe(true)
})
it('should return false for different values', () => {
expect(shallowEqual({ a: 1 }, { a: 2 })).toBe(false)
})
it('should return false for different key counts', () => {
expect(shallowEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false)
})
it('should return true for two empty objects', () => {
expect(shallowEqual({}, {})).toBe(true)
})
})
describe('toVChildArray', () => {
it('should return empty array for null', () => {
expect(toVChildArray(null)).toEqual([])
})
it('should wrap string as VTextNode', () => {
const result = toVChildArray('hello')
expect(result).toHaveLength(1)
expect(isVTextNode(result[0])).toBe(true)
})
it('should unwrap fragment VNode children', () => {
const fragment = createVNode(null, null, createVNode('p', null, 'a'), createVNode('p', null, 'b'))
const result = toVChildArray(fragment)
expect(result).toHaveLength(2)
})
it('should wrap single VNode in array', () => {
const vnode = createVNode('div', null, 'text')
const result = toVChildArray(vnode)
expect(result).toEqual([vnode])
})
it('should wrap real DOM element as EXISTING_NODE', () => {
const el = document.createElement('div')
const result = toVChildArray(el)
expect(result).toHaveLength(1)
expect((result[0] as VNode).type).toBe(EXISTING_NODE)
expect((result[0] as VNode)._el).toBe(el)
})
it('should wrap DocumentFragment children as EXISTING_NODE VNodes', () => {
const fragment = document.createDocumentFragment()
fragment.appendChild(document.createElement('span'))
fragment.appendChild(document.createTextNode('text'))
const result = toVChildArray(fragment)
expect(result).toHaveLength(2)
expect((result[0] as VNode).type).toBe(EXISTING_NODE)
expect((result[0] as VNode)._el).toBeInstanceOf(HTMLSpanElement)
expect((result[1] as VNode).type).toBe(EXISTING_NODE)
expect((result[1] as VNode)._el).toBeInstanceOf(Text)
})
it('should convert number to VTextNode', () => {
const result = toVChildArray(42)
expect(result).toHaveLength(1)
expect(isVTextNode(result[0])).toBe(true)
expect((result[0] as VTextNode).text).toBe('42')
})
it('should return empty array for undefined', () => {
expect(toVChildArray(undefined)).toEqual([])
})
})
describe('mountChild', () => {
it('should mount a text node', () => {
const parent = document.createElement('div')
mountChild(vtext('hello'), parent)
expect(parent.textContent).toBe('hello')
})
it('should mount an intrinsic element with props', () => {
const parent = document.createElement('div')
const child = vel('span', { id: 'test', className: 'cls' }, vtext('content'))
mountChild(child, parent)
const span = parent.querySelector('span')!
expect(span.id).toBe('test')
expect(span.className).toBe('cls')
expect(span.textContent).toBe('content')
expect(child._el).toBe(span)
})
it('should mount nested children', () => {
const parent = document.createElement('div')
const child = vel('ul', null, vel('li', null, vtext('a')), vel('li', null, vtext('b')))
mountChild(child, parent)
expect(parent.innerHTML).toBe('<ul><li>a</li><li>b</li></ul>')
})
it('should mount an EXISTING_NODE by appending the real element', () => {
const parent = document.createElement('div')
const existing = document.createElement('span')
existing.textContent = 'existing'
const child: VNode = { _brand: 'vnode', type: EXISTING_NODE, props: {}, children: [], _el: existing }
mountChild(child, parent)
expect(parent.firstChild).toBe(existing)
})
it('should set _el on text nodes', () => {
const parent = document.createElement('div')
const child = vtext('hi')
mountChild(child, parent)
expect(child._el).toBeInstanceOf(Text)
expect(child._el?.textContent).toBe('hi')
})
it('should create SVG elements with createElementNS', () => {
const parent = document.createElement('div')
const child = vel('svg', { viewBox: '0 0 100 100' }, vel('circle', { cx: '50', cy: '50', r: '40' }))
mountChild(child, parent)
const svg = parent.querySelector('svg')
expect(svg).toBeInstanceOf(SVGElement)
expect(svg?.namespaceURI).toBe(SVG_NS)
expect(svg?.getAttribute('viewBox')).toBe('0 0 100 100')
const circle = svg?.querySelector('circle')
expect(circle).toBeInstanceOf(SVGElement)
expect(circle?.namespaceURI).toBe(SVG_NS)
expect(circle?.getAttribute('cx')).toBe('50')
})
it('should set className as class attribute on SVG elements', () => {
const parent = document.createElement('div')
const child = vel('g', { className: 'my-group' })
mountChild(child, parent)
const g = parent.querySelector('g')
expect(g?.getAttribute('class')).toBe('my-group')
})
it('should attach event handlers as properties on SVG elements', () => {
const parent = document.createElement('div')
const handler = vi.fn()
const child = vel('rect', { onclick: handler })
mountChild(child, parent)
const rect = parent.querySelector('rect')
expect((rect as unknown as Record<string, unknown>).onclick).toBe(handler)
})
it('should handle SVG elements with style props', () => {
const parent = document.createElement('div')
const child = vel('rect', { style: { fill: 'red', strokeWidth: '2px' } })
mountChild(child, parent)
const rect = parent.querySelector('rect') as SVGElement
expect(rect.style.fill).toBe('red')
expect(rect.style.strokeWidth).toBe('2px')
})
it('should not mount EXISTING_NODE when _el is undefined', () => {
const parent = document.createElement('div')
const child: VNode = { _brand: 'vnode', type: EXISTING_NODE, props: {}, children: [] }
const result = mountChild(child, parent)
expect(result).toBeUndefined()
expect(parent.childNodes.length).toBe(0)
})
it('should set ref on mounted intrinsic elements', () => {
const parent = document.createElement('div')
const ref = { current: null } as { current: Element | null }
const child = vel('input', { ref })
mountChild(child, parent)
expect(ref.current).toBeInstanceOf(HTMLInputElement)
expect(ref.current).toBe(parent.querySelector('input'))
})
})
describe('unmountChild', () => {
it('should remove a mounted element from the DOM', () => {
const parent = document.createElement('div')
const child = vel('span', null, vtext('bye'))
mountChild(child, parent)
expect(parent.children.length).toBe(1)
unmountChild(child)
expect(parent.children.length).toBe(0)
})
it('should remove a mounted text node from the DOM', () => {
const parent = document.createElement('div')
const child = vtext('bye')
mountChild(child, parent)
expect(parent.childNodes.length).toBe(1)
unmountChild(child)
expect(parent.childNodes.length).toBe(0)
})
})
describe('patchProps', () => {
it('should add new props', () => {
const el = document.createElement('div')
patchProps(el, {}, { id: 'new' })
expect(el.id).toBe('new')
})
it('should update changed props', () => {
const el = document.createElement('div')
el.id = 'old'
patchProps(el, { id: 'old' }, { id: 'new' })
expect(el.id).toBe('new')
})
it('should remove stale event handlers', () => {
const el = document.createElement('div')
const handler = vi.fn()
el.onclick = handler
patchProps(el, { onclick: handler }, {})
expect(el.onclick).toBeNull()
})
it('should update event handlers', () => {
const el = document.createElement('button')
const handler1 = vi.fn()
const handler2 = vi.fn()
patchProps(el, { onclick: handler1 }, { onclick: handler2 })
expect(el.onclick).toBe(handler2)
})
it('should patch styles', () => {
const el = document.createElement('div')
patchProps(el, { style: { color: 'red', fontSize: '14px' } }, { style: { color: 'blue' } })
expect(el.style.color).toBe('blue')
expect(el.style.fontSize).toBe('')
})
it('should set data attributes', () => {
const el = document.createElement('div')
patchProps(el, {}, { 'data-testid': 'foo' })
expect(el.getAttribute('data-testid')).toBe('foo')
})
it('should remove data attributes', () => {
const el = document.createElement('div')
el.setAttribute('data-testid', 'foo')
patchProps(el, { 'data-testid': 'foo' }, {})
expect(el.hasAttribute('data-testid')).toBe(false)
})
describe('SVG elements', () => {
it('should set attributes via setAttribute on SVG elements', () => {
const el = document.createElementNS(SVG_NS, 'rect')
patchProps(el, {}, { width: '100', height: '50', rx: '5' })
expect(el.getAttribute('width')).toBe('100')
expect(el.getAttribute('height')).toBe('50')
expect(el.getAttribute('rx')).toBe('5')
})
it('should set className as class attribute on SVG elements', () => {
const el = document.createElementNS(SVG_NS, 'g')
patchProps(el, {}, { className: 'my-group' })
expect(el.getAttribute('class')).toBe('my-group')
})
it('should remove attributes from SVG elements', () => {
const el = document.createElementNS(SVG_NS, 'circle')
el.setAttribute('fill', 'red')
patchProps(el, { fill: 'red' }, {})
expect(el.hasAttribute('fill')).toBe(false)
})
it('should remove className as class from SVG elements', () => {
const el = document.createElementNS(SVG_NS, 'g')
el.setAttribute('class', 'old')
patchProps(el, { className: 'old' }, {})
expect(el.hasAttribute('class')).toBe(false)
})
it('should remove attributes when value is null/undefined/false on SVG elements', () => {
const el = document.createElementNS(SVG_NS, 'rect')
el.setAttribute('fill', 'red')
patchProps(el, { fill: 'red' }, { fill: null })
expect(el.hasAttribute('fill')).toBe(false)
})
it('should set event handlers as properties on SVG elements', () => {
const el = document.createElementNS(SVG_NS, 'rect')
const handler = vi.fn()
patchProps(el, {}, { onclick: handler })
expect((el as unknown as Record<string, unknown>).onclick).toBe(handler)
})
})
})
describe('patchChildren', () => {
it('should mount all children when old is empty', () => {
const parent = document.createElement('div')
const newChildren: VChild[] = [vel('span', null, vtext('a')), vel('span', null, vtext('b'))]
patchChildren(parent, [], newChildren)
expect(parent.children.length).toBe(2)
expect(parent.children[0].textContent).toBe('a')
expect(parent.children[1].textContent).toBe('b')
})
it('should remove all children when new is empty', () => {
const parent = document.createElement('div')
const oldChildren: VChild[] = [vel('span', null, vtext('a'))]
patchChildren(parent, [], oldChildren) // mount first
patchChildren(parent, oldChildren, [])
expect(parent.children.length).toBe(0)
})
it('should patch matching text nodes', () => {
const parent = document.createElement('div')
const old: VChild[] = [vtext('old')]
patchChildren(parent, [], old)
const textNode = parent.firstChild!
const updated: VChild[] = [vtext('new')]
patchChildren(parent, old, updated)
expect(parent.firstChild).toBe(textNode)
expect(parent.textContent).toBe('new')
})
it('should patch matching intrinsic elements in place', () => {
const parent = document.createElement('div')
const old: VChild[] = [vel('span', { id: 'a' }, vtext('old'))]
patchChildren(parent, [], old)
const span = parent.querySelector('span')!
const updated: VChild[] = [vel('span', { id: 'b' }, vtext('new'))]
patchChildren(parent, old, updated)
expect(parent.querySelector('span')).toBe(span)
expect(span.id).toBe('b')
expect(span.textContent).toBe('new')
})
it('should replace when types differ', () => {
const parent = document.createElement('div')
const old: VChild[] = [vel('div', null, vtext('div'))]
patchChildren(parent, [], old)
const updated: VChild[] = [vel('span', null, vtext('span'))]
patchChildren(parent, old, updated)
expect(parent.children[0].tagName).toBe('SPAN')
expect(parent.textContent).toBe('span')
})
it('should add excess new children', () => {
const parent = document.createElement('div')
const old: VChild[] = [vel('p', null, vtext('a'))]
patchChildren(parent, [], old)
const updated: VChild[] = [vel('p', null, vtext('a')), vel('p', null, vtext('b'))]
patchChildren(parent, old, updated)
expect(parent.children.length).toBe(2)
})
it('should remove excess old children', () => {
const parent = document.createElement('div')
const old: VChild[] = [vel('p', null, vtext('a')), vel('p', null, vtext('b'))]
patchChildren(parent, [], old)
const updated: VChild[] = [vel('p', null, vtext('only'))]
patchChildren(parent, old, updated)
expect(parent.children.length).toBe(1)
expect(parent.textContent).toBe('only')
})
it('should preserve element identity across patches', () => {
const parent = document.createElement('div')
const old: VChild[] = [vel('input', { type: 'text' })]
patchChildren(parent, [], old)
const input = parent.querySelector('input')!
const updated: VChild[] = [vel('input', { type: 'text', id: 'updated' })]
patchChildren(parent, old, updated)
expect(parent.querySelector('input')).toBe(input)
expect(input.id).toBe('updated')
})
it('should handle EXISTING_NODE patching (same reference)', () => {
const parent = document.createElement('div')
const real = document.createElement('span')
real.textContent = 'real'
const old: VChild[] = [{ _brand: 'vnode', type: EXISTING_NODE, props: {}, children: [], _el: real }]
patchChildren(parent, [], old)
expect(parent.firstChild).toBe(real)
const updated: VChild[] = [{ _brand: 'vnode', type: EXISTING_NODE, props: {}, children: [], _el: real }]
patchChildren(parent, old, updated)
expect(parent.firstChild).toBe(real)
})
it('should mount new child into parent when types differ and old node is detached', () => {
const parent = document.createElement('div')
const old: VChild[] = [vel('div', null, vtext('div'))]
patchChildren(parent, [], old)
// Detach old node manually to simulate a detached state
const oldNode = old[0]._el!
oldNode.parentNode!.removeChild(oldNode)
const updated: VChild[] = [vel('span', null, vtext('span'))]
patchChildren(parent, old, updated)
expect(parent.children.length).toBe(1)
expect(parent.children[0].tagName).toBe('SPAN')
})
it('should clear ref on unmount', () => {
const parent = document.createElement('div')
const ref = { current: null } as { current: Element | null }
const child = vel('input', { ref })
patchChildren(parent, [], [child])
expect(ref.current).toBeInstanceOf(HTMLInputElement)
patchChildren(parent, [child], [])
expect(ref.current).toBeNull()
})
describe('Shade component boundaries', () => {
it('should call updateComponentSync on child Shade when props change', () => {
const parent = document.createElement('div')
const fakeShadeEl = document.createElement('my-shade') as unknown as JSX.Element
const updateFn = vi.fn()
;(fakeShadeEl as unknown as Record<string, unknown>).updateComponentSync = updateFn
;(fakeShadeEl as unknown as Record<string, unknown>).props = { count: 1 }
;(fakeShadeEl as unknown as Record<string, unknown>).shadeChildren = undefined
const factory = vi.fn(() => fakeShadeEl)
const old: VChild[] = [{ _brand: 'vnode', type: factory, props: { count: 1 }, children: [], _el: fakeShadeEl }]
parent.appendChild(fakeShadeEl)
const updated: VChild[] = [{ _brand: 'vnode', type: factory, props: { count: 2 }, children: [] }]
patchChildren(parent, old, updated)
expect(updateFn).toHaveBeenCalledOnce()
expect(fakeShadeEl.props).toEqual({ count: 2 })
})
it('should set empty object (not null) on Shade when props transition to none', () => {
const parent = document.createElement('div')
const fakeShadeEl = document.createElement('my-shade-3') as unknown as JSX.Element
const updateFn = vi.fn()
;(fakeShadeEl as unknown as Record<string, unknown>).updateComponentSync = updateFn
;(fakeShadeEl as unknown as Record<string, unknown>).props = { elevation: 2 }
;(fakeShadeEl as unknown as Record<string, unknown>).shadeChildren = undefined
const factory = vi.fn(() => fakeShadeEl)
const old: VChild[] = [
{ _brand: 'vnode', type: factory, props: { elevation: 2 }, children: [], _el: fakeShadeEl },
]
parent.appendChild(fakeShadeEl)
const updated: VChild[] = [{ _brand: 'vnode', type: factory, props: {}, children: [] }]
patchChildren(parent, old, updated)
expect(updateFn).toHaveBeenCalledOnce()
expect(fakeShadeEl.props).toEqual({})
expect(fakeShadeEl.props).not.toBeNull()
})
it('should NOT call updateComponentSync when props are unchanged', () => {
const parent = document.createElement('div')
const fakeShadeEl = document.createElement('my-shade-2') as unknown as JSX.Element
const updateFn = vi.fn()
const props = { count: 1 }
;(fakeShadeEl as unknown as Record<string, unknown>).updateComponentSync = updateFn
;(fakeShadeEl as unknown as Record<string, unknown>).props = props
;(fakeShadeEl as unknown as Record<string, unknown>).shadeChildren = undefined
const factory = vi.fn(() => fakeShadeEl)
const old: VChild[] = [{ _brand: 'vnode', type: factory, props: { count: 1 }, children: [], _el: fakeShadeEl }]
parent.appendChild(fakeShadeEl)
const updated: VChild[] = [{ _brand: 'vnode', type: factory, props: { count: 1 }, children: [] }]
patchChildren(parent, old, updated)
expect(updateFn).not.toHaveBeenCalled()
})
})
})
})