@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,657 @@
1
+ import { Injector } from '@furystack/inject'
2
+ import { ObservableValue, usingAsync } from '@furystack/utils'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import { initializeShadeRoot } from './initialize.js'
5
+ import { createComponent } from './shade-component.js'
6
+ import { flushUpdates, Shade } from './shade.js'
7
+
8
+ describe('VNode reconciliation integration tests', () => {
9
+ beforeEach(() => {
10
+ document.body.innerHTML = '<div id="root"></div>'
11
+ })
12
+ afterEach(() => {
13
+ document.body.innerHTML = ''
14
+ })
15
+
16
+ describe('focus preservation', () => {
17
+ it('should preserve focus on an input element across re-renders', async () => {
18
+ await usingAsync(new Injector(), async (injector) => {
19
+ const rootElement = document.getElementById('root') as HTMLDivElement
20
+
21
+ const ExampleComponent = Shade({
22
+ shadowDomName: 'morph-focus-test',
23
+ render: ({ useState }) => {
24
+ const [label, setLabel] = useState('label', 'initial')
25
+ return (
26
+ <div>
27
+ <label>{label}</label>
28
+ <input id="my-input" type="text" />
29
+ <button id="update-label" onclick={() => setLabel('updated')}>
30
+ Update
31
+ </button>
32
+ </div>
33
+ )
34
+ },
35
+ })
36
+
37
+ initializeShadeRoot({
38
+ injector,
39
+ rootElement,
40
+ jsxElement: <ExampleComponent />,
41
+ })
42
+ await flushUpdates()
43
+
44
+ const input = document.getElementById('my-input') as HTMLInputElement
45
+ input.focus()
46
+ expect(document.activeElement).toBe(input)
47
+
48
+ // Trigger a re-render by clicking the button
49
+ document.getElementById('update-label')?.click()
50
+ await flushUpdates()
51
+
52
+ // The label should have updated
53
+ expect(document.querySelector('label')?.textContent).toBe('updated')
54
+
55
+ // The same input element should still be in the DOM and focused
56
+ const inputAfter = document.getElementById('my-input') as HTMLInputElement
57
+ expect(inputAfter).toBe(input)
58
+ expect(document.activeElement).toBe(input)
59
+ })
60
+ })
61
+
62
+ it('should preserve focus on a textarea across re-renders', async () => {
63
+ await usingAsync(new Injector(), async (injector) => {
64
+ const rootElement = document.getElementById('root') as HTMLDivElement
65
+
66
+ const ExampleComponent = Shade({
67
+ shadowDomName: 'morph-focus-textarea-test',
68
+ render: ({ useState }) => {
69
+ const [count, setCount] = useState('count', 0)
70
+ return (
71
+ <div>
72
+ <span>Count: {count}</span>
73
+ <textarea id="my-textarea" />
74
+ <button id="increment" onclick={() => setCount(count + 1)}>
75
+ +
76
+ </button>
77
+ </div>
78
+ )
79
+ },
80
+ })
81
+
82
+ initializeShadeRoot({
83
+ injector,
84
+ rootElement,
85
+ jsxElement: <ExampleComponent />,
86
+ })
87
+ await flushUpdates()
88
+
89
+ const textarea = document.getElementById('my-textarea') as HTMLTextAreaElement
90
+ textarea.focus()
91
+ expect(document.activeElement).toBe(textarea)
92
+
93
+ document.getElementById('increment')?.click()
94
+ await flushUpdates()
95
+
96
+ expect(document.querySelector('span')?.textContent).toBe('Count: 1')
97
+ expect(document.getElementById('my-textarea')).toBe(textarea)
98
+ expect(document.activeElement).toBe(textarea)
99
+ })
100
+ })
101
+ })
102
+
103
+ describe('form value preservation', () => {
104
+ it('should preserve user-typed input value across re-renders when value is not controlled', async () => {
105
+ await usingAsync(new Injector(), async (injector) => {
106
+ const rootElement = document.getElementById('root') as HTMLDivElement
107
+
108
+ const ExampleComponent = Shade({
109
+ shadowDomName: 'morph-form-value-test',
110
+ render: ({ useState }) => {
111
+ const [title, setTitle] = useState('title', 'Title')
112
+ return (
113
+ <div>
114
+ <h1>{title}</h1>
115
+ <input id="user-input" type="text" />
116
+ <button id="change-title" onclick={() => setTitle('New Title')}>
117
+ Change
118
+ </button>
119
+ </div>
120
+ )
121
+ },
122
+ })
123
+
124
+ initializeShadeRoot({
125
+ injector,
126
+ rootElement,
127
+ jsxElement: <ExampleComponent />,
128
+ })
129
+ await flushUpdates()
130
+
131
+ // Simulate user typing
132
+ const input = document.getElementById('user-input') as HTMLInputElement
133
+ input.value = 'user typed this'
134
+
135
+ // Trigger re-render
136
+ document.getElementById('change-title')?.click()
137
+ await flushUpdates()
138
+
139
+ // Title should have changed
140
+ expect(document.querySelector('h1')?.textContent).toBe('New Title')
141
+
142
+ // Input value should be preserved (same element, value not in render props)
143
+ const inputAfter = document.getElementById('user-input') as HTMLInputElement
144
+ expect(inputAfter).toBe(input)
145
+ expect(inputAfter.value).toBe('user typed this')
146
+ })
147
+ })
148
+
149
+ it('should preserve checkbox checked state across re-renders', async () => {
150
+ await usingAsync(new Injector(), async (injector) => {
151
+ const rootElement = document.getElementById('root') as HTMLDivElement
152
+
153
+ const ExampleComponent = Shade({
154
+ shadowDomName: 'morph-checkbox-test',
155
+ render: ({ useState }) => {
156
+ const [count, setCount] = useState('count', 0)
157
+ return (
158
+ <div>
159
+ <span>Count: {count}</span>
160
+ <input id="my-checkbox" type="checkbox" />
161
+ <button id="increment" onclick={() => setCount(count + 1)}>
162
+ +
163
+ </button>
164
+ </div>
165
+ )
166
+ },
167
+ })
168
+
169
+ initializeShadeRoot({
170
+ injector,
171
+ rootElement,
172
+ jsxElement: <ExampleComponent />,
173
+ })
174
+ await flushUpdates()
175
+
176
+ // User checks the checkbox
177
+ const checkbox = document.getElementById('my-checkbox') as HTMLInputElement
178
+ checkbox.checked = true
179
+
180
+ // Trigger re-render
181
+ document.getElementById('increment')?.click()
182
+ await flushUpdates()
183
+
184
+ expect(document.querySelector('span')?.textContent).toBe('Count: 1')
185
+
186
+ // Checkbox should still be checked
187
+ const checkboxAfter = document.getElementById('my-checkbox') as HTMLInputElement
188
+ expect(checkboxAfter).toBe(checkbox)
189
+ expect(checkboxAfter.checked).toBe(true)
190
+ })
191
+ })
192
+
193
+ it('should preserve select value across re-renders', async () => {
194
+ await usingAsync(new Injector(), async (injector) => {
195
+ const rootElement = document.getElementById('root') as HTMLDivElement
196
+
197
+ const ExampleComponent = Shade({
198
+ shadowDomName: 'morph-select-test',
199
+ render: ({ useState }) => {
200
+ const [label, setLabel] = useState('label', 'Pick one')
201
+ return (
202
+ <div>
203
+ <label>{label}</label>
204
+ <select id="my-select">
205
+ <option value="a">A</option>
206
+ <option value="b">B</option>
207
+ <option value="c">C</option>
208
+ </select>
209
+ <button id="update-label" onclick={() => setLabel('Updated label')}>
210
+ Update
211
+ </button>
212
+ </div>
213
+ )
214
+ },
215
+ })
216
+
217
+ initializeShadeRoot({
218
+ injector,
219
+ rootElement,
220
+ jsxElement: <ExampleComponent />,
221
+ })
222
+ await flushUpdates()
223
+
224
+ // User selects option B
225
+ const select = document.getElementById('my-select') as HTMLSelectElement
226
+ select.value = 'b'
227
+
228
+ // Trigger re-render
229
+ document.getElementById('update-label')?.click()
230
+ await flushUpdates()
231
+
232
+ expect(document.querySelector('label')?.textContent).toBe('Updated label')
233
+
234
+ // Select should still have value 'b'
235
+ const selectAfter = document.getElementById('my-select') as HTMLSelectElement
236
+ expect(selectAfter).toBe(select)
237
+ expect(selectAfter.value).toBe('b')
238
+ })
239
+ })
240
+ })
241
+
242
+ describe('element identity preservation', () => {
243
+ it('should preserve DOM element references across re-renders', async () => {
244
+ await usingAsync(new Injector(), async (injector) => {
245
+ const rootElement = document.getElementById('root') as HTMLDivElement
246
+
247
+ const ExampleComponent = Shade({
248
+ shadowDomName: 'morph-identity-test',
249
+ render: ({ useState }) => {
250
+ const [count, setCount] = useState('count', 0)
251
+ return (
252
+ <div id="container">
253
+ <span id="counter">Count: {count}</span>
254
+ <button id="increment" onclick={() => setCount(count + 1)}>
255
+ +
256
+ </button>
257
+ </div>
258
+ )
259
+ },
260
+ })
261
+
262
+ initializeShadeRoot({
263
+ injector,
264
+ rootElement,
265
+ jsxElement: <ExampleComponent />,
266
+ })
267
+ await flushUpdates()
268
+
269
+ const container = document.getElementById('container')
270
+ const counter = document.getElementById('counter')
271
+ const button = document.getElementById('increment')
272
+
273
+ // Trigger re-render
274
+ button?.click()
275
+ await flushUpdates()
276
+
277
+ // Same elements should be reused
278
+ expect(document.getElementById('container')).toBe(container)
279
+ expect(document.getElementById('counter')).toBe(counter)
280
+ expect(document.getElementById('increment')).toBe(button)
281
+ expect(counter?.textContent).toBe('Count: 1')
282
+ })
283
+ })
284
+
285
+ it('should replace element when tag changes between renders', async () => {
286
+ await usingAsync(new Injector(), async (injector) => {
287
+ const rootElement = document.getElementById('root') as HTMLDivElement
288
+
289
+ const ExampleComponent = Shade({
290
+ shadowDomName: 'morph-tag-change-test',
291
+ render: ({ useState }) => {
292
+ const [useDiv, setUseDiv] = useState('useDiv', true)
293
+ return useDiv ? (
294
+ <div id="content">
295
+ <button id="toggle" onclick={() => setUseDiv(false)}>
296
+ Toggle
297
+ </button>
298
+ </div>
299
+ ) : (
300
+ <section id="content">
301
+ <button id="toggle" onclick={() => setUseDiv(true)}>
302
+ Toggle
303
+ </button>
304
+ </section>
305
+ )
306
+ },
307
+ })
308
+
309
+ initializeShadeRoot({
310
+ injector,
311
+ rootElement,
312
+ jsxElement: <ExampleComponent />,
313
+ })
314
+ await flushUpdates()
315
+
316
+ const oldContent = document.getElementById('content')
317
+ expect(oldContent?.tagName).toBe('DIV')
318
+
319
+ document.getElementById('toggle')?.click()
320
+ await flushUpdates()
321
+
322
+ const newContent = document.getElementById('content')
323
+ expect(newContent?.tagName).toBe('SECTION')
324
+ // Different tag means different element
325
+ expect(newContent).not.toBe(oldContent)
326
+ })
327
+ })
328
+ })
329
+
330
+ describe('animation continuity', () => {
331
+ it('should preserve CSS class-based transitions by keeping element identity', async () => {
332
+ await usingAsync(new Injector(), async (injector) => {
333
+ const rootElement = document.getElementById('root') as HTMLDivElement
334
+
335
+ const ExampleComponent = Shade({
336
+ shadowDomName: 'morph-animation-test',
337
+ render: ({ useState }) => {
338
+ const [isActive, setIsActive] = useState('isActive', false)
339
+ return (
340
+ <div>
341
+ <div id="animated-box" className={isActive ? 'active' : 'inactive'} />
342
+ <button id="activate" onclick={() => setIsActive(true)}>
343
+ Activate
344
+ </button>
345
+ </div>
346
+ )
347
+ },
348
+ })
349
+
350
+ initializeShadeRoot({
351
+ injector,
352
+ rootElement,
353
+ jsxElement: <ExampleComponent />,
354
+ })
355
+ await flushUpdates()
356
+
357
+ const box = document.getElementById('animated-box')
358
+ expect(box?.className).toBe('inactive')
359
+
360
+ document.getElementById('activate')?.click()
361
+ await flushUpdates()
362
+
363
+ // Same element, class updated in place (animation would continue)
364
+ const boxAfter = document.getElementById('animated-box')
365
+ expect(boxAfter).toBe(box)
366
+ expect(boxAfter?.className).toBe('active')
367
+ })
368
+ })
369
+
370
+ it('should preserve inline style transitions by keeping element identity', async () => {
371
+ await usingAsync(new Injector(), async (injector) => {
372
+ const rootElement = document.getElementById('root') as HTMLDivElement
373
+
374
+ const ExampleComponent = Shade({
375
+ shadowDomName: 'morph-style-transition-test',
376
+ render: ({ useState }) => {
377
+ const [isExpanded, setIsExpanded] = useState('isExpanded', false)
378
+ return (
379
+ <div>
380
+ <div
381
+ id="expandable"
382
+ style={{
383
+ height: isExpanded ? '200px' : '50px',
384
+ transition: 'height 0.3s ease',
385
+ }}
386
+ />
387
+ <button id="expand" onclick={() => setIsExpanded(!isExpanded)}>
388
+ Toggle
389
+ </button>
390
+ </div>
391
+ )
392
+ },
393
+ })
394
+
395
+ initializeShadeRoot({
396
+ injector,
397
+ rootElement,
398
+ jsxElement: <ExampleComponent />,
399
+ })
400
+ await flushUpdates()
401
+
402
+ const expandable = document.getElementById('expandable')
403
+ expect(expandable?.style.height).toBe('50px')
404
+
405
+ document.getElementById('expand')?.click()
406
+ await flushUpdates()
407
+
408
+ // Same element, style updated in place (transition would animate)
409
+ const expandableAfter = document.getElementById('expandable')
410
+ expect(expandableAfter).toBe(expandable)
411
+ expect(expandableAfter?.style.height).toBe('200px')
412
+ expect(expandableAfter?.style.transition).toBe('height 0.3s ease')
413
+ })
414
+ })
415
+ })
416
+
417
+ describe('event handler updates', () => {
418
+ it('should update event handlers after re-render (closures capture new state)', async () => {
419
+ await usingAsync(new Injector(), async (injector) => {
420
+ const rootElement = document.getElementById('root') as HTMLDivElement
421
+
422
+ const clicks: number[] = []
423
+
424
+ const ExampleComponent = Shade({
425
+ shadowDomName: 'morph-handler-test',
426
+ render: ({ useState }) => {
427
+ const [count, setCount] = useState('count', 0)
428
+ return (
429
+ <div>
430
+ <span id="count">{count}</span>
431
+ <button
432
+ id="increment"
433
+ onclick={() => {
434
+ clicks.push(count)
435
+ setCount(count + 1)
436
+ }}
437
+ >
438
+ +
439
+ </button>
440
+ </div>
441
+ )
442
+ },
443
+ })
444
+
445
+ initializeShadeRoot({
446
+ injector,
447
+ rootElement,
448
+ jsxElement: <ExampleComponent />,
449
+ })
450
+ await flushUpdates()
451
+
452
+ const button = document.getElementById('increment')!
453
+
454
+ // First click: count is 0
455
+ button.click()
456
+ await flushUpdates()
457
+ expect(clicks).toEqual([0])
458
+ expect(document.getElementById('count')?.textContent).toBe('1')
459
+
460
+ // Second click: handler should capture count=1 after morph
461
+ button.click()
462
+ await flushUpdates()
463
+ expect(clicks).toEqual([0, 1])
464
+ expect(document.getElementById('count')?.textContent).toBe('2')
465
+
466
+ // Third click: handler should capture count=2
467
+ button.click()
468
+ await flushUpdates()
469
+ expect(clicks).toEqual([0, 1, 2])
470
+ expect(document.getElementById('count')?.textContent).toBe('3')
471
+ })
472
+ })
473
+ })
474
+
475
+ describe('observable-driven re-renders with morphing', () => {
476
+ it('should morph correctly when observable drives updates', async () => {
477
+ await usingAsync(new Injector(), async (injector) => {
478
+ const rootElement = document.getElementById('root') as HTMLDivElement
479
+
480
+ const obs = new ObservableValue('hello')
481
+
482
+ const ExampleComponent = Shade({
483
+ shadowDomName: 'morph-observable-test',
484
+ render: ({ useObservable }) => {
485
+ const [value] = useObservable('obs', obs)
486
+ return (
487
+ <div>
488
+ <span id="value">{value}</span>
489
+ <input id="my-input" type="text" />
490
+ </div>
491
+ )
492
+ },
493
+ })
494
+
495
+ initializeShadeRoot({
496
+ injector,
497
+ rootElement,
498
+ jsxElement: <ExampleComponent />,
499
+ })
500
+ await flushUpdates()
501
+
502
+ const input = document.getElementById('my-input') as HTMLInputElement
503
+ const span = document.getElementById('value')!
504
+ input.value = 'user text'
505
+ input.focus()
506
+
507
+ // External observable change
508
+ obs.setValue('world')
509
+ await flushUpdates()
510
+
511
+ // Text should update
512
+ expect(document.getElementById('value')).toBe(span)
513
+ expect(span.textContent).toBe('world')
514
+
515
+ // Input should be preserved
516
+ expect(document.getElementById('my-input')).toBe(input)
517
+ expect(input.value).toBe('user text')
518
+ expect(document.activeElement).toBe(input)
519
+ })
520
+ })
521
+ })
522
+
523
+ describe('fragment render result morphing', () => {
524
+ it('should morph fragment children across re-renders', async () => {
525
+ await usingAsync(new Injector(), async (injector) => {
526
+ const rootElement = document.getElementById('root') as HTMLDivElement
527
+
528
+ const ExampleComponent = Shade({
529
+ shadowDomName: 'morph-fragment-test',
530
+ render: ({ useState }) => {
531
+ const [count, setCount] = useState('count', 0)
532
+ return (
533
+ <>
534
+ <p id="counter">Count: {count}</p>
535
+ <button id="increment" onclick={() => setCount(count + 1)}>
536
+ +
537
+ </button>
538
+ </>
539
+ )
540
+ },
541
+ })
542
+
543
+ initializeShadeRoot({
544
+ injector,
545
+ rootElement,
546
+ jsxElement: <ExampleComponent />,
547
+ })
548
+ await flushUpdates()
549
+
550
+ const counter = document.getElementById('counter')
551
+ const button = document.getElementById('increment')
552
+
553
+ button?.click()
554
+ await flushUpdates()
555
+
556
+ // Elements should be reused
557
+ expect(document.getElementById('counter')).toBe(counter)
558
+ expect(document.getElementById('increment')).toBe(button)
559
+ expect(counter?.textContent).toBe('Count: 1')
560
+ })
561
+ })
562
+ })
563
+
564
+ describe('text render result optimization', () => {
565
+ it('should efficiently update text-only render results', async () => {
566
+ await usingAsync(new Injector(), async (injector) => {
567
+ const rootElement = document.getElementById('root') as HTMLDivElement
568
+
569
+ const ExampleComponent = Shade({
570
+ shadowDomName: 'morph-text-result-test',
571
+ render: ({ useState }) => {
572
+ const [text] = useState('text', 'initial')
573
+ return text
574
+ },
575
+ })
576
+
577
+ initializeShadeRoot({
578
+ injector,
579
+ rootElement,
580
+ jsxElement: <ExampleComponent />,
581
+ })
582
+ await flushUpdates()
583
+
584
+ const shadeEl = document.querySelector('morph-text-result-test')!
585
+ expect(shadeEl.textContent).toBe('initial')
586
+ const textNode = shadeEl.firstChild
587
+
588
+ // Trigger update via the shade element
589
+ const el = shadeEl as JSX.Element
590
+ ;(
591
+ el as unknown as { resourceManager: { stateObservers: Map<string, ObservableValue<string>> } }
592
+ ).resourceManager.stateObservers
593
+ .get('text')
594
+ ?.setValue('updated')
595
+ await flushUpdates()
596
+
597
+ expect(shadeEl.textContent).toBe('updated')
598
+ // Text node should be reused (not recreated)
599
+ expect(shadeEl.firstChild).toBe(textNode)
600
+ })
601
+ })
602
+ })
603
+
604
+ describe('Shade component boundary morphing', () => {
605
+ it('should update child Shade component props without recreating it', async () => {
606
+ await usingAsync(new Injector(), async (injector) => {
607
+ const rootElement = document.getElementById('root') as HTMLDivElement
608
+
609
+ const childRenderSpy = vi.fn()
610
+
611
+ const ChildComponent = Shade<{ value: number }>({
612
+ shadowDomName: 'morph-child-component',
613
+ render: ({ props }) => {
614
+ childRenderSpy()
615
+ return <span id="child-value">{props.value}</span>
616
+ },
617
+ })
618
+
619
+ const ParentComponent = Shade({
620
+ shadowDomName: 'morph-parent-component',
621
+ render: ({ useState }) => {
622
+ const [count, setCount] = useState('count', 0)
623
+ return (
624
+ <div>
625
+ <ChildComponent value={count} />
626
+ <button id="parent-increment" onclick={() => setCount(count + 1)}>
627
+ +
628
+ </button>
629
+ </div>
630
+ )
631
+ },
632
+ })
633
+
634
+ initializeShadeRoot({
635
+ injector,
636
+ rootElement,
637
+ jsxElement: <ParentComponent />,
638
+ })
639
+ await flushUpdates()
640
+ await flushUpdates()
641
+
642
+ expect(document.getElementById('child-value')?.textContent).toBe('0')
643
+ const childElement = document.querySelector('morph-child-component')
644
+
645
+ // Trigger parent re-render
646
+ document.getElementById('parent-increment')?.click()
647
+ await flushUpdates()
648
+ await flushUpdates()
649
+
650
+ // Child should be the same DOM element (not recreated)
651
+ expect(document.querySelector('morph-child-component')).toBe(childElement)
652
+ // Child should have been updated with new props
653
+ expect(document.getElementById('child-value')?.textContent).toBe('1')
654
+ })
655
+ })
656
+ })
657
+ })