@furystack/shades 11.1.0 → 12.0.0
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.
- package/CHANGELOG.md +291 -0
- package/README.md +13 -13
- package/esm/component-factory.spec.js +13 -5
- package/esm/component-factory.spec.js.map +1 -1
- package/esm/components/index.d.ts +4 -1
- package/esm/components/index.d.ts.map +1 -1
- package/esm/components/index.js +4 -1
- package/esm/components/index.js.map +1 -1
- package/esm/components/lazy-load.d.ts +2 -4
- package/esm/components/lazy-load.d.ts.map +1 -1
- package/esm/components/lazy-load.js +40 -24
- package/esm/components/lazy-load.js.map +1 -1
- package/esm/components/lazy-load.spec.js +57 -50
- package/esm/components/lazy-load.spec.js.map +1 -1
- package/esm/components/link-to-route.d.ts +2 -0
- package/esm/components/link-to-route.d.ts.map +1 -1
- package/esm/components/link-to-route.js +3 -2
- package/esm/components/link-to-route.js.map +1 -1
- package/esm/components/link-to-route.spec.js +13 -9
- package/esm/components/link-to-route.spec.js.map +1 -1
- package/esm/components/nested-route-link.d.ts +62 -0
- package/esm/components/nested-route-link.d.ts.map +1 -0
- package/esm/components/nested-route-link.js +66 -0
- package/esm/components/nested-route-link.js.map +1 -0
- package/esm/components/nested-route-link.spec.d.ts +2 -0
- package/esm/components/nested-route-link.spec.d.ts.map +1 -0
- package/esm/components/nested-route-link.spec.js +179 -0
- package/esm/components/nested-route-link.spec.js.map +1 -0
- package/esm/components/nested-route-types.d.ts +37 -0
- package/esm/components/nested-route-types.d.ts.map +1 -0
- package/esm/components/nested-route-types.js +2 -0
- package/esm/components/nested-route-types.js.map +1 -0
- package/esm/components/nested-router.d.ts +103 -0
- package/esm/components/nested-router.d.ts.map +1 -0
- package/esm/components/nested-router.js +178 -0
- package/esm/components/nested-router.js.map +1 -0
- package/esm/components/nested-router.spec.d.ts +2 -0
- package/esm/components/nested-router.spec.d.ts.map +1 -0
- package/esm/components/nested-router.spec.js +659 -0
- package/esm/components/nested-router.spec.js.map +1 -0
- package/esm/components/route-link.d.ts +4 -0
- package/esm/components/route-link.d.ts.map +1 -1
- package/esm/components/route-link.js +5 -5
- package/esm/components/route-link.js.map +1 -1
- package/esm/components/route-link.spec.js +16 -12
- package/esm/components/route-link.spec.js.map +1 -1
- package/esm/components/router.d.ts +20 -2
- package/esm/components/router.d.ts.map +1 -1
- package/esm/components/router.js +3 -0
- package/esm/components/router.js.map +1 -1
- package/esm/components/router.spec.js +75 -74
- package/esm/components/router.spec.js.map +1 -1
- package/esm/initialize.d.ts +11 -0
- package/esm/initialize.d.ts.map +1 -1
- package/esm/initialize.js +5 -0
- package/esm/initialize.js.map +1 -1
- package/esm/jsx.d.ts +83 -2
- package/esm/jsx.d.ts.map +1 -1
- package/esm/models/children-list.d.ts +5 -1
- package/esm/models/children-list.d.ts.map +1 -1
- package/esm/models/partial-element.d.ts +12 -2
- package/esm/models/partial-element.d.ts.map +1 -1
- package/esm/models/render-options.d.ts +89 -3
- package/esm/models/render-options.d.ts.map +1 -1
- package/esm/models/selection-state.d.ts +4 -0
- package/esm/models/selection-state.d.ts.map +1 -1
- package/esm/services/location-service.d.ts +11 -0
- package/esm/services/location-service.d.ts.map +1 -1
- package/esm/services/location-service.js +11 -0
- package/esm/services/location-service.js.map +1 -1
- package/esm/services/resource-manager.d.ts +24 -0
- package/esm/services/resource-manager.d.ts.map +1 -1
- package/esm/services/resource-manager.js +30 -0
- package/esm/services/resource-manager.js.map +1 -1
- package/esm/services/resource-manager.spec.js +93 -0
- package/esm/services/resource-manager.spec.js.map +1 -1
- package/esm/services/screen-service.d.ts +81 -4
- package/esm/services/screen-service.d.ts.map +1 -1
- package/esm/services/screen-service.js +75 -4
- package/esm/services/screen-service.js.map +1 -1
- package/esm/services/screen-service.spec.js +91 -7
- package/esm/services/screen-service.spec.js.map +1 -1
- package/esm/shade-component.d.ts +17 -4
- package/esm/shade-component.d.ts.map +1 -1
- package/esm/shade-component.js +67 -5
- package/esm/shade-component.js.map +1 -1
- package/esm/shade-host-props-ref.integration.spec.d.ts +2 -0
- package/esm/shade-host-props-ref.integration.spec.d.ts.map +1 -0
- package/esm/shade-host-props-ref.integration.spec.js +381 -0
- package/esm/shade-host-props-ref.integration.spec.js.map +1 -0
- package/esm/shade-resources.integration.spec.js +208 -39
- package/esm/shade-resources.integration.spec.js.map +1 -1
- package/esm/shade.d.ts +20 -17
- package/esm/shade.d.ts.map +1 -1
- package/esm/shade.js +172 -33
- package/esm/shade.js.map +1 -1
- package/esm/shade.spec.js +31 -30
- package/esm/shade.spec.js.map +1 -1
- package/esm/shades.integration.spec.js +135 -72
- package/esm/shades.integration.spec.js.map +1 -1
- package/esm/style-manager.d.ts +2 -2
- package/esm/style-manager.js +2 -2
- package/esm/svg-types.d.ts +389 -0
- package/esm/svg-types.d.ts.map +1 -0
- package/esm/svg-types.js +9 -0
- package/esm/svg-types.js.map +1 -0
- package/esm/svg.d.ts +15 -0
- package/esm/svg.d.ts.map +1 -0
- package/esm/svg.js +76 -0
- package/esm/svg.js.map +1 -0
- package/esm/svg.spec.d.ts +2 -0
- package/esm/svg.spec.d.ts.map +1 -0
- package/esm/svg.spec.js +80 -0
- package/esm/svg.spec.js.map +1 -0
- package/esm/vnode.d.ts +103 -0
- package/esm/vnode.d.ts.map +1 -0
- package/esm/vnode.integration.spec.d.ts +2 -0
- package/esm/vnode.integration.spec.d.ts.map +1 -0
- package/esm/vnode.integration.spec.js +494 -0
- package/esm/vnode.integration.spec.js.map +1 -0
- package/esm/vnode.js +453 -0
- package/esm/vnode.js.map +1 -0
- package/esm/vnode.spec.d.ts +2 -0
- package/esm/vnode.spec.d.ts.map +1 -0
- package/esm/vnode.spec.js +473 -0
- package/esm/vnode.spec.js.map +1 -0
- package/package.json +3 -3
- package/src/component-factory.spec.tsx +18 -5
- package/src/components/index.ts +4 -1
- package/src/components/lazy-load.spec.tsx +82 -75
- package/src/components/lazy-load.tsx +49 -27
- package/src/components/link-to-route.spec.tsx +25 -21
- package/src/components/link-to-route.tsx +4 -2
- package/src/components/nested-route-link.spec.tsx +303 -0
- package/src/components/nested-route-link.tsx +100 -0
- package/src/components/nested-route-types.ts +42 -0
- package/src/components/nested-router.spec.tsx +817 -0
- package/src/components/nested-router.tsx +256 -0
- package/src/components/route-link.spec.tsx +22 -18
- package/src/components/route-link.tsx +6 -5
- package/src/components/router.spec.tsx +109 -108
- package/src/components/router.tsx +15 -2
- package/src/initialize.ts +12 -0
- package/src/jsx.ts +129 -2
- package/src/models/children-list.ts +7 -1
- package/src/models/partial-element.ts +13 -2
- package/src/models/render-options.ts +90 -3
- package/src/models/selection-state.ts +4 -0
- package/src/services/location-service.tsx +11 -0
- package/src/services/resource-manager.spec.ts +116 -0
- package/src/services/resource-manager.ts +30 -0
- package/src/services/screen-service.spec.ts +109 -7
- package/src/services/screen-service.ts +81 -4
- package/src/shade-component.ts +72 -6
- package/src/shade-host-props-ref.integration.spec.tsx +460 -0
- package/src/shade-resources.integration.spec.tsx +276 -52
- package/src/shade.spec.tsx +40 -39
- package/src/shade.ts +186 -58
- package/src/shades.integration.spec.tsx +154 -80
- package/src/style-manager.ts +2 -2
- package/src/svg-types.ts +437 -0
- package/src/svg.spec.ts +89 -0
- package/src/svg.ts +78 -0
- package/src/vnode.integration.spec.tsx +657 -0
- package/src/vnode.spec.ts +579 -0
- 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
|
+
})
|