@furystack/shades 11.1.0 → 12.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +312 -0
  2. package/README.md +13 -13
  3. package/esm/component-factory.spec.js +13 -5
  4. package/esm/component-factory.spec.js.map +1 -1
  5. package/esm/components/index.d.ts +4 -1
  6. package/esm/components/index.d.ts.map +1 -1
  7. package/esm/components/index.js +4 -1
  8. package/esm/components/index.js.map +1 -1
  9. package/esm/components/lazy-load.d.ts +2 -4
  10. package/esm/components/lazy-load.d.ts.map +1 -1
  11. package/esm/components/lazy-load.js +40 -24
  12. package/esm/components/lazy-load.js.map +1 -1
  13. package/esm/components/lazy-load.spec.js +57 -50
  14. package/esm/components/lazy-load.spec.js.map +1 -1
  15. package/esm/components/link-to-route.d.ts +2 -0
  16. package/esm/components/link-to-route.d.ts.map +1 -1
  17. package/esm/components/link-to-route.js +3 -2
  18. package/esm/components/link-to-route.js.map +1 -1
  19. package/esm/components/link-to-route.spec.js +13 -9
  20. package/esm/components/link-to-route.spec.js.map +1 -1
  21. package/esm/components/nested-route-link.d.ts +62 -0
  22. package/esm/components/nested-route-link.d.ts.map +1 -0
  23. package/esm/components/nested-route-link.js +66 -0
  24. package/esm/components/nested-route-link.js.map +1 -0
  25. package/esm/components/nested-route-link.spec.d.ts +2 -0
  26. package/esm/components/nested-route-link.spec.d.ts.map +1 -0
  27. package/esm/components/nested-route-link.spec.js +179 -0
  28. package/esm/components/nested-route-link.spec.js.map +1 -0
  29. package/esm/components/nested-route-types.d.ts +37 -0
  30. package/esm/components/nested-route-types.d.ts.map +1 -0
  31. package/esm/components/nested-route-types.js +2 -0
  32. package/esm/components/nested-route-types.js.map +1 -0
  33. package/esm/components/nested-router.d.ts +103 -0
  34. package/esm/components/nested-router.d.ts.map +1 -0
  35. package/esm/components/nested-router.js +183 -0
  36. package/esm/components/nested-router.js.map +1 -0
  37. package/esm/components/nested-router.spec.d.ts +2 -0
  38. package/esm/components/nested-router.spec.d.ts.map +1 -0
  39. package/esm/components/nested-router.spec.js +737 -0
  40. package/esm/components/nested-router.spec.js.map +1 -0
  41. package/esm/components/route-link.d.ts +4 -0
  42. package/esm/components/route-link.d.ts.map +1 -1
  43. package/esm/components/route-link.js +5 -5
  44. package/esm/components/route-link.js.map +1 -1
  45. package/esm/components/route-link.spec.js +16 -12
  46. package/esm/components/route-link.spec.js.map +1 -1
  47. package/esm/components/router.d.ts +20 -2
  48. package/esm/components/router.d.ts.map +1 -1
  49. package/esm/components/router.js +12 -7
  50. package/esm/components/router.js.map +1 -1
  51. package/esm/components/router.spec.js +141 -74
  52. package/esm/components/router.spec.js.map +1 -1
  53. package/esm/initialize.d.ts +11 -0
  54. package/esm/initialize.d.ts.map +1 -1
  55. package/esm/initialize.js +5 -0
  56. package/esm/initialize.js.map +1 -1
  57. package/esm/jsx.d.ts +83 -2
  58. package/esm/jsx.d.ts.map +1 -1
  59. package/esm/models/children-list.d.ts +5 -1
  60. package/esm/models/children-list.d.ts.map +1 -1
  61. package/esm/models/partial-element.d.ts +12 -2
  62. package/esm/models/partial-element.d.ts.map +1 -1
  63. package/esm/models/render-options.d.ts +89 -3
  64. package/esm/models/render-options.d.ts.map +1 -1
  65. package/esm/models/selection-state.d.ts +4 -0
  66. package/esm/models/selection-state.d.ts.map +1 -1
  67. package/esm/services/location-service.d.ts +11 -0
  68. package/esm/services/location-service.d.ts.map +1 -1
  69. package/esm/services/location-service.js +11 -0
  70. package/esm/services/location-service.js.map +1 -1
  71. package/esm/services/resource-manager.d.ts +24 -0
  72. package/esm/services/resource-manager.d.ts.map +1 -1
  73. package/esm/services/resource-manager.js +36 -1
  74. package/esm/services/resource-manager.js.map +1 -1
  75. package/esm/services/resource-manager.spec.js +102 -0
  76. package/esm/services/resource-manager.spec.js.map +1 -1
  77. package/esm/services/screen-service.d.ts +81 -4
  78. package/esm/services/screen-service.d.ts.map +1 -1
  79. package/esm/services/screen-service.js +75 -4
  80. package/esm/services/screen-service.js.map +1 -1
  81. package/esm/services/screen-service.spec.js +91 -7
  82. package/esm/services/screen-service.spec.js.map +1 -1
  83. package/esm/shade-component.d.ts +17 -4
  84. package/esm/shade-component.d.ts.map +1 -1
  85. package/esm/shade-component.js +67 -5
  86. package/esm/shade-component.js.map +1 -1
  87. package/esm/shade-host-props-ref.integration.spec.d.ts +2 -0
  88. package/esm/shade-host-props-ref.integration.spec.d.ts.map +1 -0
  89. package/esm/shade-host-props-ref.integration.spec.js +381 -0
  90. package/esm/shade-host-props-ref.integration.spec.js.map +1 -0
  91. package/esm/shade-resources.integration.spec.js +208 -39
  92. package/esm/shade-resources.integration.spec.js.map +1 -1
  93. package/esm/shade.d.ts +20 -17
  94. package/esm/shade.d.ts.map +1 -1
  95. package/esm/shade.js +172 -33
  96. package/esm/shade.js.map +1 -1
  97. package/esm/shade.spec.js +31 -30
  98. package/esm/shade.spec.js.map +1 -1
  99. package/esm/shades.integration.spec.js +135 -72
  100. package/esm/shades.integration.spec.js.map +1 -1
  101. package/esm/style-manager.d.ts +2 -2
  102. package/esm/style-manager.js +2 -2
  103. package/esm/svg-types.d.ts +389 -0
  104. package/esm/svg-types.d.ts.map +1 -0
  105. package/esm/svg-types.js +9 -0
  106. package/esm/svg-types.js.map +1 -0
  107. package/esm/svg.d.ts +15 -0
  108. package/esm/svg.d.ts.map +1 -0
  109. package/esm/svg.js +76 -0
  110. package/esm/svg.js.map +1 -0
  111. package/esm/svg.spec.d.ts +2 -0
  112. package/esm/svg.spec.d.ts.map +1 -0
  113. package/esm/svg.spec.js +80 -0
  114. package/esm/svg.spec.js.map +1 -0
  115. package/esm/vnode.d.ts +103 -0
  116. package/esm/vnode.d.ts.map +1 -0
  117. package/esm/vnode.integration.spec.d.ts +2 -0
  118. package/esm/vnode.integration.spec.d.ts.map +1 -0
  119. package/esm/vnode.integration.spec.js +494 -0
  120. package/esm/vnode.integration.spec.js.map +1 -0
  121. package/esm/vnode.js +453 -0
  122. package/esm/vnode.js.map +1 -0
  123. package/esm/vnode.spec.d.ts +2 -0
  124. package/esm/vnode.spec.d.ts.map +1 -0
  125. package/esm/vnode.spec.js +473 -0
  126. package/esm/vnode.spec.js.map +1 -0
  127. package/package.json +8 -9
  128. package/src/component-factory.spec.tsx +18 -5
  129. package/src/components/index.ts +4 -1
  130. package/src/components/lazy-load.spec.tsx +82 -75
  131. package/src/components/lazy-load.tsx +49 -27
  132. package/src/components/link-to-route.spec.tsx +25 -21
  133. package/src/components/link-to-route.tsx +4 -2
  134. package/src/components/nested-route-link.spec.tsx +303 -0
  135. package/src/components/nested-route-link.tsx +100 -0
  136. package/src/components/nested-route-types.ts +42 -0
  137. package/src/components/nested-router.spec.tsx +918 -0
  138. package/src/components/nested-router.tsx +260 -0
  139. package/src/components/route-link.spec.tsx +22 -18
  140. package/src/components/route-link.tsx +6 -5
  141. package/src/components/router.spec.tsx +196 -108
  142. package/src/components/router.tsx +21 -8
  143. package/src/initialize.ts +12 -0
  144. package/src/jsx.ts +129 -2
  145. package/src/models/children-list.ts +7 -1
  146. package/src/models/partial-element.ts +13 -2
  147. package/src/models/render-options.ts +90 -3
  148. package/src/models/selection-state.ts +4 -0
  149. package/src/services/location-service.tsx +11 -0
  150. package/src/services/resource-manager.spec.ts +128 -0
  151. package/src/services/resource-manager.ts +36 -1
  152. package/src/services/screen-service.spec.ts +109 -7
  153. package/src/services/screen-service.ts +81 -4
  154. package/src/shade-component.ts +72 -6
  155. package/src/shade-host-props-ref.integration.spec.tsx +460 -0
  156. package/src/shade-resources.integration.spec.tsx +276 -52
  157. package/src/shade.spec.tsx +40 -39
  158. package/src/shade.ts +186 -58
  159. package/src/shades.integration.spec.tsx +154 -80
  160. package/src/style-manager.ts +2 -2
  161. package/src/svg-types.ts +437 -0
  162. package/src/svg.spec.ts +89 -0
  163. package/src/svg.ts +78 -0
  164. package/src/vnode.integration.spec.tsx +657 -0
  165. package/src/vnode.spec.ts +579 -0
  166. package/src/vnode.ts +508 -0
