@pyreon/runtime-dom 0.12.7 → 0.12.8
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.
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { defineComponent, For, h, onMount, onUnmount, Show } from '@pyreon/core'
|
|
2
|
+
import { signal } from '@pyreon/reactivity'
|
|
3
|
+
import { mount } from '../index'
|
|
4
|
+
|
|
5
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
function container(): HTMLElement {
|
|
8
|
+
const el = document.createElement('div')
|
|
9
|
+
document.body.appendChild(el)
|
|
10
|
+
return el
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
document.body.innerHTML = ''
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// ─── Show toggle ────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
describe('lifecycle — Show toggle', () => {
|
|
20
|
+
test('show=true — child mounts, signal reactive', () => {
|
|
21
|
+
const el = container()
|
|
22
|
+
const text = signal('hello')
|
|
23
|
+
mount(
|
|
24
|
+
h(Show, { when: () => true }, h('div', null, () => text())),
|
|
25
|
+
el,
|
|
26
|
+
)
|
|
27
|
+
expect(el.textContent).toBe('hello')
|
|
28
|
+
text.set('world')
|
|
29
|
+
expect(el.textContent).toBe('world')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('show=false — child unmounts', () => {
|
|
33
|
+
const el = container()
|
|
34
|
+
const visible = signal(false)
|
|
35
|
+
mount(
|
|
36
|
+
h(Show, { when: visible }, h('div', { id: 'child' }, 'content')),
|
|
37
|
+
el,
|
|
38
|
+
)
|
|
39
|
+
expect(el.querySelector('#child')).toBeNull()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('show=true again — fresh mount, signal still reactive', () => {
|
|
43
|
+
const el = container()
|
|
44
|
+
const visible = signal(true)
|
|
45
|
+
const text = signal('initial')
|
|
46
|
+
mount(
|
|
47
|
+
h(Show, { when: visible }, h('div', { id: 'child' }, () => text())),
|
|
48
|
+
el,
|
|
49
|
+
)
|
|
50
|
+
expect(el.querySelector('#child')?.textContent).toBe('initial')
|
|
51
|
+
|
|
52
|
+
// Hide
|
|
53
|
+
visible.set(false)
|
|
54
|
+
expect(el.querySelector('#child')).toBeNull()
|
|
55
|
+
|
|
56
|
+
// Show again
|
|
57
|
+
visible.set(true)
|
|
58
|
+
expect(el.querySelector('#child')).not.toBeNull()
|
|
59
|
+
|
|
60
|
+
// Signal should still be reactive in new mount
|
|
61
|
+
text.set('updated')
|
|
62
|
+
expect(el.querySelector('#child')?.textContent).toBe('updated')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('rapid toggle (true -> false -> true) — no duplicate effects', () => {
|
|
66
|
+
const el = container()
|
|
67
|
+
const visible = signal(true)
|
|
68
|
+
let mountCount = 0
|
|
69
|
+
|
|
70
|
+
const Child = defineComponent(() => {
|
|
71
|
+
mountCount++
|
|
72
|
+
return h('div', null, 'child')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
mount(h(Show, { when: visible }, h(Child, null)), el)
|
|
76
|
+
expect(mountCount).toBe(1)
|
|
77
|
+
|
|
78
|
+
// Rapid toggle
|
|
79
|
+
visible.set(false)
|
|
80
|
+
visible.set(true)
|
|
81
|
+
// After rapid toggle, component should be mounted exactly once more
|
|
82
|
+
expect(mountCount).toBe(2)
|
|
83
|
+
|
|
84
|
+
visible.set(false)
|
|
85
|
+
visible.set(true)
|
|
86
|
+
expect(mountCount).toBe(3)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// ─── For list ───────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe('lifecycle — For list', () => {
|
|
93
|
+
type Item = { id: number; label: string }
|
|
94
|
+
|
|
95
|
+
test('render list of 3 items — 3 DOM nodes', () => {
|
|
96
|
+
const el = container()
|
|
97
|
+
const items = signal<Item[]>([
|
|
98
|
+
{ id: 1, label: 'Alice' },
|
|
99
|
+
{ id: 2, label: 'Bob' },
|
|
100
|
+
{ id: 3, label: 'Charlie' },
|
|
101
|
+
])
|
|
102
|
+
mount(
|
|
103
|
+
h(
|
|
104
|
+
'ul',
|
|
105
|
+
null,
|
|
106
|
+
For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
|
|
107
|
+
),
|
|
108
|
+
el,
|
|
109
|
+
)
|
|
110
|
+
expect(el.querySelectorAll('li').length).toBe(3)
|
|
111
|
+
expect(el.querySelectorAll('li')[0]?.textContent).toBe('Alice')
|
|
112
|
+
expect(el.querySelectorAll('li')[1]?.textContent).toBe('Bob')
|
|
113
|
+
expect(el.querySelectorAll('li')[2]?.textContent).toBe('Charlie')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('add item — 4 DOM nodes, existing unchanged', () => {
|
|
117
|
+
const el = container()
|
|
118
|
+
const items = signal<Item[]>([
|
|
119
|
+
{ id: 1, label: 'Alice' },
|
|
120
|
+
{ id: 2, label: 'Bob' },
|
|
121
|
+
{ id: 3, label: 'Charlie' },
|
|
122
|
+
])
|
|
123
|
+
mount(
|
|
124
|
+
h(
|
|
125
|
+
'ul',
|
|
126
|
+
null,
|
|
127
|
+
For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
|
|
128
|
+
),
|
|
129
|
+
el,
|
|
130
|
+
)
|
|
131
|
+
const firstLi = el.querySelectorAll('li')[0]
|
|
132
|
+
|
|
133
|
+
items.set([...items(), { id: 4, label: 'Dave' }])
|
|
134
|
+
expect(el.querySelectorAll('li').length).toBe(4)
|
|
135
|
+
expect(el.querySelectorAll('li')[3]?.textContent).toBe('Dave')
|
|
136
|
+
// Existing nodes should be reused (same DOM identity)
|
|
137
|
+
expect(el.querySelectorAll('li')[0]).toBe(firstLi)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('remove item — 2 DOM nodes', () => {
|
|
141
|
+
const el = container()
|
|
142
|
+
const items = signal<Item[]>([
|
|
143
|
+
{ id: 1, label: 'Alice' },
|
|
144
|
+
{ id: 2, label: 'Bob' },
|
|
145
|
+
{ id: 3, label: 'Charlie' },
|
|
146
|
+
])
|
|
147
|
+
mount(
|
|
148
|
+
h(
|
|
149
|
+
'ul',
|
|
150
|
+
null,
|
|
151
|
+
For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
|
|
152
|
+
),
|
|
153
|
+
el,
|
|
154
|
+
)
|
|
155
|
+
items.set([{ id: 1, label: 'Alice' }, { id: 3, label: 'Charlie' }])
|
|
156
|
+
expect(el.querySelectorAll('li').length).toBe(2)
|
|
157
|
+
expect(el.querySelectorAll('li')[0]?.textContent).toBe('Alice')
|
|
158
|
+
expect(el.querySelectorAll('li')[1]?.textContent).toBe('Charlie')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('update item signal — only that item DOM changes', () => {
|
|
162
|
+
const el = container()
|
|
163
|
+
const items = signal([
|
|
164
|
+
{ id: 1, name: signal('Alice') },
|
|
165
|
+
{ id: 2, name: signal('Bob') },
|
|
166
|
+
])
|
|
167
|
+
mount(
|
|
168
|
+
h(
|
|
169
|
+
'ul',
|
|
170
|
+
null,
|
|
171
|
+
For({
|
|
172
|
+
each: items,
|
|
173
|
+
by: (r) => r.id,
|
|
174
|
+
children: (r) => h('li', { key: r.id }, () => r.name()),
|
|
175
|
+
}),
|
|
176
|
+
),
|
|
177
|
+
el,
|
|
178
|
+
)
|
|
179
|
+
expect(el.querySelectorAll('li')[0]?.textContent).toBe('Alice')
|
|
180
|
+
|
|
181
|
+
// Update only the first item's signal
|
|
182
|
+
items()[0]!.name.set('Alicia')
|
|
183
|
+
expect(el.querySelectorAll('li')[0]?.textContent).toBe('Alicia')
|
|
184
|
+
// Second item unchanged
|
|
185
|
+
expect(el.querySelectorAll('li')[1]?.textContent).toBe('Bob')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('reorder items — DOM reordered without remount', () => {
|
|
189
|
+
const el = container()
|
|
190
|
+
const r1: Item = { id: 1, label: 'a' }
|
|
191
|
+
const r2: Item = { id: 2, label: 'b' }
|
|
192
|
+
const r3: Item = { id: 3, label: 'c' }
|
|
193
|
+
const items = signal<Item[]>([r1, r2, r3])
|
|
194
|
+
mount(
|
|
195
|
+
h(
|
|
196
|
+
'ul',
|
|
197
|
+
null,
|
|
198
|
+
For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
|
|
199
|
+
),
|
|
200
|
+
el,
|
|
201
|
+
)
|
|
202
|
+
const origLi1 = el.querySelectorAll('li')[0]
|
|
203
|
+
const origLi2 = el.querySelectorAll('li')[1]
|
|
204
|
+
const origLi3 = el.querySelectorAll('li')[2]
|
|
205
|
+
|
|
206
|
+
// Reverse order
|
|
207
|
+
items.set([r3, r2, r1])
|
|
208
|
+
const lis = el.querySelectorAll('li')
|
|
209
|
+
expect(lis[0]?.textContent).toBe('c')
|
|
210
|
+
expect(lis[1]?.textContent).toBe('b')
|
|
211
|
+
expect(lis[2]?.textContent).toBe('a')
|
|
212
|
+
// DOM nodes should be reused (moved, not recreated)
|
|
213
|
+
expect(lis[0]).toBe(origLi3)
|
|
214
|
+
expect(lis[1]).toBe(origLi2)
|
|
215
|
+
expect(lis[2]).toBe(origLi1)
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// ─── Effect cleanup ─────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
describe('lifecycle — effect cleanup', () => {
|
|
222
|
+
test('effect inside component — runs on mount', () => {
|
|
223
|
+
const el = container()
|
|
224
|
+
let effectRan = false
|
|
225
|
+
const Comp = defineComponent(() => {
|
|
226
|
+
onMount(() => {
|
|
227
|
+
effectRan = true
|
|
228
|
+
})
|
|
229
|
+
return h('div', null, 'mounted')
|
|
230
|
+
})
|
|
231
|
+
mount(h(Comp, null), el)
|
|
232
|
+
expect(effectRan).toBe(true)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('unmount component — effect cleaned up (verify via mock)', () => {
|
|
236
|
+
const el = container()
|
|
237
|
+
let cleanupCalled = false
|
|
238
|
+
const Comp = defineComponent(() => {
|
|
239
|
+
onMount(() => {
|
|
240
|
+
return () => {
|
|
241
|
+
cleanupCalled = true
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
return h('div', null, 'with-cleanup')
|
|
245
|
+
})
|
|
246
|
+
const unmount = mount(h(Comp, null), el)
|
|
247
|
+
expect(cleanupCalled).toBe(false)
|
|
248
|
+
|
|
249
|
+
unmount()
|
|
250
|
+
expect(cleanupCalled).toBe(true)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('mount 10 components — unmount all — verify no lingering effects', () => {
|
|
254
|
+
const el = container()
|
|
255
|
+
let cleanupCount = 0
|
|
256
|
+
const totalComponents = 10
|
|
257
|
+
|
|
258
|
+
const Comp = defineComponent(() => {
|
|
259
|
+
onUnmount(() => {
|
|
260
|
+
cleanupCount++
|
|
261
|
+
})
|
|
262
|
+
return h('span', null, 'item')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
const unmount = mount(
|
|
266
|
+
h(
|
|
267
|
+
'div',
|
|
268
|
+
null,
|
|
269
|
+
...Array.from({ length: totalComponents }, (_, i) => h(Comp, { key: i })),
|
|
270
|
+
),
|
|
271
|
+
el,
|
|
272
|
+
)
|
|
273
|
+
expect(el.querySelectorAll('span').length).toBe(totalComponents)
|
|
274
|
+
|
|
275
|
+
unmount()
|
|
276
|
+
expect(cleanupCount).toBe(totalComponents)
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// ─── Deep nesting ───────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
describe('lifecycle — deep nesting', () => {
|
|
283
|
+
test('4 levels of components with signals — all reactive', () => {
|
|
284
|
+
const el = container()
|
|
285
|
+
const s1 = signal('L1')
|
|
286
|
+
const s2 = signal('L2')
|
|
287
|
+
const s3 = signal('L3')
|
|
288
|
+
const s4 = signal('L4')
|
|
289
|
+
|
|
290
|
+
const Level4 = () => h('span', { class: 'l4' }, () => s4())
|
|
291
|
+
const Level3 = () => h('div', { class: 'l3' }, () => s3(), h(Level4, null))
|
|
292
|
+
const Level2 = () => h('div', { class: 'l2' }, () => s2(), h(Level3, null))
|
|
293
|
+
const Level1 = () => h('div', { class: 'l1' }, () => s1(), h(Level2, null))
|
|
294
|
+
|
|
295
|
+
mount(h(Level1, null), el)
|
|
296
|
+
expect(el.textContent).toContain('L1')
|
|
297
|
+
expect(el.textContent).toContain('L2')
|
|
298
|
+
expect(el.textContent).toContain('L3')
|
|
299
|
+
expect(el.textContent).toContain('L4')
|
|
300
|
+
|
|
301
|
+
s1.set('L1-updated')
|
|
302
|
+
expect(el.textContent).toContain('L1-updated')
|
|
303
|
+
|
|
304
|
+
s4.set('L4-updated')
|
|
305
|
+
expect(el.textContent).toContain('L4-updated')
|
|
306
|
+
|
|
307
|
+
// Middle levels also reactive
|
|
308
|
+
s2.set('L2-updated')
|
|
309
|
+
s3.set('L3-updated')
|
|
310
|
+
expect(el.textContent).toContain('L2-updated')
|
|
311
|
+
expect(el.textContent).toContain('L3-updated')
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test('unmount root — all nested effects cleaned', () => {
|
|
315
|
+
const el = container()
|
|
316
|
+
let cleanupCount = 0
|
|
317
|
+
|
|
318
|
+
const Level4 = defineComponent(() => {
|
|
319
|
+
onUnmount(() => { cleanupCount++ })
|
|
320
|
+
return h('span', null, 'l4')
|
|
321
|
+
})
|
|
322
|
+
const Level3 = defineComponent(() => {
|
|
323
|
+
onUnmount(() => { cleanupCount++ })
|
|
324
|
+
return h('div', null, 'l3', h(Level4, null))
|
|
325
|
+
})
|
|
326
|
+
const Level2 = defineComponent(() => {
|
|
327
|
+
onUnmount(() => { cleanupCount++ })
|
|
328
|
+
return h('div', null, 'l2', h(Level3, null))
|
|
329
|
+
})
|
|
330
|
+
const Level1 = defineComponent(() => {
|
|
331
|
+
onUnmount(() => { cleanupCount++ })
|
|
332
|
+
return h('div', null, 'l1', h(Level2, null))
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
const unmount = mount(h(Level1, null), el)
|
|
336
|
+
expect(el.textContent).toContain('l1')
|
|
337
|
+
expect(el.textContent).toContain('l4')
|
|
338
|
+
|
|
339
|
+
unmount()
|
|
340
|
+
expect(cleanupCount).toBe(4)
|
|
341
|
+
})
|
|
342
|
+
})
|