@@ -0,0 +1,579 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { SVG_NS } from './svg.js'
3
+ import {
4
+ createVNode,
5
+ EXISTING_NODE,
6
+ flattenVChildren,
7
+ FRAGMENT,
8
+ isVNode,
9
+ isVTextNode,
10
+ mountChild,
11
+ patchChildren,
12
+ patchProps,
13
+ shallowEqual,
14
+ toVChildArray,
15
+ unmountChild,
16
+ type VChild,
17
+ type VNode,
18
+ type VTextNode,
19
+ } from './vnode.js'
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Helpers
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const vtext = (text: string): VTextNode => ({ _brand: 'vtext', text })
26
+
27
+ const vel = (tag: string, props: Record<string, unknown> | null, ...children: VChild[]): VNode => ({
28
+ _brand: 'vnode',
29
+ type: tag,
30
+ props,
31
+ children,
32
+ })
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Tests
36
+ // ---------------------------------------------------------------------------
37
+
38
+ describe('vnode', () => {
39
+ describe('createVNode', () => {
40
+ it('should create an intrinsic element VNode', () => {
41
+ const vnode = createVNode('div', { id: 'test' }, 'hello')
42
+ expect(vnode._brand).toBe('vnode')
43
+ expect(vnode.type).toBe('div')
44
+ expect(vnode.props).toEqual({ id: 'test' })
45
+ expect(vnode.children).toHaveLength(1)
46
+ expect(vnode.children[0]).toEqual({ _brand: 'vtext', text: 'hello' })
47
+ })
48
+
49
+ it('should create a fragment VNode', () => {
50
+ const child = createVNode('p', null, 'text')
51
+ const vnode = createVNode(null, null, child)
52
+ expect(vnode.type).toBe(FRAGMENT)
53
+ expect(vnode.children).toEqual([child])
54
+ })
55
+
56
+ it('should flatten nested arrays', () => {
57
+ const vnode = createVNode('ul', null, [createVNode('li', null, 'a'), createVNode('li', null, 'b')])
58
+ expect(vnode.children).toHaveLength(2)
59
+ })
60
+
61
+ it('should skip null and boolean children', () => {
62
+ const vnode = createVNode('div', null, null, false, true, undefined, 'kept')
63
+ expect(vnode.children).toHaveLength(1)
64
+ expect((vnode.children[0] as VTextNode).text).toBe('kept')
65
+ })
66
+
67
+ it('should inline fragment children', () => {
68
+ const fragment = createVNode(null, null, 'a', 'b')
69
+ const vnode = createVNode('div', null, fragment)
70
+ expect(vnode.children).toHaveLength(2)
71
+ expect((vnode.children[0] as VTextNode).text).toBe('a')
72
+ expect((vnode.children[1] as VTextNode).text).toBe('b')
73
+ })
74
+
75
+ it('should convert numbers to text nodes', () => {
76
+ const vnode = createVNode('span', null, 42)
77
+ expect(vnode.children).toHaveLength(1)
78
+ expect((vnode.children[0] as VTextNode).text).toBe('42')
79
+ })
80
+ })
81
+
82
+ describe('flattenVChildren', () => {
83
+ it('should wrap real DOM nodes as EXISTING_NODE VNodes', () => {
84
+ const div = document.createElement('div')
85
+ const result = flattenVChildren([div])
86
+ expect(result).toHaveLength(1)
87
+ expect(isVNode(result[0])).toBe(true)
88
+ expect((result[0] as VNode).type).toBe(EXISTING_NODE)
89
+ expect((result[0] as VNode)._el).toBe(div)
90
+ })
91
+ })
92
+
93
+ describe('type guards', () => {
94
+ it('isVNode should identify VNodes', () => {
95
+ expect(isVNode(createVNode('div', null))).toBe(true)
96
+ expect(isVNode({ _brand: 'vtext', text: 'hello' })).toBe(false)
97
+ expect(isVNode(null)).toBe(false)
98
+ expect(isVNode('string')).toBe(false)
99
+ })
100
+
101
+ it('isVTextNode should identify VTextNodes', () => {
102
+ expect(isVTextNode({ _brand: 'vtext', text: 'hi' })).toBe(true)
103
+ expect(isVTextNode(createVNode('div', null))).toBe(false)
104
+ })
105
+ })
106
+
107
+ describe('shallowEqual', () => {
108
+ it('should return true for identical references', () => {
109
+ const obj = { a: 1 }
110
+ expect(shallowEqual(obj, obj)).toBe(true)
111
+ })
112
+
113
+ it('should return true for equal props', () => {
114
+ expect(shallowEqual({ a: 1, b: 'x' }, { a: 1, b: 'x' })).toBe(true)
115
+ })
116
+
117
+ it('should return false for different values', () => {
118
+ expect(shallowEqual({ a: 1 }, { a: 2 })).toBe(false)
119
+ })
120
+
121
+ it('should return false for different key counts', () => {
122
+ expect(shallowEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false)
123
+ })
124
+
125
+ it('should handle null comparisons', () => {
126
+ expect(shallowEqual(null, null)).toBe(true)
127
+ expect(shallowEqual(null, {})).toBe(false)
128
+ expect(shallowEqual({}, null)).toBe(false)
129
+ })
130
+ })
131
+
132
+ describe('toVChildArray', () => {
133
+ it('should return empty array for null', () => {
134
+ expect(toVChildArray(null)).toEqual([])
135
+ })
136
+
137
+ it('should wrap string as VTextNode', () => {
138
+ const result = toVChildArray('hello')
139
+ expect(result).toHaveLength(1)
140
+ expect(isVTextNode(result[0])).toBe(true)
141
+ })
142
+
143
+ it('should unwrap fragment VNode children', () => {
144
+ const fragment = createVNode(null, null, createVNode('p', null, 'a'), createVNode('p', null, 'b'))
145
+ const result = toVChildArray(fragment)
146
+ expect(result).toHaveLength(2)
147
+ })
148
+
149
+ it('should wrap single VNode in array', () => {
150
+ const vnode = createVNode('div', null, 'text')
151
+ const result = toVChildArray(vnode)
152
+ expect(result).toEqual([vnode])
153
+ })
154
+
155
+ it('should wrap real DOM element as EXISTING_NODE', () => {
156
+ const el = document.createElement('div')
157
+ const result = toVChildArray(el)
158
+ expect(result).toHaveLength(1)
159
+ expect((result[0] as VNode).type).toBe(EXISTING_NODE)
160
+ expect((result[0] as VNode)._el).toBe(el)
161
+ })
162
+
163
+ it('should wrap DocumentFragment children as EXISTING_NODE VNodes', () => {
164
+ const fragment = document.createDocumentFragment()
165
+ fragment.appendChild(document.createElement('span'))
166
+ fragment.appendChild(document.createTextNode('text'))
167
+ const result = toVChildArray(fragment)
168
+ expect(result).toHaveLength(2)
169
+ expect((result[0] as VNode).type).toBe(EXISTING_NODE)
170
+ expect((result[0] as VNode)._el).toBeInstanceOf(HTMLSpanElement)
171
+ expect((result[1] as VNode).type).toBe(EXISTING_NODE)
172
+ expect((result[1] as VNode)._el).toBeInstanceOf(Text)
173
+ })
174
+
175
+ it('should convert number to VTextNode', () => {
176
+ const result = toVChildArray(42)
177
+ expect(result).toHaveLength(1)
178
+ expect(isVTextNode(result[0])).toBe(true)
179
+ expect((result[0] as VTextNode).text).toBe('42')
180
+ })
181
+
182
+ it('should return empty array for undefined', () => {
183
+ expect(toVChildArray(undefined)).toEqual([])
184
+ })
185
+ })
186
+
187
+ describe('mountChild', () => {
188
+ it('should mount a text node', () => {
189
+ const parent = document.createElement('div')
190
+ mountChild(vtext('hello'), parent)
191
+ expect(parent.textContent).toBe('hello')
192
+ })
193
+
194
+ it('should mount an intrinsic element with props', () => {
195
+ const parent = document.createElement('div')
196
+ const child = vel('span', { id: 'test', className: 'cls' }, vtext('content'))
197
+ mountChild(child, parent)
198
+ const span = parent.querySelector('span')!
199
+ expect(span.id).toBe('test')
200
+ expect(span.className).toBe('cls')
201
+ expect(span.textContent).toBe('content')
202
+ expect(child._el).toBe(span)
203
+ })
204
+
205
+ it('should mount nested children', () => {
206
+ const parent = document.createElement('div')
207
+ const child = vel('ul', null, vel('li', null, vtext('a')), vel('li', null, vtext('b')))
208
+ mountChild(child, parent)
209
+ expect(parent.innerHTML).toBe('<ul><li>a</li><li>b</li></ul>')
210
+ })
211
+
212
+ it('should mount an EXISTING_NODE by appending the real element', () => {
213
+ const parent = document.createElement('div')
214
+ const existing = document.createElement('span')
215
+ existing.textContent = 'existing'
216
+ const child: VNode = { _brand: 'vnode', type: EXISTING_NODE, props: null, children: [], _el: existing }
217
+ mountChild(child, parent)
218
+ expect(parent.firstChild).toBe(existing)
219
+ })
220
+
221
+ it('should set _el on text nodes', () => {
222
+ const parent = document.createElement('div')
223
+ const child = vtext('hi')
224
+ mountChild(child, parent)
225
+ expect(child._el).toBeInstanceOf(Text)
226
+ expect(child._el?.textContent).toBe('hi')
227
+ })
228
+
229
+ it('should create SVG elements with createElementNS', () => {
230
+ const parent = document.createElement('div')
231
+ const child = vel('svg', { viewBox: '0 0 100 100' }, vel('circle', { cx: '50', cy: '50', r: '40' }))
232
+ mountChild(child, parent)
233
+ const svg = parent.querySelector('svg')
234
+ expect(svg).toBeInstanceOf(SVGElement)
235
+ expect(svg?.namespaceURI).toBe(SVG_NS)
236
+ expect(svg?.getAttribute('viewBox')).toBe('0 0 100 100')
237
+ const circle = svg?.querySelector('circle')
238
+ expect(circle).toBeInstanceOf(SVGElement)
239
+ expect(circle?.namespaceURI).toBe(SVG_NS)
240
+ expect(circle?.getAttribute('cx')).toBe('50')
241
+ })
242
+
243
+ it('should set className as class attribute on SVG elements', () => {
244
+ const parent = document.createElement('div')
245
+ const child = vel('g', { className: 'my-group' })
246
+ mountChild(child, parent)
247
+ const g = parent.querySelector('g')
248
+ expect(g?.getAttribute('class')).toBe('my-group')
249
+ })
250
+
251
+ it('should attach event handlers as properties on SVG elements', () => {
252
+ const parent = document.createElement('div')
253
+ const handler = vi.fn()
254
+ const child = vel('rect', { onclick: handler })
255
+ mountChild(child, parent)
256
+ const rect = parent.querySelector('rect')
257
+ expect((rect as unknown as Record<string, unknown>).onclick).toBe(handler)
258
+ })
259
+
260
+ it('should handle SVG elements with style props', () => {
261
+ const parent = document.createElement('div')
262
+ const child = vel('rect', { style: { fill: 'red', strokeWidth: '2px' } })
263
+ mountChild(child, parent)
264
+ const rect = parent.querySelector('rect') as SVGElement
265
+ expect(rect.style.fill).toBe('red')
266
+ expect(rect.style.strokeWidth).toBe('2px')
267
+ })
268
+
269
+ it('should not mount EXISTING_NODE when _el is undefined', () => {
270
+ const parent = document.createElement('div')
271
+ const child: VNode = { _brand: 'vnode', type: EXISTING_NODE, props: null, children: [] }
272
+ const result = mountChild(child, parent)
273
+ expect(result).toBeUndefined()
274
+ expect(parent.childNodes.length).toBe(0)
275
+ })
276
+
277
+ it('should set ref on mounted intrinsic elements', () => {
278
+ const parent = document.createElement('div')
279
+ const ref = { current: null } as { current: Element | null }
280
+ const child = vel('input', { ref })
281
+ mountChild(child, parent)
282
+ expect(ref.current).toBeInstanceOf(HTMLInputElement)
283
+ expect(ref.current).toBe(parent.querySelector('input'))
284
+ })
285
+ })
286
+
287
+ describe('unmountChild', () => {
288
+ it('should remove a mounted element from the DOM', () => {
289
+ const parent = document.createElement('div')
290
+ const child = vel('span', null, vtext('bye'))
291
+ mountChild(child, parent)
292
+ expect(parent.children.length).toBe(1)
293
+ unmountChild(child)
294
+ expect(parent.children.length).toBe(0)
295
+ })
296
+
297
+ it('should remove a mounted text node from the DOM', () => {
298
+ const parent = document.createElement('div')
299
+ const child = vtext('bye')
300
+ mountChild(child, parent)
301
+ expect(parent.childNodes.length).toBe(1)
302
+ unmountChild(child)
303
+ expect(parent.childNodes.length).toBe(0)
304
+ })
305
+ })
306
+
307
+ describe('patchProps', () => {
308
+ it('should add new props', () => {
309
+ const el = document.createElement('div')
310
+ patchProps(el, null, { id: 'new' })
311
+ expect(el.id).toBe('new')
312
+ })
313
+
314
+ it('should update changed props', () => {
315
+ const el = document.createElement('div')
316
+ el.id = 'old'
317
+ patchProps(el, { id: 'old' }, { id: 'new' })
318
+ expect(el.id).toBe('new')
319
+ })
320
+
321
+ it('should remove stale event handlers', () => {
322
+ const el = document.createElement('div')
323
+ const handler = vi.fn()
324
+ el.onclick = handler
325
+ patchProps(el, { onclick: handler }, {})
326
+ expect(el.onclick).toBeNull()
327
+ })
328
+
329
+ it('should update event handlers', () => {
330
+ const el = document.createElement('button')
331
+ const handler1 = vi.fn()
332
+ const handler2 = vi.fn()
333
+ patchProps(el, { onclick: handler1 }, { onclick: handler2 })
334
+ expect(el.onclick).toBe(handler2)
335
+ })
336
+
337
+ it('should patch styles', () => {
338
+ const el = document.createElement('div')
339
+ patchProps(el, { style: { color: 'red', fontSize: '14px' } }, { style: { color: 'blue' } })
340
+ expect(el.style.color).toBe('blue')
341
+ expect(el.style.fontSize).toBe('')
342
+ })
343
+
344
+ it('should set data attributes', () => {
345
+ const el = document.createElement('div')
346
+ patchProps(el, null, { 'data-testid': 'foo' })
347
+ expect(el.getAttribute('data-testid')).toBe('foo')
348
+ })
349
+
350
+ it('should remove data attributes', () => {
351
+ const el = document.createElement('div')
352
+ el.setAttribute('data-testid', 'foo')
353
+ patchProps(el, { 'data-testid': 'foo' }, {})
354
+ expect(el.hasAttribute('data-testid')).toBe(false)
355
+ })
356
+
357
+ describe('SVG elements', () => {
358
+ it('should set attributes via setAttribute on SVG elements', () => {
359
+ const el = document.createElementNS(SVG_NS, 'rect')
360
+ patchProps(el, null, { width: '100', height: '50', rx: '5' })
361
+ expect(el.getAttribute('width')).toBe('100')
362
+ expect(el.getAttribute('height')).toBe('50')
363
+ expect(el.getAttribute('rx')).toBe('5')
364
+ })
365
+
366
+ it('should set className as class attribute on SVG elements', () => {
367
+ const el = document.createElementNS(SVG_NS, 'g')
368
+ patchProps(el, null, { className: 'my-group' })
369
+ expect(el.getAttribute('class')).toBe('my-group')
370
+ })
371
+
372
+ it('should remove attributes from SVG elements', () => {
373
+ const el = document.createElementNS(SVG_NS, 'circle')
374
+ el.setAttribute('fill', 'red')
375
+ patchProps(el, { fill: 'red' }, {})
376
+ expect(el.hasAttribute('fill')).toBe(false)
377
+ })
378
+
379
+ it('should remove className as class from SVG elements', () => {
380
+ const el = document.createElementNS(SVG_NS, 'g')
381
+ el.setAttribute('class', 'old')
382
+ patchProps(el, { className: 'old' }, {})
383
+ expect(el.hasAttribute('class')).toBe(false)
384
+ })
385
+
386
+ it('should remove attributes when value is null/undefined/false on SVG elements', () => {
387
+ const el = document.createElementNS(SVG_NS, 'rect')
388
+ el.setAttribute('fill', 'red')
389
+ patchProps(el, { fill: 'red' }, { fill: null })
390
+ expect(el.hasAttribute('fill')).toBe(false)
391
+ })
392
+
393
+ it('should set event handlers as properties on SVG elements', () => {
394
+ const el = document.createElementNS(SVG_NS, 'rect')
395
+ const handler = vi.fn()
396
+ patchProps(el, null, { onclick: handler })
397
+ expect((el as unknown as Record<string, unknown>).onclick).toBe(handler)
398
+ })
399
+ })
400
+ })
401
+
402
+ describe('patchChildren', () => {
403
+ it('should mount all children when old is empty', () => {
404
+ const parent = document.createElement('div')
405
+ const newChildren: VChild[] = [vel('span', null, vtext('a')), vel('span', null, vtext('b'))]
406
+ patchChildren(parent, [], newChildren)
407
+ expect(parent.children.length).toBe(2)
408
+ expect(parent.children[0].textContent).toBe('a')
409
+ expect(parent.children[1].textContent).toBe('b')
410
+ })
411
+
412
+ it('should remove all children when new is empty', () => {
413
+ const parent = document.createElement('div')
414
+ const oldChildren: VChild[] = [vel('span', null, vtext('a'))]
415
+ patchChildren(parent, [], oldChildren) // mount first
416
+ patchChildren(parent, oldChildren, [])
417
+ expect(parent.children.length).toBe(0)
418
+ })
419
+
420
+ it('should patch matching text nodes', () => {
421
+ const parent = document.createElement('div')
422
+ const old: VChild[] = [vtext('old')]
423
+ patchChildren(parent, [], old)
424
+ const textNode = parent.firstChild!
425
+ const updated: VChild[] = [vtext('new')]
426
+ patchChildren(parent, old, updated)
427
+ expect(parent.firstChild).toBe(textNode)
428
+ expect(parent.textContent).toBe('new')
429
+ })
430
+
431
+ it('should patch matching intrinsic elements in place', () => {
432
+ const parent = document.createElement('div')
433
+ const old: VChild[] = [vel('span', { id: 'a' }, vtext('old'))]
434
+ patchChildren(parent, [], old)
435
+ const span = parent.querySelector('span')!
436
+
437
+ const updated: VChild[] = [vel('span', { id: 'b' }, vtext('new'))]
438
+ patchChildren(parent, old, updated)
439
+
440
+ expect(parent.querySelector('span')).toBe(span)
441
+ expect(span.id).toBe('b')
442
+ expect(span.textContent).toBe('new')
443
+ })
444
+
445
+ it('should replace when types differ', () => {
446
+ const parent = document.createElement('div')
447
+ const old: VChild[] = [vel('div', null, vtext('div'))]
448
+ patchChildren(parent, [], old)
449
+
450
+ const updated: VChild[] = [vel('span', null, vtext('span'))]
451
+ patchChildren(parent, old, updated)
452
+
453
+ expect(parent.children[0].tagName).toBe('SPAN')
454
+ expect(parent.textContent).toBe('span')
455
+ })
456
+
457
+ it('should add excess new children', () => {
458
+ const parent = document.createElement('div')
459
+ const old: VChild[] = [vel('p', null, vtext('a'))]
460
+ patchChildren(parent, [], old)
461
+
462
+ const updated: VChild[] = [vel('p', null, vtext('a')), vel('p', null, vtext('b'))]
463
+ patchChildren(parent, old, updated)
464
+
465
+ expect(parent.children.length).toBe(2)
466
+ })
467
+
468
+ it('should remove excess old children', () => {
469
+ const parent = document.createElement('div')
470
+ const old: VChild[] = [vel('p', null, vtext('a')), vel('p', null, vtext('b'))]
471
+ patchChildren(parent, [], old)
472
+
473
+ const updated: VChild[] = [vel('p', null, vtext('only'))]
474
+ patchChildren(parent, old, updated)
475
+
476
+ expect(parent.children.length).toBe(1)
477
+ expect(parent.textContent).toBe('only')
478
+ })
479
+
480
+ it('should preserve element identity across patches', () => {
481
+ const parent = document.createElement('div')
482
+ const old: VChild[] = [vel('input', { type: 'text' })]
483
+ patchChildren(parent, [], old)
484
+ const input = parent.querySelector('input')!
485
+
486
+ const updated: VChild[] = [vel('input', { type: 'text', id: 'updated' })]
487
+ patchChildren(parent, old, updated)
488
+
489
+ expect(parent.querySelector('input')).toBe(input)
490
+ expect(input.id).toBe('updated')
491
+ })
492
+
493
+ it('should handle EXISTING_NODE patching (same reference)', () => {
494
+ const parent = document.createElement('div')
495
+ const real = document.createElement('span')
496
+ real.textContent = 'real'
497
+
498
+ const old: VChild[] = [{ _brand: 'vnode', type: EXISTING_NODE, props: null, children: [], _el: real }]
499
+ patchChildren(parent, [], old)
500
+ expect(parent.firstChild).toBe(real)
501
+
502
+ const updated: VChild[] = [{ _brand: 'vnode', type: EXISTING_NODE, props: null, children: [], _el: real }]
503
+ patchChildren(parent, old, updated)
504
+ expect(parent.firstChild).toBe(real)
505
+ })
506
+
507
+ it('should mount new child into parent when types differ and old node is detached', () => {
508
+ const parent = document.createElement('div')
509
+ const old: VChild[] = [vel('div', null, vtext('div'))]
510
+ patchChildren(parent, [], old)
511
+
512
+ // Detach old node manually to simulate a detached state
513
+ const oldNode = old[0]._el!
514
+ oldNode.parentNode!.removeChild(oldNode)
515
+
516
+ const updated: VChild[] = [vel('span', null, vtext('span'))]
517
+ patchChildren(parent, old, updated)
518
+
519
+ expect(parent.children.length).toBe(1)
520
+ expect(parent.children[0].tagName).toBe('SPAN')
521
+ })
522
+
523
+ it('should clear ref on unmount', () => {
524
+ const parent = document.createElement('div')
525
+ const ref = { current: null } as { current: Element | null }
526
+ const child = vel('input', { ref })
527
+ patchChildren(parent, [], [child])
528
+ expect(ref.current).toBeInstanceOf(HTMLInputElement)
529
+
530
+ patchChildren(parent, [child], [])
531
+ expect(ref.current).toBeNull()
532
+ })
533
+
534
+ describe('Shade component boundaries', () => {
535
+ it('should call updateComponent on child Shade when props change', () => {
536
+ const parent = document.createElement('div')
537
+
538
+ const fakeShadeEl = document.createElement('my-shade') as unknown as JSX.Element
539
+ const updateFn = vi.fn()
540
+ ;(fakeShadeEl as unknown as Record<string, unknown>).updateComponent = updateFn
541
+ ;(fakeShadeEl as unknown as Record<string, unknown>).props = { count: 1 }
542
+ ;(fakeShadeEl as unknown as Record<string, unknown>).shadeChildren = undefined
543
+
544
+ const factory = vi.fn(() => fakeShadeEl as unknown as JSX.Element)
545
+
546
+ const old: VChild[] = [{ _brand: 'vnode', type: factory, props: { count: 1 }, children: [], _el: fakeShadeEl }]
547
+ // Simulate initial mount by manually appending
548
+ parent.appendChild(fakeShadeEl)
549
+
550
+ const updated: VChild[] = [{ _brand: 'vnode', type: factory, props: { count: 2 }, children: [] }]
551
+ patchChildren(parent, old, updated)
552
+
553
+ expect(updateFn).toHaveBeenCalledOnce()
554
+ expect(fakeShadeEl.props).toEqual({ count: 2 })
555
+ })
556
+
557
+ it('should NOT call updateComponent when props are unchanged', () => {
558
+ const parent = document.createElement('div')
559
+
560
+ const fakeShadeEl = document.createElement('my-shade-2') as unknown as JSX.Element
561
+ const updateFn = vi.fn()
562
+ const props = { count: 1 }
563
+ ;(fakeShadeEl as unknown as Record<string, unknown>).updateComponent = updateFn
564
+ ;(fakeShadeEl as unknown as Record<string, unknown>).props = props
565
+ ;(fakeShadeEl as unknown as Record<string, unknown>).shadeChildren = undefined
566
+
567
+ const factory = vi.fn(() => fakeShadeEl as unknown as JSX.Element)
568
+
569
+ const old: VChild[] = [{ _brand: 'vnode', type: factory, props: { count: 1 }, children: [], _el: fakeShadeEl }]
570
+ parent.appendChild(fakeShadeEl)
571
+
572
+ const updated: VChild[] = [{ _brand: 'vnode', type: factory, props: { count: 1 }, children: [] }]
573
+ patchChildren(parent, old, updated)
574
+
575
+ expect(updateFn).not.toHaveBeenCalled()
576
+ })
577
+ })
578
+ })
579
+ })