@pyreon/runtime-dom 0.24.5 → 0.24.6
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/package.json +5 -9
- package/src/delegate.ts +0 -98
- package/src/devtools.ts +0 -339
- package/src/env.d.ts +0 -6
- package/src/hydrate.ts +0 -450
- package/src/hydration-debug.ts +0 -129
- package/src/index.ts +0 -83
- package/src/keep-alive-entry.ts +0 -3
- package/src/keep-alive.ts +0 -83
- package/src/manifest.ts +0 -236
- package/src/mount.ts +0 -597
- package/src/nodes.ts +0 -896
- package/src/props.ts +0 -474
- package/src/template.ts +0 -523
- package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
- package/src/tests/callback-ref-unmount.test.ts +0 -52
- package/src/tests/compiler-integration.test.tsx +0 -508
- package/src/tests/coverage-gaps.test.ts +0 -3183
- package/src/tests/coverage.test.ts +0 -1140
- package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
- package/src/tests/dev-gate-pattern.test.ts +0 -46
- package/src/tests/dev-gate-treeshake.test.ts +0 -256
- package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
- package/src/tests/fanout-repro.test.tsx +0 -219
- package/src/tests/hydration-integration.test.tsx +0 -540
- package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
- package/src/tests/lifecycle-integration.test.tsx +0 -342
- package/src/tests/lis-prepend.browser.test.ts +0 -99
- package/src/tests/manifest-snapshot.test.ts +0 -85
- package/src/tests/mount.test.ts +0 -3529
- package/src/tests/native-markers.test.ts +0 -19
- package/src/tests/props.test.ts +0 -581
- package/src/tests/reactive-props.test.ts +0 -270
- package/src/tests/real-world-integration.test.tsx +0 -714
- package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
- package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
- package/src/tests/rs-collapse-h.browser.test.ts +0 -152
- package/src/tests/rs-collapse-h.test.ts +0 -237
- package/src/tests/rs-collapse.browser.test.ts +0 -128
- package/src/tests/runtime-dom.browser.test.ts +0 -409
- package/src/tests/setup.ts +0 -3
- package/src/tests/show-context.test.ts +0 -270
- package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
- package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
- package/src/tests/style-key-removal.browser.test.ts +0 -54
- package/src/tests/style-key-removal.test.ts +0 -88
- package/src/tests/template.test.ts +0 -383
- package/src/tests/transition-timeout-leak.test.ts +0 -126
- package/src/tests/transition.test.ts +0 -568
- package/src/tests/verified-correct-probes.test.ts +0 -56
- package/src/transition-entry.ts +0 -7
- package/src/transition-group.ts +0 -350
- package/src/transition.ts +0 -245
package/src/tests/mount.test.ts
DELETED
|
@@ -1,3529 +0,0 @@
|
|
|
1
|
-
import type { ComponentFn, VNodeChild } from '@pyreon/core'
|
|
2
|
-
import {
|
|
3
|
-
ErrorBoundary as _ErrorBoundary,
|
|
4
|
-
createRef,
|
|
5
|
-
Dynamic,
|
|
6
|
-
defineComponent,
|
|
7
|
-
For,
|
|
8
|
-
Fragment,
|
|
9
|
-
h,
|
|
10
|
-
lazy,
|
|
11
|
-
Match,
|
|
12
|
-
onMount,
|
|
13
|
-
onUnmount,
|
|
14
|
-
onUpdate,
|
|
15
|
-
Portal,
|
|
16
|
-
Show,
|
|
17
|
-
Suspense as _Suspense,
|
|
18
|
-
Switch,
|
|
19
|
-
} from '@pyreon/core'
|
|
20
|
-
import { cell, signal } from '@pyreon/reactivity'
|
|
21
|
-
import { installDevTools, registerComponent, unregisterComponent } from '../devtools'
|
|
22
|
-
import {
|
|
23
|
-
KeepAlive as _KeepAlive,
|
|
24
|
-
Transition as _Transition,
|
|
25
|
-
TransitionGroup as _TransitionGroup,
|
|
26
|
-
createTemplate,
|
|
27
|
-
disableHydrationWarnings,
|
|
28
|
-
enableHydrationWarnings,
|
|
29
|
-
hydrateRoot,
|
|
30
|
-
mount,
|
|
31
|
-
sanitizeHtml,
|
|
32
|
-
setSanitizer,
|
|
33
|
-
} from '../index'
|
|
34
|
-
import { mountChild } from '../mount'
|
|
35
|
-
|
|
36
|
-
// Cast components that return VNodeChild (not VNode | null) so h() accepts them
|
|
37
|
-
const Transition = _Transition as unknown as ComponentFn<Record<string, unknown>>
|
|
38
|
-
const TransitionGroup = _TransitionGroup as unknown as ComponentFn<Record<string, unknown>>
|
|
39
|
-
const ErrorBoundary = _ErrorBoundary as unknown as ComponentFn<Record<string, unknown>>
|
|
40
|
-
const KeepAlive = _KeepAlive as unknown as ComponentFn<Record<string, unknown>>
|
|
41
|
-
const Suspense = _Suspense as unknown as ComponentFn<Record<string, unknown>>
|
|
42
|
-
|
|
43
|
-
function container(): HTMLElement {
|
|
44
|
-
const el = document.createElement('div')
|
|
45
|
-
document.body.appendChild(el)
|
|
46
|
-
return el
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ─── Static mounting ─────────────────────────────────────────────────────────
|
|
50
|
-
|
|
51
|
-
describe('mount — static', () => {
|
|
52
|
-
test('mounts a text node', () => {
|
|
53
|
-
const el = container()
|
|
54
|
-
mount('hello', el)
|
|
55
|
-
expect(el.textContent).toBe('hello')
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
test('mounts a number as text', () => {
|
|
59
|
-
const el = container()
|
|
60
|
-
mount(42, el)
|
|
61
|
-
expect(el.textContent).toBe('42')
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
test('mounts a simple element', () => {
|
|
65
|
-
const el = container()
|
|
66
|
-
mount(h('span', null, 'world'), el)
|
|
67
|
-
expect(el.innerHTML).toBe('<span>world</span>')
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
test('mounts nested elements', () => {
|
|
71
|
-
const el = container()
|
|
72
|
-
mount(h('div', null, h('p', null, 'nested')), el)
|
|
73
|
-
expect(el.querySelector('p')?.textContent).toBe('nested')
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
test('mounts null / undefined / false as nothing', () => {
|
|
77
|
-
const el = container()
|
|
78
|
-
mount(null, el)
|
|
79
|
-
expect(el.innerHTML).toBe('')
|
|
80
|
-
mount(undefined, el)
|
|
81
|
-
expect(el.innerHTML).toBe('')
|
|
82
|
-
mount(false, el)
|
|
83
|
-
expect(el.innerHTML).toBe('')
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
test('mounts a Fragment', () => {
|
|
87
|
-
const el = container()
|
|
88
|
-
mount(h(Fragment, null, h('span', null, 'a'), h('span', null, 'b')), el)
|
|
89
|
-
expect(el.querySelectorAll('span').length).toBe(2)
|
|
90
|
-
})
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
// ─── Props ────────────────────────────────────────────────────────────────────
|
|
94
|
-
|
|
95
|
-
describe('mount — props', () => {
|
|
96
|
-
test('sets class attribute', () => {
|
|
97
|
-
const el = container()
|
|
98
|
-
mount(h('div', { class: 'foo bar' }), el)
|
|
99
|
-
expect(el.querySelector('div')?.className).toBe('foo bar')
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
test('sets arbitrary attribute', () => {
|
|
103
|
-
const el = container()
|
|
104
|
-
mount(h('div', { 'data-id': '123' }), el)
|
|
105
|
-
expect(el.querySelector('div')?.getAttribute('data-id')).toBe('123')
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
test('removes attribute when value is null', () => {
|
|
109
|
-
const el = container()
|
|
110
|
-
mount(h('div', { 'data-x': null }), el)
|
|
111
|
-
expect(el.querySelector('div')?.hasAttribute('data-x')).toBe(false)
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
test('attaches event listener', () => {
|
|
115
|
-
const el = container()
|
|
116
|
-
let clicked = false
|
|
117
|
-
mount(
|
|
118
|
-
h(
|
|
119
|
-
'button',
|
|
120
|
-
{
|
|
121
|
-
onClick: () => {
|
|
122
|
-
clicked = true
|
|
123
|
-
},
|
|
124
|
-
},
|
|
125
|
-
'click me',
|
|
126
|
-
),
|
|
127
|
-
el,
|
|
128
|
-
)
|
|
129
|
-
el.querySelector('button')?.click()
|
|
130
|
-
expect(clicked).toBe(true)
|
|
131
|
-
})
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
// ─── Reactive props & children ────────────────────────────────────────────────
|
|
135
|
-
|
|
136
|
-
describe('mount — reactive', () => {
|
|
137
|
-
test('reactive text child updates', () => {
|
|
138
|
-
const el = container()
|
|
139
|
-
const text = signal('hello')
|
|
140
|
-
mount(
|
|
141
|
-
h('div', null, () => text()),
|
|
142
|
-
el,
|
|
143
|
-
)
|
|
144
|
-
expect(el.querySelector('div')?.textContent).toBe('hello')
|
|
145
|
-
text.set('world')
|
|
146
|
-
expect(el.querySelector('div')?.textContent).toBe('world')
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
test('reactive class prop updates', () => {
|
|
150
|
-
const el = container()
|
|
151
|
-
const cls = signal('a')
|
|
152
|
-
mount(h('div', { class: () => cls() }), el)
|
|
153
|
-
expect(el.querySelector('div')?.className).toBe('a')
|
|
154
|
-
cls.set('b')
|
|
155
|
-
expect(el.querySelector('div')?.className).toBe('b')
|
|
156
|
-
})
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
// ─── Components ───────────────────────────────────────────────────────────────
|
|
160
|
-
|
|
161
|
-
describe('mount — components', () => {
|
|
162
|
-
test('mounts a functional component', () => {
|
|
163
|
-
const Greeting = defineComponent(({ name }: { name: string }) =>
|
|
164
|
-
h('p', null, `Hello, ${name}!`),
|
|
165
|
-
)
|
|
166
|
-
const el = container()
|
|
167
|
-
mount(h(Greeting, { name: 'Pyreon' }), el)
|
|
168
|
-
expect(el.querySelector('p')?.textContent).toBe('Hello, Pyreon!')
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
test('component with reactive state updates DOM', () => {
|
|
172
|
-
const Counter = defineComponent(() => {
|
|
173
|
-
const count = signal(0)
|
|
174
|
-
return h(
|
|
175
|
-
'div',
|
|
176
|
-
null,
|
|
177
|
-
h('span', null, () => String(count())),
|
|
178
|
-
h('button', { onClick: () => count.update((n) => n + 1) }, '+'),
|
|
179
|
-
)
|
|
180
|
-
})
|
|
181
|
-
const el = container()
|
|
182
|
-
mount(h(Counter, {}), el)
|
|
183
|
-
expect(el.querySelector('span')?.textContent).toBe('0')
|
|
184
|
-
el.querySelector('button')?.click()
|
|
185
|
-
expect(el.querySelector('span')?.textContent).toBe('1')
|
|
186
|
-
el.querySelector('button')?.click()
|
|
187
|
-
expect(el.querySelector('span')?.textContent).toBe('2')
|
|
188
|
-
})
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
// ─── Unmount ──────────────────────────────────────────────────────────────────
|
|
192
|
-
|
|
193
|
-
describe('mount — refs', () => {
|
|
194
|
-
test('ref.current is set after mount', () => {
|
|
195
|
-
const el = container()
|
|
196
|
-
const ref = createRef<HTMLButtonElement>()
|
|
197
|
-
expect(ref.current).toBeNull()
|
|
198
|
-
mount(h('button', { ref }), el)
|
|
199
|
-
expect(ref.current).toBeInstanceOf(HTMLButtonElement)
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
test('ref.current is cleared after unmount', () => {
|
|
203
|
-
const el = container()
|
|
204
|
-
const ref = createRef<HTMLDivElement>()
|
|
205
|
-
const unmount = mount(h('div', { ref }), el)
|
|
206
|
-
expect(ref.current).not.toBeNull()
|
|
207
|
-
unmount()
|
|
208
|
-
expect(ref.current).toBeNull()
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
test('callback ref is called with element after mount', () => {
|
|
212
|
-
const el = container()
|
|
213
|
-
let refEl: Element | null = null
|
|
214
|
-
mount(
|
|
215
|
-
h('div', {
|
|
216
|
-
ref: (e: Element) => {
|
|
217
|
-
refEl = e
|
|
218
|
-
},
|
|
219
|
-
}),
|
|
220
|
-
el,
|
|
221
|
-
)
|
|
222
|
-
expect(refEl).toBeInstanceOf(HTMLDivElement)
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
test('callback ref is invoked with null on unmount', () => {
|
|
226
|
-
const el = container()
|
|
227
|
-
let refEl: Element | null = null
|
|
228
|
-
const unmount = mount(
|
|
229
|
-
h('div', {
|
|
230
|
-
ref: (e: Element | null) => {
|
|
231
|
-
refEl = e
|
|
232
|
-
},
|
|
233
|
-
}),
|
|
234
|
-
el,
|
|
235
|
-
)
|
|
236
|
-
expect(refEl).toBeInstanceOf(HTMLDivElement)
|
|
237
|
-
unmount()
|
|
238
|
-
// Fixed: callback refs are now called with null on cleanup
|
|
239
|
-
// to match React/Solid/Vue behavior and the RefCallback<T> type.
|
|
240
|
-
expect(refEl).toBeNull()
|
|
241
|
-
})
|
|
242
|
-
|
|
243
|
-
test('ref is not emitted as an HTML attribute', () => {
|
|
244
|
-
const el = container()
|
|
245
|
-
const ref = createRef<HTMLDivElement>()
|
|
246
|
-
mount(h('div', { ref }), el)
|
|
247
|
-
expect(el.firstElementChild?.hasAttribute('ref')).toBe(false)
|
|
248
|
-
})
|
|
249
|
-
})
|
|
250
|
-
|
|
251
|
-
describe('mount — unmount', () => {
|
|
252
|
-
test('unmount removes mounted nodes', () => {
|
|
253
|
-
const el = container()
|
|
254
|
-
const unmount = mount(h('div', null, 'bye'), el)
|
|
255
|
-
expect(el.innerHTML).not.toBe('')
|
|
256
|
-
unmount()
|
|
257
|
-
expect(el.innerHTML).toBe('')
|
|
258
|
-
})
|
|
259
|
-
|
|
260
|
-
test('unmount disposes reactive effects', () => {
|
|
261
|
-
const el = container()
|
|
262
|
-
const text = signal('initial')
|
|
263
|
-
const unmount = mount(
|
|
264
|
-
h('p', null, () => text()),
|
|
265
|
-
el,
|
|
266
|
-
)
|
|
267
|
-
unmount()
|
|
268
|
-
text.set('updated')
|
|
269
|
-
// After unmount, node is gone — no error thrown, no stale update
|
|
270
|
-
expect(el.innerHTML).toBe('')
|
|
271
|
-
})
|
|
272
|
-
})
|
|
273
|
-
|
|
274
|
-
// ─── For ──────────────────────────────────────────────────────────────────────
|
|
275
|
-
|
|
276
|
-
describe('mount — For', () => {
|
|
277
|
-
type Item = { id: number; label: string }
|
|
278
|
-
|
|
279
|
-
test('renders initial list', () => {
|
|
280
|
-
const el = container()
|
|
281
|
-
const items = signal<Item[]>([
|
|
282
|
-
{ id: 1, label: 'a' },
|
|
283
|
-
{ id: 2, label: 'b' },
|
|
284
|
-
{ id: 3, label: 'c' },
|
|
285
|
-
])
|
|
286
|
-
mount(
|
|
287
|
-
h(
|
|
288
|
-
'ul',
|
|
289
|
-
null,
|
|
290
|
-
For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
|
|
291
|
-
),
|
|
292
|
-
el,
|
|
293
|
-
)
|
|
294
|
-
expect(el.querySelectorAll('li').length).toBe(3)
|
|
295
|
-
expect(el.querySelectorAll('li')[0]?.textContent).toBe('a')
|
|
296
|
-
expect(el.querySelectorAll('li')[2]?.textContent).toBe('c')
|
|
297
|
-
})
|
|
298
|
-
|
|
299
|
-
test('appends new items', () => {
|
|
300
|
-
const el = container()
|
|
301
|
-
const items = signal<Item[]>([{ id: 1, label: 'a' }])
|
|
302
|
-
mount(
|
|
303
|
-
h(
|
|
304
|
-
'ul',
|
|
305
|
-
null,
|
|
306
|
-
For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
|
|
307
|
-
),
|
|
308
|
-
el,
|
|
309
|
-
)
|
|
310
|
-
expect(el.querySelectorAll('li').length).toBe(1)
|
|
311
|
-
items.set([
|
|
312
|
-
{ id: 1, label: 'a' },
|
|
313
|
-
{ id: 2, label: 'b' },
|
|
314
|
-
])
|
|
315
|
-
expect(el.querySelectorAll('li').length).toBe(2)
|
|
316
|
-
expect(el.querySelectorAll('li')[1]?.textContent).toBe('b')
|
|
317
|
-
})
|
|
318
|
-
|
|
319
|
-
test('removes items', () => {
|
|
320
|
-
const el = container()
|
|
321
|
-
const items = signal<Item[]>([
|
|
322
|
-
{ id: 1, label: 'a' },
|
|
323
|
-
{ id: 2, label: 'b' },
|
|
324
|
-
])
|
|
325
|
-
mount(
|
|
326
|
-
h(
|
|
327
|
-
'ul',
|
|
328
|
-
null,
|
|
329
|
-
For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
|
|
330
|
-
),
|
|
331
|
-
el,
|
|
332
|
-
)
|
|
333
|
-
items.set([{ id: 1, label: 'a' }])
|
|
334
|
-
expect(el.querySelectorAll('li').length).toBe(1)
|
|
335
|
-
expect(el.querySelectorAll('li')[0]?.textContent).toBe('a')
|
|
336
|
-
})
|
|
337
|
-
|
|
338
|
-
test('swaps two items (small-k fast path)', () => {
|
|
339
|
-
const el = container()
|
|
340
|
-
const items = signal<Item[]>([
|
|
341
|
-
{ id: 1, label: 'a' },
|
|
342
|
-
{ id: 2, label: 'b' },
|
|
343
|
-
{ id: 3, label: 'c' },
|
|
344
|
-
])
|
|
345
|
-
mount(
|
|
346
|
-
h(
|
|
347
|
-
'ul',
|
|
348
|
-
null,
|
|
349
|
-
For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
|
|
350
|
-
),
|
|
351
|
-
el,
|
|
352
|
-
)
|
|
353
|
-
items.set([
|
|
354
|
-
{ id: 1, label: 'a' },
|
|
355
|
-
{ id: 3, label: 'c' },
|
|
356
|
-
{ id: 2, label: 'b' },
|
|
357
|
-
])
|
|
358
|
-
const lis = el.querySelectorAll('li')
|
|
359
|
-
expect(lis[0]?.textContent).toBe('a')
|
|
360
|
-
expect(lis[1]?.textContent).toBe('c')
|
|
361
|
-
expect(lis[2]?.textContent).toBe('b')
|
|
362
|
-
})
|
|
363
|
-
|
|
364
|
-
test('replaces all items', () => {
|
|
365
|
-
const el = container()
|
|
366
|
-
const items = signal<Item[]>([{ id: 1, label: 'old' }])
|
|
367
|
-
mount(
|
|
368
|
-
h(
|
|
369
|
-
'ul',
|
|
370
|
-
null,
|
|
371
|
-
For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
|
|
372
|
-
),
|
|
373
|
-
el,
|
|
374
|
-
)
|
|
375
|
-
items.set([{ id: 99, label: 'new' }])
|
|
376
|
-
const lis = el.querySelectorAll('li')
|
|
377
|
-
expect(lis.length).toBe(1)
|
|
378
|
-
expect(lis[0]?.textContent).toBe('new')
|
|
379
|
-
})
|
|
380
|
-
|
|
381
|
-
test('clears list', () => {
|
|
382
|
-
const el = container()
|
|
383
|
-
const items = signal<Item[]>([{ id: 1, label: 'x' }])
|
|
384
|
-
mount(
|
|
385
|
-
h(
|
|
386
|
-
'ul',
|
|
387
|
-
null,
|
|
388
|
-
For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
|
|
389
|
-
),
|
|
390
|
-
el,
|
|
391
|
-
)
|
|
392
|
-
items.set([])
|
|
393
|
-
expect(el.querySelectorAll('li').length).toBe(0)
|
|
394
|
-
})
|
|
395
|
-
|
|
396
|
-
test('unmount cleans up', () => {
|
|
397
|
-
const el = container()
|
|
398
|
-
const items = signal<Item[]>([{ id: 1, label: 'x' }])
|
|
399
|
-
const unmount = mount(
|
|
400
|
-
h(
|
|
401
|
-
'ul',
|
|
402
|
-
null,
|
|
403
|
-
For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
|
|
404
|
-
),
|
|
405
|
-
el,
|
|
406
|
-
)
|
|
407
|
-
unmount()
|
|
408
|
-
expect(el.innerHTML).toBe('')
|
|
409
|
-
})
|
|
410
|
-
})
|
|
411
|
-
|
|
412
|
-
// ─── For + NativeItem (createTemplate path — what the benchmark uses) ─────
|
|
413
|
-
|
|
414
|
-
describe('mount — For + NativeItem (createTemplate)', () => {
|
|
415
|
-
type RR = { id: number; label: ReturnType<typeof cell<string>> }
|
|
416
|
-
|
|
417
|
-
function makeRR(id: number, text: string): RR {
|
|
418
|
-
return { id, label: cell(text) }
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
const rowFactory = createTemplate<RR>('<tr><td>\x00</td><td>\x00</td></tr>', (tr, row) => {
|
|
422
|
-
const td1 = tr.firstChild as HTMLElement
|
|
423
|
-
const td2 = td1.nextSibling as HTMLElement
|
|
424
|
-
const t1 = td1.firstChild as Text
|
|
425
|
-
const t2 = td2.firstChild as Text
|
|
426
|
-
t1.data = String(row.id)
|
|
427
|
-
t2.data = row.label.peek()
|
|
428
|
-
row.label.listen(() => {
|
|
429
|
-
t2.data = row.label.peek()
|
|
430
|
-
})
|
|
431
|
-
return null
|
|
432
|
-
})
|
|
433
|
-
|
|
434
|
-
test('renders initial list with correct text', () => {
|
|
435
|
-
const el = container()
|
|
436
|
-
const items = signal<RR[]>([makeRR(1, 'a'), makeRR(2, 'b'), makeRR(3, 'c')])
|
|
437
|
-
mount(
|
|
438
|
-
h(
|
|
439
|
-
'table',
|
|
440
|
-
null,
|
|
441
|
-
h('tbody', null, For({ each: items, by: (r) => r.id, children: rowFactory })),
|
|
442
|
-
),
|
|
443
|
-
el,
|
|
444
|
-
)
|
|
445
|
-
const trs = el.querySelectorAll('tr')
|
|
446
|
-
expect(trs.length).toBe(3)
|
|
447
|
-
expect(trs[0]?.querySelectorAll('td')[1]?.textContent).toBe('a')
|
|
448
|
-
expect(trs[2]?.querySelectorAll('td')[1]?.textContent).toBe('c')
|
|
449
|
-
})
|
|
450
|
-
|
|
451
|
-
test('cell.set() updates text in-place (partial update)', () => {
|
|
452
|
-
const el = container()
|
|
453
|
-
const rows = [makeRR(1, 'hello'), makeRR(2, 'world')]
|
|
454
|
-
const items = signal<RR[]>(rows)
|
|
455
|
-
mount(
|
|
456
|
-
h(
|
|
457
|
-
'table',
|
|
458
|
-
null,
|
|
459
|
-
h('tbody', null, For({ each: items, by: (r) => r.id, children: rowFactory })),
|
|
460
|
-
),
|
|
461
|
-
el,
|
|
462
|
-
)
|
|
463
|
-
// Update label via cell — should change DOM without re-rendering list
|
|
464
|
-
const first = rows[0]
|
|
465
|
-
if (!first) throw new Error('missing row')
|
|
466
|
-
first.label.set('changed')
|
|
467
|
-
expect(el.querySelectorAll('tr')[0]?.querySelectorAll('td')[1]?.textContent).toBe('changed')
|
|
468
|
-
// Second row untouched
|
|
469
|
-
expect(el.querySelectorAll('tr')[1]?.querySelectorAll('td')[1]?.textContent).toBe('world')
|
|
470
|
-
})
|
|
471
|
-
|
|
472
|
-
test('replace all rows', () => {
|
|
473
|
-
const el = container()
|
|
474
|
-
const items = signal<RR[]>([makeRR(1, 'old')])
|
|
475
|
-
mount(
|
|
476
|
-
h(
|
|
477
|
-
'table',
|
|
478
|
-
null,
|
|
479
|
-
h('tbody', null, For({ each: items, by: (r) => r.id, children: rowFactory })),
|
|
480
|
-
),
|
|
481
|
-
el,
|
|
482
|
-
)
|
|
483
|
-
items.set([makeRR(10, 'new1'), makeRR(11, 'new2')])
|
|
484
|
-
const trs = el.querySelectorAll('tr')
|
|
485
|
-
expect(trs.length).toBe(2)
|
|
486
|
-
expect(trs[0]?.querySelectorAll('td')[0]?.textContent).toBe('10')
|
|
487
|
-
expect(trs[1]?.querySelectorAll('td')[1]?.textContent).toBe('new2')
|
|
488
|
-
})
|
|
489
|
-
|
|
490
|
-
test('swap rows preserves DOM identity', () => {
|
|
491
|
-
const el = container()
|
|
492
|
-
const r1 = makeRR(1, 'a')
|
|
493
|
-
const r2 = makeRR(2, 'b')
|
|
494
|
-
const r3 = makeRR(3, 'c')
|
|
495
|
-
const items = signal<RR[]>([r1, r2, r3])
|
|
496
|
-
mount(
|
|
497
|
-
h(
|
|
498
|
-
'table',
|
|
499
|
-
null,
|
|
500
|
-
h('tbody', null, For({ each: items, by: (r) => r.id, children: rowFactory })),
|
|
501
|
-
),
|
|
502
|
-
el,
|
|
503
|
-
)
|
|
504
|
-
const origTr2 = el.querySelectorAll('tr')[1]
|
|
505
|
-
const origTr3 = el.querySelectorAll('tr')[2]
|
|
506
|
-
// Swap positions 1 and 2
|
|
507
|
-
items.set([r1, r3, r2])
|
|
508
|
-
const trs = el.querySelectorAll('tr')
|
|
509
|
-
expect(trs[1]?.querySelectorAll('td')[1]?.textContent).toBe('c')
|
|
510
|
-
expect(trs[2]?.querySelectorAll('td')[1]?.textContent).toBe('b')
|
|
511
|
-
// Same DOM nodes reused, just moved
|
|
512
|
-
expect(trs[1]).toBe(origTr3)
|
|
513
|
-
expect(trs[2]).toBe(origTr2)
|
|
514
|
-
})
|
|
515
|
-
|
|
516
|
-
test('clear removes all rows', () => {
|
|
517
|
-
const el = container()
|
|
518
|
-
const items = signal<RR[]>([makeRR(1, 'x'), makeRR(2, 'y')])
|
|
519
|
-
mount(
|
|
520
|
-
h(
|
|
521
|
-
'table',
|
|
522
|
-
null,
|
|
523
|
-
h('tbody', null, For({ each: items, by: (r) => r.id, children: rowFactory })),
|
|
524
|
-
),
|
|
525
|
-
el,
|
|
526
|
-
)
|
|
527
|
-
items.set([])
|
|
528
|
-
expect(el.querySelectorAll('tr').length).toBe(0)
|
|
529
|
-
})
|
|
530
|
-
|
|
531
|
-
test('clear then re-create works', () => {
|
|
532
|
-
const el = container()
|
|
533
|
-
const items = signal<RR[]>([makeRR(1, 'first')])
|
|
534
|
-
mount(
|
|
535
|
-
h(
|
|
536
|
-
'table',
|
|
537
|
-
null,
|
|
538
|
-
h('tbody', null, For({ each: items, by: (r) => r.id, children: rowFactory })),
|
|
539
|
-
),
|
|
540
|
-
el,
|
|
541
|
-
)
|
|
542
|
-
items.set([])
|
|
543
|
-
expect(el.querySelectorAll('tr').length).toBe(0)
|
|
544
|
-
items.set([makeRR(5, 'back')])
|
|
545
|
-
expect(el.querySelectorAll('tr').length).toBe(1)
|
|
546
|
-
expect(el.querySelectorAll('td')[1]?.textContent).toBe('back')
|
|
547
|
-
})
|
|
548
|
-
|
|
549
|
-
test('append items to existing list', () => {
|
|
550
|
-
const el = container()
|
|
551
|
-
const r1 = makeRR(1, 'a')
|
|
552
|
-
const items = signal<RR[]>([r1])
|
|
553
|
-
mount(
|
|
554
|
-
h(
|
|
555
|
-
'table',
|
|
556
|
-
null,
|
|
557
|
-
h('tbody', null, For({ each: items, by: (r) => r.id, children: rowFactory })),
|
|
558
|
-
),
|
|
559
|
-
el,
|
|
560
|
-
)
|
|
561
|
-
items.set([r1, makeRR(2, 'b'), makeRR(3, 'c')])
|
|
562
|
-
expect(el.querySelectorAll('tr').length).toBe(3)
|
|
563
|
-
expect(el.querySelectorAll('tr')[2]?.querySelectorAll('td')[1]?.textContent).toBe('c')
|
|
564
|
-
})
|
|
565
|
-
|
|
566
|
-
test('remove items from middle', () => {
|
|
567
|
-
const el = container()
|
|
568
|
-
const r1 = makeRR(1, 'a')
|
|
569
|
-
const r2 = makeRR(2, 'b')
|
|
570
|
-
const r3 = makeRR(3, 'c')
|
|
571
|
-
const items = signal<RR[]>([r1, r2, r3])
|
|
572
|
-
mount(
|
|
573
|
-
h(
|
|
574
|
-
'table',
|
|
575
|
-
null,
|
|
576
|
-
h('tbody', null, For({ each: items, by: (r) => r.id, children: rowFactory })),
|
|
577
|
-
),
|
|
578
|
-
el,
|
|
579
|
-
)
|
|
580
|
-
items.set([r1, r3])
|
|
581
|
-
const trs = el.querySelectorAll('tr')
|
|
582
|
-
expect(trs.length).toBe(2)
|
|
583
|
-
expect(trs[0]?.querySelectorAll('td')[1]?.textContent).toBe('a')
|
|
584
|
-
expect(trs[1]?.querySelectorAll('td')[1]?.textContent).toBe('c')
|
|
585
|
-
})
|
|
586
|
-
})
|
|
587
|
-
|
|
588
|
-
// ─── Portal ───────────────────────────────────────────────────────────────────
|
|
589
|
-
|
|
590
|
-
describe('mount — Portal', () => {
|
|
591
|
-
test('renders into target instead of parent', () => {
|
|
592
|
-
const src = container()
|
|
593
|
-
const target = container()
|
|
594
|
-
mount(Portal({ target, children: h('span', null, 'portaled') }), src)
|
|
595
|
-
// content appears in target, not in src
|
|
596
|
-
expect(target.querySelector('span')?.textContent).toBe('portaled')
|
|
597
|
-
expect(src.querySelector('span')).toBeNull()
|
|
598
|
-
})
|
|
599
|
-
|
|
600
|
-
test('unmount removes content from target', () => {
|
|
601
|
-
const src = container()
|
|
602
|
-
const target = container()
|
|
603
|
-
const unmount = mount(Portal({ target, children: h('p', null, 'bye') }), src)
|
|
604
|
-
expect(target.querySelector('p')).not.toBeNull()
|
|
605
|
-
unmount()
|
|
606
|
-
expect(target.querySelector('p')).toBeNull()
|
|
607
|
-
})
|
|
608
|
-
|
|
609
|
-
test('portal content updates reactively', () => {
|
|
610
|
-
const src = container()
|
|
611
|
-
const target = container()
|
|
612
|
-
const text = signal('hello')
|
|
613
|
-
mount(Portal({ target, children: h('span', null, () => text()) }), src)
|
|
614
|
-
expect(target.querySelector('span')?.textContent).toBe('hello')
|
|
615
|
-
text.set('world')
|
|
616
|
-
expect(target.querySelector('span')?.textContent).toBe('world')
|
|
617
|
-
})
|
|
618
|
-
|
|
619
|
-
test('portal inside component renders into target', () => {
|
|
620
|
-
const src = container()
|
|
621
|
-
const target = container()
|
|
622
|
-
const Modal = () => Portal({ target, children: h('dialog', null, 'modal content') })
|
|
623
|
-
mount(h(Modal, null), src)
|
|
624
|
-
expect(target.querySelector('dialog')?.textContent).toBe('modal content')
|
|
625
|
-
expect(src.querySelector('dialog')).toBeNull()
|
|
626
|
-
})
|
|
627
|
-
|
|
628
|
-
test('portal re-mount after unmount works correctly', () => {
|
|
629
|
-
const src = container()
|
|
630
|
-
const target = container()
|
|
631
|
-
const unmount1 = mount(Portal({ target, children: h('span', null, 'first') }), src)
|
|
632
|
-
expect(target.querySelector('span')?.textContent).toBe('first')
|
|
633
|
-
unmount1()
|
|
634
|
-
expect(target.querySelector('span')).toBeNull()
|
|
635
|
-
// Re-mount into same target
|
|
636
|
-
const unmount2 = mount(Portal({ target, children: h('span', null, 'second') }), src)
|
|
637
|
-
expect(target.querySelector('span')?.textContent).toBe('second')
|
|
638
|
-
unmount2()
|
|
639
|
-
expect(target.querySelector('span')).toBeNull()
|
|
640
|
-
})
|
|
641
|
-
|
|
642
|
-
test('multiple portals into same target', () => {
|
|
643
|
-
const src = container()
|
|
644
|
-
const target = container()
|
|
645
|
-
const unmount1 = mount(Portal({ target, children: h('span', { class: 'a' }, 'A') }), src)
|
|
646
|
-
const unmount2 = mount(Portal({ target, children: h('span', { class: 'b' }, 'B') }), src)
|
|
647
|
-
expect(target.querySelectorAll('span').length).toBe(2)
|
|
648
|
-
expect(target.querySelector('.a')?.textContent).toBe('A')
|
|
649
|
-
expect(target.querySelector('.b')?.textContent).toBe('B')
|
|
650
|
-
unmount1()
|
|
651
|
-
expect(target.querySelectorAll('span').length).toBe(1)
|
|
652
|
-
expect(target.querySelector('.b')?.textContent).toBe('B')
|
|
653
|
-
unmount2()
|
|
654
|
-
expect(target.querySelectorAll('span').length).toBe(0)
|
|
655
|
-
})
|
|
656
|
-
|
|
657
|
-
test('portal with reactive Show toggle', () => {
|
|
658
|
-
const src = container()
|
|
659
|
-
const target = container()
|
|
660
|
-
const visible = signal(true)
|
|
661
|
-
mount(
|
|
662
|
-
h('div', null, () =>
|
|
663
|
-
visible() ? Portal({ target, children: h('span', null, 'vis') }) : null,
|
|
664
|
-
),
|
|
665
|
-
src,
|
|
666
|
-
)
|
|
667
|
-
expect(target.querySelector('span')?.textContent).toBe('vis')
|
|
668
|
-
visible.set(false)
|
|
669
|
-
expect(target.querySelector('span')).toBeNull()
|
|
670
|
-
visible.set(true)
|
|
671
|
-
expect(target.querySelector('span')?.textContent).toBe('vis')
|
|
672
|
-
})
|
|
673
|
-
})
|
|
674
|
-
|
|
675
|
-
// ─── ErrorBoundary ────────────────────────────────────────────────────────────
|
|
676
|
-
|
|
677
|
-
describe('ErrorBoundary', () => {
|
|
678
|
-
test('renders fallback when child throws', () => {
|
|
679
|
-
const el = container()
|
|
680
|
-
function Broken(): never {
|
|
681
|
-
throw new Error('boom')
|
|
682
|
-
}
|
|
683
|
-
mount(
|
|
684
|
-
h(ErrorBoundary, {
|
|
685
|
-
fallback: (err: unknown) => h('p', { id: 'fb' }, String(err)),
|
|
686
|
-
children: h(Broken, null),
|
|
687
|
-
}),
|
|
688
|
-
el,
|
|
689
|
-
)
|
|
690
|
-
expect(el.querySelector('#fb')?.textContent).toContain('boom')
|
|
691
|
-
})
|
|
692
|
-
|
|
693
|
-
test('renders children when no error', () => {
|
|
694
|
-
const el = container()
|
|
695
|
-
function Fine() {
|
|
696
|
-
return h('p', { id: 'ok' }, 'works')
|
|
697
|
-
}
|
|
698
|
-
mount(
|
|
699
|
-
h(ErrorBoundary, {
|
|
700
|
-
fallback: () => h('p', null, 'error'),
|
|
701
|
-
children: h(Fine, null),
|
|
702
|
-
}),
|
|
703
|
-
el,
|
|
704
|
-
)
|
|
705
|
-
expect(el.querySelector('#ok')?.textContent).toBe('works')
|
|
706
|
-
})
|
|
707
|
-
|
|
708
|
-
test('reset() clears error and re-renders children', () => {
|
|
709
|
-
const el = container()
|
|
710
|
-
let shouldThrow = true
|
|
711
|
-
|
|
712
|
-
function MaybeThrow() {
|
|
713
|
-
if (shouldThrow) throw new Error('recoverable')
|
|
714
|
-
return h('p', { id: 'recovered' }, 'back')
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
mount(
|
|
718
|
-
h(ErrorBoundary, {
|
|
719
|
-
fallback: (_err: unknown, reset: () => void) =>
|
|
720
|
-
h(
|
|
721
|
-
'button',
|
|
722
|
-
{
|
|
723
|
-
id: 'retry',
|
|
724
|
-
onClick: () => {
|
|
725
|
-
shouldThrow = false
|
|
726
|
-
reset()
|
|
727
|
-
},
|
|
728
|
-
},
|
|
729
|
-
'retry',
|
|
730
|
-
),
|
|
731
|
-
children: h(MaybeThrow, null),
|
|
732
|
-
}),
|
|
733
|
-
el,
|
|
734
|
-
)
|
|
735
|
-
|
|
736
|
-
// Fallback rendered
|
|
737
|
-
expect(el.querySelector('#retry')).not.toBeNull()
|
|
738
|
-
expect(el.querySelector('#recovered')).toBeNull()
|
|
739
|
-
|
|
740
|
-
// Click retry — reset() fires, shouldThrow is false, children re-render
|
|
741
|
-
;(el.querySelector('#retry') as HTMLButtonElement).click()
|
|
742
|
-
|
|
743
|
-
expect(el.querySelector('#recovered')?.textContent).toBe('back')
|
|
744
|
-
expect(el.querySelector('#retry')).toBeNull()
|
|
745
|
-
})
|
|
746
|
-
|
|
747
|
-
test('reset() with signal-driven children', () => {
|
|
748
|
-
const el = container()
|
|
749
|
-
const broken = signal(true)
|
|
750
|
-
|
|
751
|
-
function Reactive() {
|
|
752
|
-
if (broken()) throw new Error('signal error')
|
|
753
|
-
return h('p', { id: 'signal-ok' }, 'fixed')
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
mount(
|
|
757
|
-
h(ErrorBoundary, {
|
|
758
|
-
fallback: (_err: unknown, reset: () => void) =>
|
|
759
|
-
h(
|
|
760
|
-
'button',
|
|
761
|
-
{
|
|
762
|
-
id: 'fix',
|
|
763
|
-
onClick: () => {
|
|
764
|
-
broken.set(false)
|
|
765
|
-
reset()
|
|
766
|
-
},
|
|
767
|
-
},
|
|
768
|
-
'fix',
|
|
769
|
-
),
|
|
770
|
-
children: h(Reactive, null),
|
|
771
|
-
}),
|
|
772
|
-
el,
|
|
773
|
-
)
|
|
774
|
-
|
|
775
|
-
expect(el.querySelector('#fix')).not.toBeNull()
|
|
776
|
-
;(el.querySelector('#fix') as HTMLButtonElement).click()
|
|
777
|
-
expect(el.querySelector('#signal-ok')?.textContent).toBe('fixed')
|
|
778
|
-
})
|
|
779
|
-
|
|
780
|
-
// ── lazy() + Suspense + ErrorBoundary integration ──
|
|
781
|
-
//
|
|
782
|
-
// The `lazy(loader)` wrapper throws synchronously when its loader's
|
|
783
|
-
// promise rejects (`error()` returns truthy → `throw err`).
|
|
784
|
-
//
|
|
785
|
-
// Pyreon components run ONCE — reactivity comes from reading signals
|
|
786
|
-
// inside reactive scopes. `lazy()`'s wrapper reads its `error` /
|
|
787
|
-
// `loaded` signals inline, so the surrounding context must be a
|
|
788
|
-
// reactive scope for signal changes to trigger re-render.
|
|
789
|
-
//
|
|
790
|
-
// `Suspense` wraps its children in `h(Fragment, null, () => ...)` —
|
|
791
|
-
// an explicit reactive accessor that calls `__loading()`. THAT
|
|
792
|
-
// accessor's reactive scope is what tracks lazy's signals: when the
|
|
793
|
-
// loader rejects, the accessor re-runs, the lazy child re-mounts,
|
|
794
|
-
// the wrapper throws, mountComponent catches, dispatches to the
|
|
795
|
-
// nearest `<ErrorBoundary>` on the boundary stack.
|
|
796
|
-
//
|
|
797
|
-
// Without Suspense, lazy()'s post-mount errors don't surface (no
|
|
798
|
-
// reactive scope to drive re-render). This is consistent with the
|
|
799
|
-
// framework's component-runs-once contract — but worth pinning
|
|
800
|
-
// down with explicit tests.
|
|
801
|
-
|
|
802
|
-
test('lazy() loader rejection surfaces to ErrorBoundary via Suspense', async () => {
|
|
803
|
-
const el = container()
|
|
804
|
-
const Comp = lazy<Record<string, never>>(() =>
|
|
805
|
-
Promise.reject(new Error('module load failed')),
|
|
806
|
-
)
|
|
807
|
-
|
|
808
|
-
mount(
|
|
809
|
-
h(ErrorBoundary, {
|
|
810
|
-
fallback: (err: unknown) =>
|
|
811
|
-
h('p', { id: 'lazy-fb' }, `Caught: ${(err as Error).message}`),
|
|
812
|
-
children: h(
|
|
813
|
-
Suspense,
|
|
814
|
-
{ fallback: h('p', { id: 'spinner' }, 'loading...') },
|
|
815
|
-
h(Comp, {}),
|
|
816
|
-
),
|
|
817
|
-
}),
|
|
818
|
-
el,
|
|
819
|
-
)
|
|
820
|
-
|
|
821
|
-
// Initial render: lazy is still loading → Suspense shows spinner,
|
|
822
|
-
// boundary fallback NOT triggered yet.
|
|
823
|
-
expect(el.querySelector('#spinner')).not.toBeNull()
|
|
824
|
-
expect(el.querySelector('#lazy-fb')).toBeNull()
|
|
825
|
-
|
|
826
|
-
// Wait for promise rejection to flush.
|
|
827
|
-
await new Promise((r) => setTimeout(r, 0))
|
|
828
|
-
// Reactive flush.
|
|
829
|
-
await Promise.resolve()
|
|
830
|
-
|
|
831
|
-
// After load fails: Suspense's reactive accessor re-runs → child
|
|
832
|
-
// wrapper throws → caught by mountComponent → dispatched to
|
|
833
|
-
// ErrorBoundary → fallback rendered.
|
|
834
|
-
expect(el.querySelector('#lazy-fb')?.textContent).toContain('module load failed')
|
|
835
|
-
expect(el.querySelector('#spinner')).toBeNull()
|
|
836
|
-
})
|
|
837
|
-
|
|
838
|
-
test('lazy() resolves successfully renders content without firing fallback', async () => {
|
|
839
|
-
const el = container()
|
|
840
|
-
const Inner: ComponentFn<Record<string, never>> = () =>
|
|
841
|
-
h('p', { id: 'loaded' }, 'content')
|
|
842
|
-
const Comp = lazy<Record<string, never>>(() => Promise.resolve({ default: Inner }))
|
|
843
|
-
|
|
844
|
-
let fallbackInvocations = 0
|
|
845
|
-
mount(
|
|
846
|
-
h(ErrorBoundary, {
|
|
847
|
-
fallback: () => {
|
|
848
|
-
fallbackInvocations++
|
|
849
|
-
return h('p', { id: 'should-not-appear' }, 'error')
|
|
850
|
-
},
|
|
851
|
-
children: h(
|
|
852
|
-
Suspense,
|
|
853
|
-
{ fallback: h('p', { id: 'spinner' }, 'loading...') },
|
|
854
|
-
h(Comp, {}),
|
|
855
|
-
),
|
|
856
|
-
}),
|
|
857
|
-
el,
|
|
858
|
-
)
|
|
859
|
-
|
|
860
|
-
await new Promise((r) => setTimeout(r, 0))
|
|
861
|
-
await Promise.resolve()
|
|
862
|
-
|
|
863
|
-
expect(el.querySelector('#loaded')?.textContent).toBe('content')
|
|
864
|
-
expect(el.querySelector('#should-not-appear')).toBeNull()
|
|
865
|
-
expect(fallbackInvocations).toBe(0)
|
|
866
|
-
})
|
|
867
|
-
})
|
|
868
|
-
|
|
869
|
-
// ─── Transition component ─────────────────────────────────────────────────────
|
|
870
|
-
|
|
871
|
-
describe('Transition', () => {
|
|
872
|
-
test('mounts child when show starts true', () => {
|
|
873
|
-
const el = container()
|
|
874
|
-
const visible = signal(true)
|
|
875
|
-
mount(h(Transition, { show: visible, children: h('div', { id: 'target' }, 'hi') }), el)
|
|
876
|
-
expect(el.querySelector('#target')).not.toBeNull()
|
|
877
|
-
})
|
|
878
|
-
|
|
879
|
-
test('does not mount child when show starts false', () => {
|
|
880
|
-
const el = container()
|
|
881
|
-
const visible = signal(false)
|
|
882
|
-
mount(h(Transition, { show: visible, children: h('div', { id: 'target' }, 'hi') }), el)
|
|
883
|
-
expect(el.querySelector('#target')).toBeNull()
|
|
884
|
-
})
|
|
885
|
-
|
|
886
|
-
test('mounts child reactively when show becomes true', () => {
|
|
887
|
-
const el = container()
|
|
888
|
-
const visible = signal(false)
|
|
889
|
-
mount(h(Transition, { show: visible, children: h('div', { id: 'target' }, 'hi') }), el)
|
|
890
|
-
expect(el.querySelector('#target')).toBeNull()
|
|
891
|
-
visible.set(true)
|
|
892
|
-
expect(el.querySelector('#target')).not.toBeNull()
|
|
893
|
-
})
|
|
894
|
-
|
|
895
|
-
test('calls onBeforeEnter when entering', async () => {
|
|
896
|
-
const el = container()
|
|
897
|
-
const visible = signal(false)
|
|
898
|
-
let called = false
|
|
899
|
-
mount(
|
|
900
|
-
h(Transition, {
|
|
901
|
-
show: visible,
|
|
902
|
-
onBeforeEnter: () => {
|
|
903
|
-
called = true
|
|
904
|
-
},
|
|
905
|
-
children: h('div', { id: 't' }),
|
|
906
|
-
}),
|
|
907
|
-
el,
|
|
908
|
-
)
|
|
909
|
-
visible.set(true)
|
|
910
|
-
// onBeforeEnter fires inside queueMicrotask — wait one microtask tick
|
|
911
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
912
|
-
expect(called).toBe(true)
|
|
913
|
-
})
|
|
914
|
-
})
|
|
915
|
-
|
|
916
|
-
// ─── Show component ───────────────────────────────────────────────────────────
|
|
917
|
-
|
|
918
|
-
describe('Show', () => {
|
|
919
|
-
test('renders children when when() is truthy', () => {
|
|
920
|
-
const el = container()
|
|
921
|
-
mount(h(Show, { when: () => true }, h('span', { id: 's' }, 'yes')), el)
|
|
922
|
-
expect(el.querySelector('#s')).not.toBeNull()
|
|
923
|
-
})
|
|
924
|
-
|
|
925
|
-
test('renders fallback when when() is falsy', () => {
|
|
926
|
-
const el = container()
|
|
927
|
-
mount(
|
|
928
|
-
h(
|
|
929
|
-
Show,
|
|
930
|
-
{ when: () => false, fallback: h('span', { id: 'fb' }, 'no') },
|
|
931
|
-
h('span', { id: 's' }, 'yes'),
|
|
932
|
-
),
|
|
933
|
-
el,
|
|
934
|
-
)
|
|
935
|
-
expect(el.querySelector('#s')).toBeNull()
|
|
936
|
-
expect(el.querySelector('#fb')).not.toBeNull()
|
|
937
|
-
})
|
|
938
|
-
|
|
939
|
-
test('reactively toggles on signal change', () => {
|
|
940
|
-
const el = container()
|
|
941
|
-
const show = signal(false)
|
|
942
|
-
mount(h(Show, { when: show }, h('div', { id: 't' }, 'visible')), el)
|
|
943
|
-
expect(el.querySelector('#t')).toBeNull()
|
|
944
|
-
show.set(true)
|
|
945
|
-
expect(el.querySelector('#t')).not.toBeNull()
|
|
946
|
-
show.set(false)
|
|
947
|
-
expect(el.querySelector('#t')).toBeNull()
|
|
948
|
-
})
|
|
949
|
-
|
|
950
|
-
test('renders nothing when falsy and no fallback', () => {
|
|
951
|
-
const el = container()
|
|
952
|
-
mount(h(Show, { when: () => false }, h('div', null, 'hi')), el)
|
|
953
|
-
expect(el.textContent).toBe('')
|
|
954
|
-
})
|
|
955
|
-
})
|
|
956
|
-
|
|
957
|
-
// ─── Switch / Match components ────────────────────────────────────────────────
|
|
958
|
-
|
|
959
|
-
describe('Switch / Match', () => {
|
|
960
|
-
test('renders first matching branch', () => {
|
|
961
|
-
const el = container()
|
|
962
|
-
const route = signal('home')
|
|
963
|
-
mount(
|
|
964
|
-
h(
|
|
965
|
-
Switch,
|
|
966
|
-
{ fallback: h('span', { id: 'notfound' }) },
|
|
967
|
-
h(Match, { when: () => route() === 'home' }, h('span', { id: 'home' })),
|
|
968
|
-
h(Match, { when: () => route() === 'about' }, h('span', { id: 'about' })),
|
|
969
|
-
),
|
|
970
|
-
el,
|
|
971
|
-
)
|
|
972
|
-
expect(el.querySelector('#home')).not.toBeNull()
|
|
973
|
-
expect(el.querySelector('#about')).toBeNull()
|
|
974
|
-
expect(el.querySelector('#notfound')).toBeNull()
|
|
975
|
-
})
|
|
976
|
-
|
|
977
|
-
test('renders fallback when no match', () => {
|
|
978
|
-
const el = container()
|
|
979
|
-
const route = signal('other')
|
|
980
|
-
mount(
|
|
981
|
-
h(
|
|
982
|
-
Switch,
|
|
983
|
-
{ fallback: h('span', { id: 'notfound' }) },
|
|
984
|
-
h(Match, { when: () => route() === 'home' }, h('span', { id: 'home' })),
|
|
985
|
-
),
|
|
986
|
-
el,
|
|
987
|
-
)
|
|
988
|
-
expect(el.querySelector('#notfound')).not.toBeNull()
|
|
989
|
-
expect(el.querySelector('#home')).toBeNull()
|
|
990
|
-
})
|
|
991
|
-
|
|
992
|
-
test('switches branch reactively', () => {
|
|
993
|
-
const el = container()
|
|
994
|
-
const route = signal('home')
|
|
995
|
-
mount(
|
|
996
|
-
h(
|
|
997
|
-
Switch,
|
|
998
|
-
{ fallback: h('span', { id: 'notfound' }) },
|
|
999
|
-
h(Match, { when: () => route() === 'home' }, h('span', { id: 'home' })),
|
|
1000
|
-
h(Match, { when: () => route() === 'about' }, h('span', { id: 'about' })),
|
|
1001
|
-
),
|
|
1002
|
-
el,
|
|
1003
|
-
)
|
|
1004
|
-
expect(el.querySelector('#home')).not.toBeNull()
|
|
1005
|
-
route.set('about')
|
|
1006
|
-
expect(el.querySelector('#home')).toBeNull()
|
|
1007
|
-
expect(el.querySelector('#about')).not.toBeNull()
|
|
1008
|
-
route.set('other')
|
|
1009
|
-
expect(el.querySelector('#notfound')).not.toBeNull()
|
|
1010
|
-
})
|
|
1011
|
-
})
|
|
1012
|
-
|
|
1013
|
-
// ─── Props (extended coverage) ───────────────────────────────────────────────
|
|
1014
|
-
|
|
1015
|
-
describe('mount — props (extended)', () => {
|
|
1016
|
-
test('style as string sets cssText', () => {
|
|
1017
|
-
const el = container()
|
|
1018
|
-
mount(h('div', { style: 'color: red; font-size: 14px' }), el)
|
|
1019
|
-
const div = el.querySelector('div') as HTMLElement
|
|
1020
|
-
expect(div.style.color).toBe('red')
|
|
1021
|
-
expect(div.style.fontSize).toBe('14px')
|
|
1022
|
-
})
|
|
1023
|
-
|
|
1024
|
-
test('style as object sets individual properties', () => {
|
|
1025
|
-
const el = container()
|
|
1026
|
-
mount(h('div', { style: { color: 'blue', marginTop: '10px' } }), el)
|
|
1027
|
-
const div = el.querySelector('div') as HTMLElement
|
|
1028
|
-
expect(div.style.color).toBe('blue')
|
|
1029
|
-
expect(div.style.marginTop).toBe('10px')
|
|
1030
|
-
})
|
|
1031
|
-
|
|
1032
|
-
test('style object auto-appends px to numeric values', () => {
|
|
1033
|
-
const el = container()
|
|
1034
|
-
mount(h('div', { style: { height: 100, marginTop: 20, opacity: 0.5, zIndex: 10 } }), el)
|
|
1035
|
-
const div = el.querySelector('div') as HTMLElement
|
|
1036
|
-
expect(div.style.height).toBe('100px')
|
|
1037
|
-
expect(div.style.marginTop).toBe('20px')
|
|
1038
|
-
expect(div.style.opacity).toBe('0.5')
|
|
1039
|
-
expect(div.style.zIndex).toBe('10')
|
|
1040
|
-
})
|
|
1041
|
-
|
|
1042
|
-
test('style object handles CSS custom properties', () => {
|
|
1043
|
-
const el = container()
|
|
1044
|
-
mount(h('div', { style: { '--my-color': 'red' } }), el)
|
|
1045
|
-
const div = el.querySelector('div') as HTMLElement
|
|
1046
|
-
expect(div.style.getPropertyValue('--my-color')).toBe('red')
|
|
1047
|
-
})
|
|
1048
|
-
|
|
1049
|
-
test('className sets class attribute', () => {
|
|
1050
|
-
const el = container()
|
|
1051
|
-
mount(h('div', { className: 'my-class' }), el)
|
|
1052
|
-
expect(el.querySelector('div')?.getAttribute('class')).toBe('my-class')
|
|
1053
|
-
})
|
|
1054
|
-
|
|
1055
|
-
test('class null sets empty class', () => {
|
|
1056
|
-
const el = container()
|
|
1057
|
-
mount(h('div', { class: null }), el)
|
|
1058
|
-
expect(el.querySelector('div')?.getAttribute('class')).toBe('')
|
|
1059
|
-
})
|
|
1060
|
-
|
|
1061
|
-
test('boolean attribute true sets empty attr', () => {
|
|
1062
|
-
const el = container()
|
|
1063
|
-
mount(h('input', { disabled: true }), el)
|
|
1064
|
-
const input = el.querySelector('input') as HTMLInputElement
|
|
1065
|
-
expect(input.disabled).toBe(true)
|
|
1066
|
-
})
|
|
1067
|
-
|
|
1068
|
-
test('boolean attribute false removes attr', () => {
|
|
1069
|
-
const el = container()
|
|
1070
|
-
mount(h('input', { disabled: false }), el)
|
|
1071
|
-
const input = el.querySelector('input') as HTMLInputElement
|
|
1072
|
-
expect(input.disabled).toBe(false)
|
|
1073
|
-
})
|
|
1074
|
-
|
|
1075
|
-
test('event handler receives event object', () => {
|
|
1076
|
-
const el = container()
|
|
1077
|
-
let receivedEvent: Event | null = null
|
|
1078
|
-
mount(
|
|
1079
|
-
h(
|
|
1080
|
-
'button',
|
|
1081
|
-
{
|
|
1082
|
-
onClick: (e: Event) => {
|
|
1083
|
-
receivedEvent = e
|
|
1084
|
-
},
|
|
1085
|
-
},
|
|
1086
|
-
'click',
|
|
1087
|
-
),
|
|
1088
|
-
el,
|
|
1089
|
-
)
|
|
1090
|
-
el.querySelector('button')?.click()
|
|
1091
|
-
expect(receivedEvent).not.toBeNull()
|
|
1092
|
-
expect(receivedEvent).toBeInstanceOf(Event)
|
|
1093
|
-
})
|
|
1094
|
-
|
|
1095
|
-
test('multiple event handlers on same element', () => {
|
|
1096
|
-
const el = container()
|
|
1097
|
-
let mouseDown = false
|
|
1098
|
-
let mouseUp = false
|
|
1099
|
-
mount(
|
|
1100
|
-
h(
|
|
1101
|
-
'div',
|
|
1102
|
-
{
|
|
1103
|
-
onMousedown: () => {
|
|
1104
|
-
mouseDown = true
|
|
1105
|
-
},
|
|
1106
|
-
onMouseup: () => {
|
|
1107
|
-
mouseUp = true
|
|
1108
|
-
},
|
|
1109
|
-
},
|
|
1110
|
-
'target',
|
|
1111
|
-
),
|
|
1112
|
-
el,
|
|
1113
|
-
)
|
|
1114
|
-
const div = el.querySelector('div') as HTMLElement
|
|
1115
|
-
div.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
1116
|
-
div.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
|
|
1117
|
-
expect(mouseDown).toBe(true)
|
|
1118
|
-
expect(mouseUp).toBe(true)
|
|
1119
|
-
})
|
|
1120
|
-
|
|
1121
|
-
test('event handler cleanup on unmount', () => {
|
|
1122
|
-
const el = container()
|
|
1123
|
-
let count = 0
|
|
1124
|
-
const unmount = mount(
|
|
1125
|
-
h(
|
|
1126
|
-
'button',
|
|
1127
|
-
{
|
|
1128
|
-
onClick: () => {
|
|
1129
|
-
count++
|
|
1130
|
-
},
|
|
1131
|
-
},
|
|
1132
|
-
'click',
|
|
1133
|
-
),
|
|
1134
|
-
el,
|
|
1135
|
-
)
|
|
1136
|
-
el.querySelector('button')?.click()
|
|
1137
|
-
expect(count).toBe(1)
|
|
1138
|
-
unmount()
|
|
1139
|
-
// Button removed from DOM so click won't reach it
|
|
1140
|
-
expect(count).toBe(1)
|
|
1141
|
-
})
|
|
1142
|
-
|
|
1143
|
-
test('sanitizes javascript: in href', () => {
|
|
1144
|
-
const el = container()
|
|
1145
|
-
mount(h('a', { href: 'javascript:alert(1)' }), el)
|
|
1146
|
-
const a = el.querySelector('a') as HTMLAnchorElement
|
|
1147
|
-
// Should not have the dangerous href set
|
|
1148
|
-
expect(a.getAttribute('href')).not.toBe('javascript:alert(1)')
|
|
1149
|
-
})
|
|
1150
|
-
|
|
1151
|
-
test('sanitizes data: in src', () => {
|
|
1152
|
-
const el = container()
|
|
1153
|
-
mount(h('img', { src: 'data:text/html,<script>alert(1)</script>' }), el)
|
|
1154
|
-
const img = el.querySelector('img') as HTMLImageElement
|
|
1155
|
-
expect(img.getAttribute('src')).not.toBe('data:text/html,<script>alert(1)</script>')
|
|
1156
|
-
})
|
|
1157
|
-
|
|
1158
|
-
test('allows safe href values', () => {
|
|
1159
|
-
const el = container()
|
|
1160
|
-
mount(h('a', { href: 'https://example.com' }), el)
|
|
1161
|
-
const a = el.querySelector('a') as HTMLAnchorElement
|
|
1162
|
-
expect(a.href).toContain('https://example.com')
|
|
1163
|
-
})
|
|
1164
|
-
|
|
1165
|
-
test('innerHTML sets content', () => {
|
|
1166
|
-
const el = container()
|
|
1167
|
-
mount(h('div', { innerHTML: '<b>bold</b>' }), el)
|
|
1168
|
-
const div = el.querySelector('div') as HTMLElement
|
|
1169
|
-
expect(div.innerHTML).toBe('<b>bold</b>')
|
|
1170
|
-
})
|
|
1171
|
-
|
|
1172
|
-
test('dangerouslySetInnerHTML sets __html content', () => {
|
|
1173
|
-
const el = container()
|
|
1174
|
-
mount(h('div', { dangerouslySetInnerHTML: { __html: '<em>raw</em>' } }), el)
|
|
1175
|
-
const div = el.querySelector('div') as HTMLElement
|
|
1176
|
-
expect(div.innerHTML).toBe('<em>raw</em>')
|
|
1177
|
-
})
|
|
1178
|
-
|
|
1179
|
-
test('reactive style updates', () => {
|
|
1180
|
-
const el = container()
|
|
1181
|
-
const color = signal('red')
|
|
1182
|
-
mount(h('div', { style: () => `color: ${color()}` }), el)
|
|
1183
|
-
const div = el.querySelector('div') as HTMLElement
|
|
1184
|
-
expect(div.style.color).toBe('red')
|
|
1185
|
-
color.set('blue')
|
|
1186
|
-
expect(div.style.color).toBe('blue')
|
|
1187
|
-
})
|
|
1188
|
-
|
|
1189
|
-
test('DOM property (value) set via prop', () => {
|
|
1190
|
-
const el = container()
|
|
1191
|
-
mount(h('input', { value: 'hello' }), el)
|
|
1192
|
-
const input = el.querySelector('input') as HTMLInputElement
|
|
1193
|
-
expect(input.value).toBe('hello')
|
|
1194
|
-
})
|
|
1195
|
-
|
|
1196
|
-
test('data-* attributes set correctly', () => {
|
|
1197
|
-
const el = container()
|
|
1198
|
-
mount(h('div', { 'data-testid': 'foo', 'data-count': '42' }), el)
|
|
1199
|
-
const div = el.querySelector('div') as HTMLElement
|
|
1200
|
-
expect(div.getAttribute('data-testid')).toBe('foo')
|
|
1201
|
-
expect(div.getAttribute('data-count')).toBe('42')
|
|
1202
|
-
})
|
|
1203
|
-
})
|
|
1204
|
-
|
|
1205
|
-
// ─── Keyed list (nodes.ts) — additional reorder patterns ────────────────────
|
|
1206
|
-
|
|
1207
|
-
describe('mount — For keyed list reorder patterns', () => {
|
|
1208
|
-
type Item = { id: number; label: string }
|
|
1209
|
-
|
|
1210
|
-
function mountList(el: HTMLElement, items: ReturnType<typeof signal<Item[]>>) {
|
|
1211
|
-
mount(
|
|
1212
|
-
h(
|
|
1213
|
-
'ul',
|
|
1214
|
-
null,
|
|
1215
|
-
For({
|
|
1216
|
-
each: items,
|
|
1217
|
-
by: (r) => r.id,
|
|
1218
|
-
children: (r) => h('li', { key: r.id }, r.label),
|
|
1219
|
-
}),
|
|
1220
|
-
),
|
|
1221
|
-
el,
|
|
1222
|
-
)
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
test('reverse order', () => {
|
|
1226
|
-
const el = container()
|
|
1227
|
-
const items = signal<Item[]>([
|
|
1228
|
-
{ id: 1, label: 'a' },
|
|
1229
|
-
{ id: 2, label: 'b' },
|
|
1230
|
-
{ id: 3, label: 'c' },
|
|
1231
|
-
{ id: 4, label: 'd' },
|
|
1232
|
-
{ id: 5, label: 'e' },
|
|
1233
|
-
])
|
|
1234
|
-
mountList(el, items)
|
|
1235
|
-
items.set([
|
|
1236
|
-
{ id: 5, label: 'e' },
|
|
1237
|
-
{ id: 4, label: 'd' },
|
|
1238
|
-
{ id: 3, label: 'c' },
|
|
1239
|
-
{ id: 2, label: 'b' },
|
|
1240
|
-
{ id: 1, label: 'a' },
|
|
1241
|
-
])
|
|
1242
|
-
const lis = el.querySelectorAll('li')
|
|
1243
|
-
expect(lis.length).toBe(5)
|
|
1244
|
-
expect(lis[0]?.textContent).toBe('e')
|
|
1245
|
-
expect(lis[1]?.textContent).toBe('d')
|
|
1246
|
-
expect(lis[2]?.textContent).toBe('c')
|
|
1247
|
-
expect(lis[3]?.textContent).toBe('b')
|
|
1248
|
-
expect(lis[4]?.textContent).toBe('a')
|
|
1249
|
-
})
|
|
1250
|
-
|
|
1251
|
-
test('move single item to front', () => {
|
|
1252
|
-
const el = container()
|
|
1253
|
-
const items = signal<Item[]>([
|
|
1254
|
-
{ id: 1, label: 'a' },
|
|
1255
|
-
{ id: 2, label: 'b' },
|
|
1256
|
-
{ id: 3, label: 'c' },
|
|
1257
|
-
])
|
|
1258
|
-
mountList(el, items)
|
|
1259
|
-
items.set([
|
|
1260
|
-
{ id: 3, label: 'c' },
|
|
1261
|
-
{ id: 1, label: 'a' },
|
|
1262
|
-
{ id: 2, label: 'b' },
|
|
1263
|
-
])
|
|
1264
|
-
const lis = el.querySelectorAll('li')
|
|
1265
|
-
expect(lis[0]?.textContent).toBe('c')
|
|
1266
|
-
expect(lis[1]?.textContent).toBe('a')
|
|
1267
|
-
expect(lis[2]?.textContent).toBe('b')
|
|
1268
|
-
})
|
|
1269
|
-
|
|
1270
|
-
test('prepend items', () => {
|
|
1271
|
-
const el = container()
|
|
1272
|
-
const items = signal<Item[]>([
|
|
1273
|
-
{ id: 3, label: 'c' },
|
|
1274
|
-
{ id: 4, label: 'd' },
|
|
1275
|
-
])
|
|
1276
|
-
mountList(el, items)
|
|
1277
|
-
items.set([
|
|
1278
|
-
{ id: 1, label: 'a' },
|
|
1279
|
-
{ id: 2, label: 'b' },
|
|
1280
|
-
{ id: 3, label: 'c' },
|
|
1281
|
-
{ id: 4, label: 'd' },
|
|
1282
|
-
])
|
|
1283
|
-
const lis = el.querySelectorAll('li')
|
|
1284
|
-
expect(lis.length).toBe(4)
|
|
1285
|
-
expect(lis[0]?.textContent).toBe('a')
|
|
1286
|
-
expect(lis[1]?.textContent).toBe('b')
|
|
1287
|
-
expect(lis[2]?.textContent).toBe('c')
|
|
1288
|
-
expect(lis[3]?.textContent).toBe('d')
|
|
1289
|
-
})
|
|
1290
|
-
|
|
1291
|
-
test('interleave new items', () => {
|
|
1292
|
-
const el = container()
|
|
1293
|
-
const items = signal<Item[]>([
|
|
1294
|
-
{ id: 1, label: 'a' },
|
|
1295
|
-
{ id: 3, label: 'c' },
|
|
1296
|
-
{ id: 5, label: 'e' },
|
|
1297
|
-
])
|
|
1298
|
-
mountList(el, items)
|
|
1299
|
-
items.set([
|
|
1300
|
-
{ id: 1, label: 'a' },
|
|
1301
|
-
{ id: 2, label: 'b' },
|
|
1302
|
-
{ id: 3, label: 'c' },
|
|
1303
|
-
{ id: 4, label: 'd' },
|
|
1304
|
-
{ id: 5, label: 'e' },
|
|
1305
|
-
])
|
|
1306
|
-
const lis = el.querySelectorAll('li')
|
|
1307
|
-
expect(lis.length).toBe(5)
|
|
1308
|
-
expect(lis[0]?.textContent).toBe('a')
|
|
1309
|
-
expect(lis[1]?.textContent).toBe('b')
|
|
1310
|
-
expect(lis[2]?.textContent).toBe('c')
|
|
1311
|
-
expect(lis[3]?.textContent).toBe('d')
|
|
1312
|
-
expect(lis[4]?.textContent).toBe('e')
|
|
1313
|
-
})
|
|
1314
|
-
|
|
1315
|
-
test('large reorder triggers LIS fallback (>8 diffs)', () => {
|
|
1316
|
-
const el = container()
|
|
1317
|
-
const initial = Array.from({ length: 20 }, (_, i) => ({
|
|
1318
|
-
id: i + 1,
|
|
1319
|
-
label: String.fromCharCode(97 + i),
|
|
1320
|
-
}))
|
|
1321
|
-
const items = signal<Item[]>(initial)
|
|
1322
|
-
mountList(el, items)
|
|
1323
|
-
// Shuffle: reverse first 15 items to force >8 diffs
|
|
1324
|
-
const shuffled = [...initial]
|
|
1325
|
-
shuffled.splice(0, 15, ...shuffled.slice(0, 15).reverse())
|
|
1326
|
-
items.set(shuffled)
|
|
1327
|
-
const lis = el.querySelectorAll('li')
|
|
1328
|
-
expect(lis.length).toBe(20)
|
|
1329
|
-
for (let i = 0; i < 20; i++) {
|
|
1330
|
-
expect(lis[i]?.textContent).toBe(shuffled[i]?.label)
|
|
1331
|
-
}
|
|
1332
|
-
})
|
|
1333
|
-
|
|
1334
|
-
test('remove from front and back simultaneously', () => {
|
|
1335
|
-
const el = container()
|
|
1336
|
-
const items = signal<Item[]>([
|
|
1337
|
-
{ id: 1, label: 'a' },
|
|
1338
|
-
{ id: 2, label: 'b' },
|
|
1339
|
-
{ id: 3, label: 'c' },
|
|
1340
|
-
{ id: 4, label: 'd' },
|
|
1341
|
-
{ id: 5, label: 'e' },
|
|
1342
|
-
])
|
|
1343
|
-
mountList(el, items)
|
|
1344
|
-
items.set([
|
|
1345
|
-
{ id: 2, label: 'b' },
|
|
1346
|
-
{ id: 3, label: 'c' },
|
|
1347
|
-
{ id: 4, label: 'd' },
|
|
1348
|
-
])
|
|
1349
|
-
const lis = el.querySelectorAll('li')
|
|
1350
|
-
expect(lis.length).toBe(3)
|
|
1351
|
-
expect(lis[0]?.textContent).toBe('b')
|
|
1352
|
-
expect(lis[2]?.textContent).toBe('d')
|
|
1353
|
-
})
|
|
1354
|
-
|
|
1355
|
-
test('swap first and last', () => {
|
|
1356
|
-
const el = container()
|
|
1357
|
-
const items = signal<Item[]>([
|
|
1358
|
-
{ id: 1, label: 'a' },
|
|
1359
|
-
{ id: 2, label: 'b' },
|
|
1360
|
-
{ id: 3, label: 'c' },
|
|
1361
|
-
])
|
|
1362
|
-
mountList(el, items)
|
|
1363
|
-
items.set([
|
|
1364
|
-
{ id: 3, label: 'c' },
|
|
1365
|
-
{ id: 2, label: 'b' },
|
|
1366
|
-
{ id: 1, label: 'a' },
|
|
1367
|
-
])
|
|
1368
|
-
const lis = el.querySelectorAll('li')
|
|
1369
|
-
expect(lis[0]?.textContent).toBe('c')
|
|
1370
|
-
expect(lis[1]?.textContent).toBe('b')
|
|
1371
|
-
expect(lis[2]?.textContent).toBe('a')
|
|
1372
|
-
})
|
|
1373
|
-
|
|
1374
|
-
test('multiple rapid updates', () => {
|
|
1375
|
-
const el = container()
|
|
1376
|
-
const items = signal<Item[]>([{ id: 1, label: 'a' }])
|
|
1377
|
-
mountList(el, items)
|
|
1378
|
-
items.set([
|
|
1379
|
-
{ id: 1, label: 'a' },
|
|
1380
|
-
{ id: 2, label: 'b' },
|
|
1381
|
-
])
|
|
1382
|
-
items.set([
|
|
1383
|
-
{ id: 2, label: 'b' },
|
|
1384
|
-
{ id: 3, label: 'c' },
|
|
1385
|
-
])
|
|
1386
|
-
items.set([{ id: 4, label: 'd' }])
|
|
1387
|
-
const lis = el.querySelectorAll('li')
|
|
1388
|
-
expect(lis.length).toBe(1)
|
|
1389
|
-
expect(lis[0]?.textContent).toBe('d')
|
|
1390
|
-
})
|
|
1391
|
-
})
|
|
1392
|
-
|
|
1393
|
-
// ─── Transition (extended coverage) ──────────────────────────────────────────
|
|
1394
|
-
|
|
1395
|
-
describe('Transition — extended', () => {
|
|
1396
|
-
test('custom class names', () => {
|
|
1397
|
-
const el = container()
|
|
1398
|
-
const visible = signal(false)
|
|
1399
|
-
mount(
|
|
1400
|
-
h(Transition, {
|
|
1401
|
-
show: visible,
|
|
1402
|
-
enterFrom: 'my-enter-from',
|
|
1403
|
-
enterActive: 'my-enter-active',
|
|
1404
|
-
enterTo: 'my-enter-to',
|
|
1405
|
-
children: h('div', { id: 'custom' }, 'content'),
|
|
1406
|
-
}),
|
|
1407
|
-
el,
|
|
1408
|
-
)
|
|
1409
|
-
expect(el.querySelector('#custom')).toBeNull()
|
|
1410
|
-
visible.set(true)
|
|
1411
|
-
expect(el.querySelector('#custom')).not.toBeNull()
|
|
1412
|
-
})
|
|
1413
|
-
|
|
1414
|
-
test('leave hides element after animation', async () => {
|
|
1415
|
-
const el = container()
|
|
1416
|
-
const visible = signal(true)
|
|
1417
|
-
mount(
|
|
1418
|
-
h(Transition, {
|
|
1419
|
-
show: visible,
|
|
1420
|
-
children: h('div', { id: 'leave-test' }, 'content'),
|
|
1421
|
-
}),
|
|
1422
|
-
el,
|
|
1423
|
-
)
|
|
1424
|
-
expect(el.querySelector('#leave-test')).not.toBeNull()
|
|
1425
|
-
visible.set(false)
|
|
1426
|
-
// After rAF + transitionend, the element should be removed
|
|
1427
|
-
// In happy-dom, we simulate the transitionend
|
|
1428
|
-
const target = el.querySelector('#leave-test')
|
|
1429
|
-
if (target) {
|
|
1430
|
-
// Wait for the requestAnimationFrame callback
|
|
1431
|
-
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
1432
|
-
target.dispatchEvent(new Event('transitionend'))
|
|
1433
|
-
}
|
|
1434
|
-
// isMounted should now be false
|
|
1435
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
1436
|
-
expect(el.querySelector('#leave-test')).toBeNull()
|
|
1437
|
-
})
|
|
1438
|
-
|
|
1439
|
-
test('appear triggers enter animation on initial mount', async () => {
|
|
1440
|
-
const el = container()
|
|
1441
|
-
const visible = signal(true)
|
|
1442
|
-
let beforeEnterCalled = false
|
|
1443
|
-
mount(
|
|
1444
|
-
h(Transition, {
|
|
1445
|
-
show: visible,
|
|
1446
|
-
appear: true,
|
|
1447
|
-
onBeforeEnter: () => {
|
|
1448
|
-
beforeEnterCalled = true
|
|
1449
|
-
},
|
|
1450
|
-
children: h('div', { id: 'appear-test' }, 'content'),
|
|
1451
|
-
}),
|
|
1452
|
-
el,
|
|
1453
|
-
)
|
|
1454
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
1455
|
-
expect(beforeEnterCalled).toBe(true)
|
|
1456
|
-
})
|
|
1457
|
-
|
|
1458
|
-
test('calls onBeforeLeave when leaving', async () => {
|
|
1459
|
-
const el = container()
|
|
1460
|
-
const visible = signal(true)
|
|
1461
|
-
let beforeLeaveCalled = false
|
|
1462
|
-
mount(
|
|
1463
|
-
h(Transition, {
|
|
1464
|
-
show: visible,
|
|
1465
|
-
onBeforeLeave: () => {
|
|
1466
|
-
beforeLeaveCalled = true
|
|
1467
|
-
},
|
|
1468
|
-
children: h('div', { id: 'leave-cb' }, 'content'),
|
|
1469
|
-
}),
|
|
1470
|
-
el,
|
|
1471
|
-
)
|
|
1472
|
-
visible.set(false)
|
|
1473
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
1474
|
-
expect(beforeLeaveCalled).toBe(true)
|
|
1475
|
-
})
|
|
1476
|
-
|
|
1477
|
-
test('re-entering during leave cancels leave', async () => {
|
|
1478
|
-
const el = container()
|
|
1479
|
-
const visible = signal(true)
|
|
1480
|
-
mount(
|
|
1481
|
-
h(Transition, {
|
|
1482
|
-
show: visible,
|
|
1483
|
-
name: 'fade',
|
|
1484
|
-
children: h('div', { id: 'reenter' }, 'content'),
|
|
1485
|
-
}),
|
|
1486
|
-
el,
|
|
1487
|
-
)
|
|
1488
|
-
// Start leaving
|
|
1489
|
-
visible.set(false)
|
|
1490
|
-
// Before the leave animation finishes, re-enter
|
|
1491
|
-
visible.set(true)
|
|
1492
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
1493
|
-
expect(el.querySelector('#reenter')).not.toBeNull()
|
|
1494
|
-
})
|
|
1495
|
-
|
|
1496
|
-
test('transition with name prefix', () => {
|
|
1497
|
-
const el = container()
|
|
1498
|
-
const visible = signal(true)
|
|
1499
|
-
mount(
|
|
1500
|
-
h(Transition, {
|
|
1501
|
-
show: visible,
|
|
1502
|
-
name: 'slide',
|
|
1503
|
-
children: h('div', { id: 'named' }, 'content'),
|
|
1504
|
-
}),
|
|
1505
|
-
el,
|
|
1506
|
-
)
|
|
1507
|
-
expect(el.querySelector('#named')).not.toBeNull()
|
|
1508
|
-
})
|
|
1509
|
-
})
|
|
1510
|
-
|
|
1511
|
-
// ─── Hydration ───────────────────────────────────────────────────────────────
|
|
1512
|
-
|
|
1513
|
-
describe('hydrateRoot', () => {
|
|
1514
|
-
test('hydrates basic element', async () => {
|
|
1515
|
-
const el = container()
|
|
1516
|
-
el.innerHTML = '<div><span>hello</span></div>'
|
|
1517
|
-
const cleanup = hydrateRoot(el, h('div', null, h('span', null, 'hello')))
|
|
1518
|
-
expect(el.querySelector('span')?.textContent).toBe('hello')
|
|
1519
|
-
cleanup()
|
|
1520
|
-
})
|
|
1521
|
-
|
|
1522
|
-
test('hydrates and attaches event handler', async () => {
|
|
1523
|
-
const el = container()
|
|
1524
|
-
el.innerHTML = '<button>click me</button>'
|
|
1525
|
-
let clicked = false
|
|
1526
|
-
hydrateRoot(
|
|
1527
|
-
el,
|
|
1528
|
-
h(
|
|
1529
|
-
'button',
|
|
1530
|
-
{
|
|
1531
|
-
onClick: () => {
|
|
1532
|
-
clicked = true
|
|
1533
|
-
},
|
|
1534
|
-
},
|
|
1535
|
-
'click me',
|
|
1536
|
-
),
|
|
1537
|
-
)
|
|
1538
|
-
el.querySelector('button')?.click()
|
|
1539
|
-
expect(clicked).toBe(true)
|
|
1540
|
-
})
|
|
1541
|
-
|
|
1542
|
-
test('hydrates text content', async () => {
|
|
1543
|
-
const el = container()
|
|
1544
|
-
el.innerHTML = '<p>some text</p>'
|
|
1545
|
-
const cleanup = hydrateRoot(el, h('p', null, 'some text'))
|
|
1546
|
-
expect(el.querySelector('p')?.textContent).toBe('some text')
|
|
1547
|
-
cleanup()
|
|
1548
|
-
})
|
|
1549
|
-
|
|
1550
|
-
test('hydrates reactive text', async () => {
|
|
1551
|
-
const el = container()
|
|
1552
|
-
el.innerHTML = '<div>initial</div>'
|
|
1553
|
-
const text = signal('initial')
|
|
1554
|
-
hydrateRoot(
|
|
1555
|
-
el,
|
|
1556
|
-
h('div', null, () => text()),
|
|
1557
|
-
)
|
|
1558
|
-
expect(el.querySelector('div')?.textContent).toBe('initial')
|
|
1559
|
-
text.set('updated')
|
|
1560
|
-
expect(el.querySelector('div')?.textContent).toBe('updated')
|
|
1561
|
-
})
|
|
1562
|
-
|
|
1563
|
-
test('hydrates nested elements', async () => {
|
|
1564
|
-
const el = container()
|
|
1565
|
-
el.innerHTML = '<div><p><span>deep</span></p></div>'
|
|
1566
|
-
const cleanup = hydrateRoot(el, h('div', null, h('p', null, h('span', null, 'deep'))))
|
|
1567
|
-
expect(el.querySelector('span')?.textContent).toBe('deep')
|
|
1568
|
-
cleanup()
|
|
1569
|
-
})
|
|
1570
|
-
|
|
1571
|
-
test('hydrates component', async () => {
|
|
1572
|
-
const el = container()
|
|
1573
|
-
el.innerHTML = '<p>Hello, World!</p>'
|
|
1574
|
-
const Greeting = defineComponent(() => h('p', null, 'Hello, World!'))
|
|
1575
|
-
const cleanup = hydrateRoot(el, h(Greeting, null))
|
|
1576
|
-
expect(el.querySelector('p')?.textContent).toBe('Hello, World!')
|
|
1577
|
-
cleanup()
|
|
1578
|
-
})
|
|
1579
|
-
})
|
|
1580
|
-
|
|
1581
|
-
// ─── Mount edge cases ────────────────────────────────────────────────────────
|
|
1582
|
-
|
|
1583
|
-
describe('mount — edge cases', () => {
|
|
1584
|
-
test('null children in fragment', () => {
|
|
1585
|
-
const el = container()
|
|
1586
|
-
mount(h(Fragment, null, null, 'text', null), el)
|
|
1587
|
-
expect(el.textContent).toBe('text')
|
|
1588
|
-
})
|
|
1589
|
-
|
|
1590
|
-
test('deeply nested fragments', () => {
|
|
1591
|
-
const el = container()
|
|
1592
|
-
mount(h(Fragment, null, h(Fragment, null, h(Fragment, null, h('span', null, 'deep')))), el)
|
|
1593
|
-
expect(el.querySelector('span')?.textContent).toBe('deep')
|
|
1594
|
-
})
|
|
1595
|
-
|
|
1596
|
-
test('component returning null', () => {
|
|
1597
|
-
const el = container()
|
|
1598
|
-
const NullComp = defineComponent(() => null)
|
|
1599
|
-
mount(h(NullComp, null), el)
|
|
1600
|
-
expect(el.innerHTML).toBe('')
|
|
1601
|
-
})
|
|
1602
|
-
|
|
1603
|
-
test('component returning fragment with mixed children', () => {
|
|
1604
|
-
const el = container()
|
|
1605
|
-
const Mixed = defineComponent(() => h(Fragment, null, 'text', h('b', null, 'bold'), null, 42))
|
|
1606
|
-
mount(h(Mixed, null), el)
|
|
1607
|
-
expect(el.textContent).toContain('text')
|
|
1608
|
-
expect(el.querySelector('b')?.textContent).toBe('bold')
|
|
1609
|
-
expect(el.textContent).toContain('42')
|
|
1610
|
-
})
|
|
1611
|
-
|
|
1612
|
-
test('mounting array of children', () => {
|
|
1613
|
-
const el = container()
|
|
1614
|
-
mount(h('div', null, h('span', null, 'a'), h('span', null, 'b'), h('span', null, 'c')), el)
|
|
1615
|
-
expect(el.querySelectorAll('span').length).toBe(3)
|
|
1616
|
-
})
|
|
1617
|
-
|
|
1618
|
-
test('reactive child toggling between null and element', () => {
|
|
1619
|
-
const el = container()
|
|
1620
|
-
const show = signal(false)
|
|
1621
|
-
mount(
|
|
1622
|
-
h('div', null, () => (show() ? h('span', { id: 'toggle' }, 'yes') : null)),
|
|
1623
|
-
el,
|
|
1624
|
-
)
|
|
1625
|
-
expect(el.querySelector('#toggle')).toBeNull()
|
|
1626
|
-
show.set(true)
|
|
1627
|
-
expect(el.querySelector('#toggle')).not.toBeNull()
|
|
1628
|
-
show.set(false)
|
|
1629
|
-
expect(el.querySelector('#toggle')).toBeNull()
|
|
1630
|
-
})
|
|
1631
|
-
|
|
1632
|
-
test('reactive child returning component renders content', () => {
|
|
1633
|
-
const el = container()
|
|
1634
|
-
const Dashboard = () => h('div', { id: 'dashboard' }, 'Dashboard content')
|
|
1635
|
-
const Store = () => h('div', { id: 'store' }, 'Store content')
|
|
1636
|
-
const activeTab = signal('dashboard')
|
|
1637
|
-
const tabs = [
|
|
1638
|
-
{ id: 'dashboard', component: Dashboard },
|
|
1639
|
-
{ id: 'store', component: Store },
|
|
1640
|
-
]
|
|
1641
|
-
|
|
1642
|
-
mount(
|
|
1643
|
-
h('div', null, () => {
|
|
1644
|
-
const tab = tabs.find((t) => t.id === activeTab())
|
|
1645
|
-
if (!tab) return null
|
|
1646
|
-
const Component = tab.component
|
|
1647
|
-
return h(Component, null)
|
|
1648
|
-
}),
|
|
1649
|
-
el,
|
|
1650
|
-
)
|
|
1651
|
-
|
|
1652
|
-
expect(el.querySelector('#dashboard')).not.toBeNull()
|
|
1653
|
-
expect(el.querySelector('#dashboard')?.textContent).toBe('Dashboard content')
|
|
1654
|
-
|
|
1655
|
-
activeTab.set('store')
|
|
1656
|
-
expect(el.querySelector('#store')).not.toBeNull()
|
|
1657
|
-
expect(el.querySelector('#store')?.textContent).toBe('Store content')
|
|
1658
|
-
expect(el.querySelector('#dashboard')).toBeNull()
|
|
1659
|
-
})
|
|
1660
|
-
|
|
1661
|
-
test('reactive child returning component with internal signals', () => {
|
|
1662
|
-
const el = container()
|
|
1663
|
-
const Dashboard = () => {
|
|
1664
|
-
const count = signal(0)
|
|
1665
|
-
return h('div', { id: 'dashboard' }, () => `Count: ${count()}`)
|
|
1666
|
-
}
|
|
1667
|
-
const Settings = () => {
|
|
1668
|
-
return h('div', { id: 'settings' }, h('span', null, 'Settings page'))
|
|
1669
|
-
}
|
|
1670
|
-
const activeTab = signal<string>('dashboard')
|
|
1671
|
-
|
|
1672
|
-
mount(
|
|
1673
|
-
h('div', null, () => {
|
|
1674
|
-
const tab = activeTab()
|
|
1675
|
-
if (tab === 'dashboard') return h(Dashboard, null)
|
|
1676
|
-
if (tab === 'settings') return h(Settings, null)
|
|
1677
|
-
return null
|
|
1678
|
-
}),
|
|
1679
|
-
el,
|
|
1680
|
-
)
|
|
1681
|
-
|
|
1682
|
-
expect(el.querySelector('#dashboard')).not.toBeNull()
|
|
1683
|
-
expect(el.querySelector('#dashboard')?.textContent).toBe('Count: 0')
|
|
1684
|
-
|
|
1685
|
-
activeTab.set('settings')
|
|
1686
|
-
expect(el.querySelector('#settings')).not.toBeNull()
|
|
1687
|
-
expect(el.querySelector('#settings')?.textContent).toBe('Settings page')
|
|
1688
|
-
expect(el.querySelector('#dashboard')).toBeNull()
|
|
1689
|
-
|
|
1690
|
-
activeTab.set('none')
|
|
1691
|
-
expect(el.querySelector('#settings')).toBeNull()
|
|
1692
|
-
expect(el.querySelector('#dashboard')).toBeNull()
|
|
1693
|
-
})
|
|
1694
|
-
|
|
1695
|
-
test('reactive Dynamic component switching', () => {
|
|
1696
|
-
const el = container()
|
|
1697
|
-
const Dashboard = () => h('div', { id: 'dashboard' }, 'Dashboard')
|
|
1698
|
-
const Settings = () => h('div', { id: 'settings' }, 'Settings')
|
|
1699
|
-
const activeTab = signal<string>('dashboard')
|
|
1700
|
-
const components: Record<string, ComponentFn> = { dashboard: Dashboard, settings: Settings }
|
|
1701
|
-
|
|
1702
|
-
mount(
|
|
1703
|
-
h('div', null, () => h(Dynamic, { component: components[activeTab()] })),
|
|
1704
|
-
el,
|
|
1705
|
-
)
|
|
1706
|
-
|
|
1707
|
-
expect(el.querySelector('#dashboard')?.textContent).toBe('Dashboard')
|
|
1708
|
-
|
|
1709
|
-
activeTab.set('settings')
|
|
1710
|
-
expect(el.querySelector('#settings')?.textContent).toBe('Settings')
|
|
1711
|
-
expect(el.querySelector('#dashboard')).toBeNull()
|
|
1712
|
-
})
|
|
1713
|
-
|
|
1714
|
-
test('boolean false renders nothing', () => {
|
|
1715
|
-
const el = container()
|
|
1716
|
-
mount(h('div', null, false), el)
|
|
1717
|
-
expect(el.querySelector('div')?.textContent).toBe('')
|
|
1718
|
-
})
|
|
1719
|
-
|
|
1720
|
-
test('number 0 renders as text', () => {
|
|
1721
|
-
const el = container()
|
|
1722
|
-
mount(h('div', null, 0), el)
|
|
1723
|
-
expect(el.querySelector('div')?.textContent).toBe('0')
|
|
1724
|
-
})
|
|
1725
|
-
|
|
1726
|
-
test('empty string renders as text node', () => {
|
|
1727
|
-
const el = container()
|
|
1728
|
-
mount(h('div', null, ''), el)
|
|
1729
|
-
expect(el.querySelector('div')?.textContent).toBe('')
|
|
1730
|
-
})
|
|
1731
|
-
|
|
1732
|
-
test('component with children prop', () => {
|
|
1733
|
-
const el = container()
|
|
1734
|
-
const Wrapper = defineComponent((props: { children?: VNodeChild }) => {
|
|
1735
|
-
return h('div', { id: 'wrapper' }, props.children)
|
|
1736
|
-
})
|
|
1737
|
-
mount(h(Wrapper, null, h('span', null, 'child')), el)
|
|
1738
|
-
expect(el.querySelector('#wrapper span')?.textContent).toBe('child')
|
|
1739
|
-
})
|
|
1740
|
-
})
|
|
1741
|
-
|
|
1742
|
-
// ─── KeepAlive ───────────────────────────────────────────────────────────────
|
|
1743
|
-
|
|
1744
|
-
describe('KeepAlive', () => {
|
|
1745
|
-
test('mounts children and preserves them when toggled', async () => {
|
|
1746
|
-
const el = container()
|
|
1747
|
-
const active = signal(true)
|
|
1748
|
-
mount(h(KeepAlive, { active }, h('div', { id: 'kept' }, 'alive')), el)
|
|
1749
|
-
// KeepAlive mounts in onMount which fires sync in this framework
|
|
1750
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
1751
|
-
expect(el.querySelector('#kept')).not.toBeNull()
|
|
1752
|
-
active.set(false)
|
|
1753
|
-
// Content should still exist in DOM but container hidden
|
|
1754
|
-
expect(el.querySelector('#kept')).not.toBeNull()
|
|
1755
|
-
})
|
|
1756
|
-
|
|
1757
|
-
test('cleanup disposes effect and child cleanup', async () => {
|
|
1758
|
-
const el = container()
|
|
1759
|
-
const active = signal(true)
|
|
1760
|
-
const unmount = mount(h(KeepAlive, { active }, h('div', { id: 'ka-cleanup' }, 'content')), el)
|
|
1761
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
1762
|
-
expect(el.querySelector('#ka-cleanup')).not.toBeNull()
|
|
1763
|
-
unmount()
|
|
1764
|
-
// After unmount, the KeepAlive container is gone
|
|
1765
|
-
expect(el.innerHTML).toBe('')
|
|
1766
|
-
})
|
|
1767
|
-
|
|
1768
|
-
test('active defaults to true when not provided', async () => {
|
|
1769
|
-
const el = container()
|
|
1770
|
-
mount(h(KeepAlive, {}, h('div', { id: 'ka-default' }, 'visible')), el)
|
|
1771
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
1772
|
-
expect(el.querySelector('#ka-default')).not.toBeNull()
|
|
1773
|
-
// Container should be visible (display not set to none)
|
|
1774
|
-
const wrapper = el.querySelector('div[style]') as HTMLElement | null
|
|
1775
|
-
// If wrapper exists, display should not be none
|
|
1776
|
-
if (wrapper) expect(wrapper.style.display).not.toBe('none')
|
|
1777
|
-
})
|
|
1778
|
-
|
|
1779
|
-
test('toggles display:none when active changes', async () => {
|
|
1780
|
-
const el = container()
|
|
1781
|
-
const active = signal(true)
|
|
1782
|
-
mount(h(KeepAlive, { active }, h('span', { id: 'ka-toggle' }, 'x')), el)
|
|
1783
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
1784
|
-
// Find the container div that KeepAlive creates
|
|
1785
|
-
const containers = el.querySelectorAll('div')
|
|
1786
|
-
const keepAliveContainer = Array.from(containers).find((d) => d.querySelector('#ka-toggle')) as
|
|
1787
|
-
| HTMLElement
|
|
1788
|
-
| undefined
|
|
1789
|
-
if (keepAliveContainer) {
|
|
1790
|
-
expect(keepAliveContainer.style.display).not.toBe('none')
|
|
1791
|
-
active.set(false)
|
|
1792
|
-
expect(keepAliveContainer.style.display).toBe('none')
|
|
1793
|
-
active.set(true)
|
|
1794
|
-
expect(keepAliveContainer.style.display).toBe('')
|
|
1795
|
-
}
|
|
1796
|
-
})
|
|
1797
|
-
})
|
|
1798
|
-
|
|
1799
|
-
// ─── Hydration (extended coverage) ───────────────────────────────────────────
|
|
1800
|
-
|
|
1801
|
-
describe('hydrateRoot — extended', () => {
|
|
1802
|
-
test('hydrates Fragment children', async () => {
|
|
1803
|
-
const el = container()
|
|
1804
|
-
el.innerHTML = '<span>a</span><span>b</span>'
|
|
1805
|
-
const cleanup = hydrateRoot(el, h(Fragment, null, h('span', null, 'a'), h('span', null, 'b')))
|
|
1806
|
-
const spans = el.querySelectorAll('span')
|
|
1807
|
-
expect(spans.length).toBe(2)
|
|
1808
|
-
expect(spans[0]?.textContent).toBe('a')
|
|
1809
|
-
expect(spans[1]?.textContent).toBe('b')
|
|
1810
|
-
cleanup()
|
|
1811
|
-
})
|
|
1812
|
-
|
|
1813
|
-
test('hydrates array children', async () => {
|
|
1814
|
-
const el = container()
|
|
1815
|
-
el.innerHTML = '<div><span>x</span><span>y</span></div>'
|
|
1816
|
-
const cleanup = hydrateRoot(el, h('div', null, h('span', null, 'x'), h('span', null, 'y')))
|
|
1817
|
-
expect(el.querySelectorAll('span').length).toBe(2)
|
|
1818
|
-
cleanup()
|
|
1819
|
-
})
|
|
1820
|
-
|
|
1821
|
-
test('hydrates null/false child — returns noop', async () => {
|
|
1822
|
-
const el = container()
|
|
1823
|
-
el.innerHTML = '<div></div>'
|
|
1824
|
-
const cleanup = hydrateRoot(el, h('div', null, null, false))
|
|
1825
|
-
expect(el.querySelector('div')).not.toBeNull()
|
|
1826
|
-
cleanup()
|
|
1827
|
-
})
|
|
1828
|
-
|
|
1829
|
-
test('hydrates reactive accessor returning null initially', async () => {
|
|
1830
|
-
const el = container()
|
|
1831
|
-
el.innerHTML = '<div></div>'
|
|
1832
|
-
const show = signal<string | null>(null)
|
|
1833
|
-
const cleanup = hydrateRoot(
|
|
1834
|
-
el,
|
|
1835
|
-
h('div', null, () => show()),
|
|
1836
|
-
)
|
|
1837
|
-
// Initially null — a comment marker is inserted
|
|
1838
|
-
show.set('hello')
|
|
1839
|
-
// After update, the text should appear
|
|
1840
|
-
expect(el.textContent).toContain('hello')
|
|
1841
|
-
cleanup()
|
|
1842
|
-
})
|
|
1843
|
-
|
|
1844
|
-
test('hydrates reactive text that mismatches DOM node type', async () => {
|
|
1845
|
-
const el = container()
|
|
1846
|
-
el.innerHTML = '<div><span>wrong</span></div>'
|
|
1847
|
-
const text = signal('hello')
|
|
1848
|
-
// Reactive text expects a TextNode but finds a SPAN — should fall back
|
|
1849
|
-
const cleanup = hydrateRoot(
|
|
1850
|
-
el,
|
|
1851
|
-
h('div', null, () => text()),
|
|
1852
|
-
)
|
|
1853
|
-
cleanup()
|
|
1854
|
-
})
|
|
1855
|
-
|
|
1856
|
-
test('hydrates reactive VNode (complex initial value)', async () => {
|
|
1857
|
-
const el = container()
|
|
1858
|
-
el.innerHTML = '<div><p>old</p></div>'
|
|
1859
|
-
const content = signal<VNodeChild>(h('p', null, 'old'))
|
|
1860
|
-
const cleanup = hydrateRoot(el, h('div', null, (() => content()) as unknown as VNodeChild))
|
|
1861
|
-
cleanup()
|
|
1862
|
-
})
|
|
1863
|
-
|
|
1864
|
-
test('hydrates static text node', async () => {
|
|
1865
|
-
const el = container()
|
|
1866
|
-
el.innerHTML = 'just text'
|
|
1867
|
-
const cleanup = hydrateRoot(el, 'just text')
|
|
1868
|
-
expect(el.textContent).toContain('just text')
|
|
1869
|
-
cleanup()
|
|
1870
|
-
})
|
|
1871
|
-
|
|
1872
|
-
test('hydrates number as text', async () => {
|
|
1873
|
-
const el = container()
|
|
1874
|
-
el.innerHTML = '42'
|
|
1875
|
-
const cleanup = hydrateRoot(el, 42)
|
|
1876
|
-
expect(el.textContent).toContain('42')
|
|
1877
|
-
cleanup()
|
|
1878
|
-
})
|
|
1879
|
-
|
|
1880
|
-
test('hydration tag mismatch falls back to mount', async () => {
|
|
1881
|
-
const el = container()
|
|
1882
|
-
el.innerHTML = '<div>wrong</div>'
|
|
1883
|
-
// Expect span but find div — should fall back
|
|
1884
|
-
const cleanup = hydrateRoot(el, h('span', null, 'right'))
|
|
1885
|
-
// The span should have been mounted via fallback
|
|
1886
|
-
cleanup()
|
|
1887
|
-
})
|
|
1888
|
-
|
|
1889
|
-
test('hydrates element with ref', async () => {
|
|
1890
|
-
const el = container()
|
|
1891
|
-
el.innerHTML = '<button>click</button>'
|
|
1892
|
-
const ref = createRef<HTMLButtonElement>()
|
|
1893
|
-
const cleanup = hydrateRoot(el, h('button', { ref }, 'click'))
|
|
1894
|
-
expect(ref.current).not.toBeNull()
|
|
1895
|
-
expect(ref.current?.tagName).toBe('BUTTON')
|
|
1896
|
-
cleanup()
|
|
1897
|
-
expect(ref.current).toBeNull()
|
|
1898
|
-
})
|
|
1899
|
-
|
|
1900
|
-
test('hydrates Portal — always remounts', async () => {
|
|
1901
|
-
const el = container()
|
|
1902
|
-
const target = container()
|
|
1903
|
-
el.innerHTML = ''
|
|
1904
|
-
const cleanup = hydrateRoot(el, Portal({ target, children: h('span', null, 'portaled') }))
|
|
1905
|
-
expect(target.querySelector('span')?.textContent).toBe('portaled')
|
|
1906
|
-
cleanup()
|
|
1907
|
-
})
|
|
1908
|
-
|
|
1909
|
-
test('hydrates component with children prop', async () => {
|
|
1910
|
-
const el = container()
|
|
1911
|
-
el.innerHTML = '<div><p>child content</p></div>'
|
|
1912
|
-
const Wrapper = defineComponent((props: { children?: VNodeChild }) =>
|
|
1913
|
-
h('div', null, props.children),
|
|
1914
|
-
)
|
|
1915
|
-
const cleanup = hydrateRoot(el, h(Wrapper, null, h('p', null, 'child content')))
|
|
1916
|
-
expect(el.querySelector('p')?.textContent).toBe('child content')
|
|
1917
|
-
cleanup()
|
|
1918
|
-
})
|
|
1919
|
-
|
|
1920
|
-
test('hydrates component that throws — error handled gracefully', async () => {
|
|
1921
|
-
const el = container()
|
|
1922
|
-
el.innerHTML = '<p>content</p>'
|
|
1923
|
-
const Broken = defineComponent((): never => {
|
|
1924
|
-
throw new Error('hydration boom')
|
|
1925
|
-
})
|
|
1926
|
-
// Should not throw — error is caught internally
|
|
1927
|
-
const cleanup = hydrateRoot(el, h(Broken, null))
|
|
1928
|
-
cleanup()
|
|
1929
|
-
})
|
|
1930
|
-
|
|
1931
|
-
test('hydrates with For — fresh mount fallback (no markers)', async () => {
|
|
1932
|
-
const el = container()
|
|
1933
|
-
el.innerHTML = '<ul></ul>'
|
|
1934
|
-
const items = signal([{ id: 1, label: 'a' }])
|
|
1935
|
-
const cleanup = hydrateRoot(
|
|
1936
|
-
el,
|
|
1937
|
-
h(
|
|
1938
|
-
'ul',
|
|
1939
|
-
null,
|
|
1940
|
-
For({
|
|
1941
|
-
each: items,
|
|
1942
|
-
by: (r: { id: number }) => r.id,
|
|
1943
|
-
children: (r: { id: number; label: string }) => h('li', null, r.label),
|
|
1944
|
-
}),
|
|
1945
|
-
),
|
|
1946
|
-
)
|
|
1947
|
-
cleanup()
|
|
1948
|
-
})
|
|
1949
|
-
|
|
1950
|
-
test('hydrates with For — SSR markers present', async () => {
|
|
1951
|
-
const el = container()
|
|
1952
|
-
el.innerHTML = '<!--pyreon-for--><li>a</li><!--/pyreon-for-->'
|
|
1953
|
-
const items = signal([{ id: 1, label: 'a' }])
|
|
1954
|
-
const cleanup = hydrateRoot(
|
|
1955
|
-
el,
|
|
1956
|
-
For({
|
|
1957
|
-
each: items,
|
|
1958
|
-
by: (r: { id: number }) => r.id,
|
|
1959
|
-
children: (r: { id: number; label: string }) => h('li', null, r.label),
|
|
1960
|
-
}),
|
|
1961
|
-
)
|
|
1962
|
-
cleanup()
|
|
1963
|
-
})
|
|
1964
|
-
|
|
1965
|
-
test('hydration skips comment and whitespace text nodes', async () => {
|
|
1966
|
-
const el = container()
|
|
1967
|
-
// Simulate SSR output with comments and whitespace
|
|
1968
|
-
el.innerHTML = '<!-- comment --> <p>real</p>'
|
|
1969
|
-
const cleanup = hydrateRoot(el, h('p', null, 'real'))
|
|
1970
|
-
expect(el.querySelector('p')?.textContent).toBe('real')
|
|
1971
|
-
cleanup()
|
|
1972
|
-
})
|
|
1973
|
-
|
|
1974
|
-
test('hydrates with missing DOM node (null domNode)', async () => {
|
|
1975
|
-
const el = container()
|
|
1976
|
-
el.innerHTML = ''
|
|
1977
|
-
// VNode expects content but DOM is empty — should fall back
|
|
1978
|
-
const cleanup = hydrateRoot(el, h('div', null, 'content'))
|
|
1979
|
-
cleanup()
|
|
1980
|
-
})
|
|
1981
|
-
|
|
1982
|
-
test('hydrates reactive accessor returning VNode with no domNode', async () => {
|
|
1983
|
-
const el = container()
|
|
1984
|
-
el.innerHTML = ''
|
|
1985
|
-
const content = signal<VNodeChild>(h('p', null, 'dynamic'))
|
|
1986
|
-
const cleanup = hydrateRoot(el, (() => content()) as unknown as VNodeChild)
|
|
1987
|
-
cleanup()
|
|
1988
|
-
})
|
|
1989
|
-
|
|
1990
|
-
test('hydrates component with onMount hooks', async () => {
|
|
1991
|
-
const el = container()
|
|
1992
|
-
el.innerHTML = '<span>mounted</span>'
|
|
1993
|
-
let mountCalled = false
|
|
1994
|
-
const Comp = defineComponent(() => {
|
|
1995
|
-
onMount(() => {
|
|
1996
|
-
mountCalled = true
|
|
1997
|
-
})
|
|
1998
|
-
return h('span', null, 'mounted')
|
|
1999
|
-
})
|
|
2000
|
-
const cleanup = hydrateRoot(el, h(Comp, null))
|
|
2001
|
-
expect(mountCalled).toBe(true)
|
|
2002
|
-
cleanup()
|
|
2003
|
-
})
|
|
2004
|
-
|
|
2005
|
-
test('hydrates text mismatch for static string — falls back', async () => {
|
|
2006
|
-
const el = container()
|
|
2007
|
-
// Put an element where text is expected
|
|
2008
|
-
el.innerHTML = '<span>not text</span>'
|
|
2009
|
-
const cleanup = hydrateRoot(el, 'plain text')
|
|
2010
|
-
cleanup()
|
|
2011
|
-
})
|
|
2012
|
-
})
|
|
2013
|
-
|
|
2014
|
-
// ─── mountFor — additional edge cases ────────────────────────────────────────
|
|
2015
|
-
|
|
2016
|
-
describe('mountFor — edge cases', () => {
|
|
2017
|
-
type Item = { id: number; label: string }
|
|
2018
|
-
|
|
2019
|
-
function mountForList(el: HTMLElement, items: ReturnType<typeof signal<Item[]>>) {
|
|
2020
|
-
mount(
|
|
2021
|
-
h(
|
|
2022
|
-
'ul',
|
|
2023
|
-
null,
|
|
2024
|
-
For({
|
|
2025
|
-
each: items,
|
|
2026
|
-
by: (r) => r.id,
|
|
2027
|
-
children: (r) => h('li', { key: r.id }, r.label),
|
|
2028
|
-
}),
|
|
2029
|
-
),
|
|
2030
|
-
el,
|
|
2031
|
-
)
|
|
2032
|
-
}
|
|
2033
|
-
|
|
2034
|
-
test('empty initial → add items (fresh render path)', () => {
|
|
2035
|
-
const el = container()
|
|
2036
|
-
const items = signal<Item[]>([])
|
|
2037
|
-
mountForList(el, items)
|
|
2038
|
-
expect(el.querySelectorAll('li').length).toBe(0)
|
|
2039
|
-
items.set([
|
|
2040
|
-
{ id: 1, label: 'a' },
|
|
2041
|
-
{ id: 2, label: 'b' },
|
|
2042
|
-
])
|
|
2043
|
-
expect(el.querySelectorAll('li').length).toBe(2)
|
|
2044
|
-
expect(el.querySelectorAll('li')[0]?.textContent).toBe('a')
|
|
2045
|
-
})
|
|
2046
|
-
|
|
2047
|
-
test('clear then add uses fresh render path', () => {
|
|
2048
|
-
const el = container()
|
|
2049
|
-
const items = signal<Item[]>([{ id: 1, label: 'x' }])
|
|
2050
|
-
mountForList(el, items)
|
|
2051
|
-
items.set([])
|
|
2052
|
-
expect(el.querySelectorAll('li').length).toBe(0)
|
|
2053
|
-
items.set([
|
|
2054
|
-
{ id: 2, label: 'y' },
|
|
2055
|
-
{ id: 3, label: 'z' },
|
|
2056
|
-
])
|
|
2057
|
-
expect(el.querySelectorAll('li').length).toBe(2)
|
|
2058
|
-
expect(el.querySelectorAll('li')[0]?.textContent).toBe('y')
|
|
2059
|
-
})
|
|
2060
|
-
|
|
2061
|
-
test('clear path with parent-swap optimization', () => {
|
|
2062
|
-
// When the For's markers are the first and last children of a parent,
|
|
2063
|
-
// the clear path uses parent-swap for O(1) clear.
|
|
2064
|
-
const el = container()
|
|
2065
|
-
const items = signal<Item[]>([
|
|
2066
|
-
{ id: 1, label: 'a' },
|
|
2067
|
-
{ id: 2, label: 'b' },
|
|
2068
|
-
{ id: 3, label: 'c' },
|
|
2069
|
-
])
|
|
2070
|
-
// Mount directly in the ul so markers are first/last children
|
|
2071
|
-
mount(
|
|
2072
|
-
h(
|
|
2073
|
-
'ul',
|
|
2074
|
-
null,
|
|
2075
|
-
For({
|
|
2076
|
-
each: items,
|
|
2077
|
-
by: (r) => r.id,
|
|
2078
|
-
children: (r) => h('li', { key: r.id }, r.label),
|
|
2079
|
-
}),
|
|
2080
|
-
),
|
|
2081
|
-
el,
|
|
2082
|
-
)
|
|
2083
|
-
expect(el.querySelectorAll('li').length).toBe(3)
|
|
2084
|
-
items.set([])
|
|
2085
|
-
expect(el.querySelectorAll('li').length).toBe(0)
|
|
2086
|
-
})
|
|
2087
|
-
|
|
2088
|
-
test('clear path without parent-swap (markers not first/last)', () => {
|
|
2089
|
-
const el = container()
|
|
2090
|
-
const items = signal<Item[]>([{ id: 1, label: 'a' }])
|
|
2091
|
-
// Mount with extra siblings so markers are not first/last
|
|
2092
|
-
mount(
|
|
2093
|
-
h(
|
|
2094
|
-
'div',
|
|
2095
|
-
null,
|
|
2096
|
-
h('span', null, 'before'),
|
|
2097
|
-
For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
|
|
2098
|
-
h('span', null, 'after'),
|
|
2099
|
-
),
|
|
2100
|
-
el,
|
|
2101
|
-
)
|
|
2102
|
-
expect(el.querySelectorAll('li').length).toBe(1)
|
|
2103
|
-
items.set([])
|
|
2104
|
-
expect(el.querySelectorAll('li').length).toBe(0)
|
|
2105
|
-
// The before/after spans should still be present
|
|
2106
|
-
expect(el.querySelectorAll('span').length).toBe(2)
|
|
2107
|
-
})
|
|
2108
|
-
|
|
2109
|
-
test('replace-all with parent-swap optimization', () => {
|
|
2110
|
-
const el = container()
|
|
2111
|
-
const items = signal<Item[]>([
|
|
2112
|
-
{ id: 1, label: 'old1' },
|
|
2113
|
-
{ id: 2, label: 'old2' },
|
|
2114
|
-
])
|
|
2115
|
-
mount(
|
|
2116
|
-
h(
|
|
2117
|
-
'ul',
|
|
2118
|
-
null,
|
|
2119
|
-
For({
|
|
2120
|
-
each: items,
|
|
2121
|
-
by: (r) => r.id,
|
|
2122
|
-
children: (r) => h('li', { key: r.id }, r.label),
|
|
2123
|
-
}),
|
|
2124
|
-
),
|
|
2125
|
-
el,
|
|
2126
|
-
)
|
|
2127
|
-
// Replace with completely new keys
|
|
2128
|
-
items.set([
|
|
2129
|
-
{ id: 10, label: 'new1' },
|
|
2130
|
-
{ id: 11, label: 'new2' },
|
|
2131
|
-
])
|
|
2132
|
-
expect(el.querySelectorAll('li').length).toBe(2)
|
|
2133
|
-
expect(el.querySelectorAll('li')[0]?.textContent).toBe('new1')
|
|
2134
|
-
})
|
|
2135
|
-
|
|
2136
|
-
test('replace-all without parent-swap (extra siblings)', () => {
|
|
2137
|
-
const el = container()
|
|
2138
|
-
const items = signal<Item[]>([{ id: 1, label: 'old' }])
|
|
2139
|
-
mount(
|
|
2140
|
-
h(
|
|
2141
|
-
'div',
|
|
2142
|
-
null,
|
|
2143
|
-
h('span', null, 'before'),
|
|
2144
|
-
For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
|
|
2145
|
-
h('span', null, 'after'),
|
|
2146
|
-
),
|
|
2147
|
-
el,
|
|
2148
|
-
)
|
|
2149
|
-
items.set([{ id: 10, label: 'new' }])
|
|
2150
|
-
expect(el.querySelectorAll('li').length).toBe(1)
|
|
2151
|
-
expect(el.querySelectorAll('li')[0]?.textContent).toBe('new')
|
|
2152
|
-
expect(el.querySelectorAll('span').length).toBe(2)
|
|
2153
|
-
})
|
|
2154
|
-
|
|
2155
|
-
test('remove stale entries', () => {
|
|
2156
|
-
const el = container()
|
|
2157
|
-
const items = signal<Item[]>([
|
|
2158
|
-
{ id: 1, label: 'a' },
|
|
2159
|
-
{ id: 2, label: 'b' },
|
|
2160
|
-
{ id: 3, label: 'c' },
|
|
2161
|
-
])
|
|
2162
|
-
mountForList(el, items)
|
|
2163
|
-
// Remove middle item — hits stale entry removal path
|
|
2164
|
-
items.set([
|
|
2165
|
-
{ id: 1, label: 'a' },
|
|
2166
|
-
{ id: 3, label: 'c' },
|
|
2167
|
-
])
|
|
2168
|
-
expect(el.querySelectorAll('li').length).toBe(2)
|
|
2169
|
-
expect(el.querySelectorAll('li')[0]?.textContent).toBe('a')
|
|
2170
|
-
expect(el.querySelectorAll('li')[1]?.textContent).toBe('c')
|
|
2171
|
-
})
|
|
2172
|
-
|
|
2173
|
-
test('LIS fallback for complex reorder (>8 diffs, same length)', () => {
|
|
2174
|
-
const el = container()
|
|
2175
|
-
// Create 15 items, then reverse all — forces > SMALL_K diffs and LIS path
|
|
2176
|
-
const initial = Array.from({ length: 15 }, (_, i) => ({
|
|
2177
|
-
id: i + 1,
|
|
2178
|
-
label: String.fromCharCode(97 + i),
|
|
2179
|
-
}))
|
|
2180
|
-
const items = signal<Item[]>(initial)
|
|
2181
|
-
mountForList(el, items)
|
|
2182
|
-
// Reverse all items: 15 diffs > SMALL_K (8)
|
|
2183
|
-
items.set([...initial].reverse())
|
|
2184
|
-
const lis = el.querySelectorAll('li')
|
|
2185
|
-
expect(lis.length).toBe(15)
|
|
2186
|
-
expect(lis[0]?.textContent).toBe('o') // last letter reversed
|
|
2187
|
-
expect(lis[14]?.textContent).toBe('a')
|
|
2188
|
-
})
|
|
2189
|
-
|
|
2190
|
-
test('LIS fallback for reorder with different length', () => {
|
|
2191
|
-
const el = container()
|
|
2192
|
-
const initial = Array.from({ length: 10 }, (_, i) => ({
|
|
2193
|
-
id: i + 1,
|
|
2194
|
-
label: String.fromCharCode(97 + i),
|
|
2195
|
-
}))
|
|
2196
|
-
const items = signal<Item[]>(initial)
|
|
2197
|
-
mountForList(el, items)
|
|
2198
|
-
// Reverse and add one — different length triggers LIS
|
|
2199
|
-
const reversed = [...initial].reverse()
|
|
2200
|
-
reversed.push({ id: 99, label: 'z' })
|
|
2201
|
-
items.set(reversed)
|
|
2202
|
-
const lis = el.querySelectorAll('li')
|
|
2203
|
-
expect(lis.length).toBe(11)
|
|
2204
|
-
expect(lis[0]?.textContent).toBe('j')
|
|
2205
|
-
expect(lis[10]?.textContent).toBe('z')
|
|
2206
|
-
})
|
|
2207
|
-
|
|
2208
|
-
test('small-k reorder path (<=8 diffs, same length)', () => {
|
|
2209
|
-
const el = container()
|
|
2210
|
-
const items = signal<Item[]>([
|
|
2211
|
-
{ id: 1, label: 'a' },
|
|
2212
|
-
{ id: 2, label: 'b' },
|
|
2213
|
-
{ id: 3, label: 'c' },
|
|
2214
|
-
{ id: 4, label: 'd' },
|
|
2215
|
-
])
|
|
2216
|
-
mountForList(el, items)
|
|
2217
|
-
// Swap positions 1 and 2 — only 2 diffs < SMALL_K
|
|
2218
|
-
items.set([
|
|
2219
|
-
{ id: 1, label: 'a' },
|
|
2220
|
-
{ id: 3, label: 'c' },
|
|
2221
|
-
{ id: 2, label: 'b' },
|
|
2222
|
-
{ id: 4, label: 'd' },
|
|
2223
|
-
])
|
|
2224
|
-
const lis = el.querySelectorAll('li')
|
|
2225
|
-
expect(lis[1]?.textContent).toBe('c')
|
|
2226
|
-
expect(lis[2]?.textContent).toBe('b')
|
|
2227
|
-
})
|
|
2228
|
-
|
|
2229
|
-
test('add and remove items simultaneously', () => {
|
|
2230
|
-
const el = container()
|
|
2231
|
-
const items = signal<Item[]>([
|
|
2232
|
-
{ id: 1, label: 'a' },
|
|
2233
|
-
{ id: 2, label: 'b' },
|
|
2234
|
-
{ id: 3, label: 'c' },
|
|
2235
|
-
])
|
|
2236
|
-
mountForList(el, items)
|
|
2237
|
-
// Remove 2, add 4 and 5
|
|
2238
|
-
items.set([
|
|
2239
|
-
{ id: 1, label: 'a' },
|
|
2240
|
-
{ id: 4, label: 'd' },
|
|
2241
|
-
{ id: 3, label: 'c' },
|
|
2242
|
-
{ id: 5, label: 'e' },
|
|
2243
|
-
])
|
|
2244
|
-
const lis = el.querySelectorAll('li')
|
|
2245
|
-
expect(lis.length).toBe(4)
|
|
2246
|
-
expect(lis[0]?.textContent).toBe('a')
|
|
2247
|
-
// Verify all expected items are present
|
|
2248
|
-
const texts = Array.from(lis).map((li) => li.textContent)
|
|
2249
|
-
expect(texts).toContain('a')
|
|
2250
|
-
expect(texts).toContain('c')
|
|
2251
|
-
expect(texts).toContain('d')
|
|
2252
|
-
expect(texts).toContain('e')
|
|
2253
|
-
})
|
|
2254
|
-
|
|
2255
|
-
test('unmount For cleanup disposes all entries', () => {
|
|
2256
|
-
const el = container()
|
|
2257
|
-
const items = signal<Item[]>([
|
|
2258
|
-
{ id: 1, label: 'a' },
|
|
2259
|
-
{ id: 2, label: 'b' },
|
|
2260
|
-
])
|
|
2261
|
-
const unmount = mount(
|
|
2262
|
-
h(
|
|
2263
|
-
'ul',
|
|
2264
|
-
null,
|
|
2265
|
-
For({
|
|
2266
|
-
each: items,
|
|
2267
|
-
by: (r) => r.id,
|
|
2268
|
-
children: (r) => h('li', { key: r.id }, r.label),
|
|
2269
|
-
}),
|
|
2270
|
-
),
|
|
2271
|
-
el,
|
|
2272
|
-
)
|
|
2273
|
-
expect(el.querySelectorAll('li').length).toBe(2)
|
|
2274
|
-
unmount()
|
|
2275
|
-
expect(el.innerHTML).toBe('')
|
|
2276
|
-
})
|
|
2277
|
-
})
|
|
2278
|
-
|
|
2279
|
-
// ─── mountKeyedList — additional coverage ────────────────────────────────────
|
|
2280
|
-
|
|
2281
|
-
describe('mountKeyedList — via reactive keyed array', () => {
|
|
2282
|
-
test('reactive accessor returning keyed VNode array uses mountKeyedList', () => {
|
|
2283
|
-
const el = container()
|
|
2284
|
-
const items = signal([
|
|
2285
|
-
{ id: 1, text: 'a' },
|
|
2286
|
-
{ id: 2, text: 'b' },
|
|
2287
|
-
])
|
|
2288
|
-
mount(
|
|
2289
|
-
h('ul', null, () => items().map((it) => h('li', { key: it.id }, it.text))),
|
|
2290
|
-
el,
|
|
2291
|
-
)
|
|
2292
|
-
expect(el.querySelectorAll('li').length).toBe(2)
|
|
2293
|
-
expect(el.querySelectorAll('li')[0]?.textContent).toBe('a')
|
|
2294
|
-
})
|
|
2295
|
-
|
|
2296
|
-
test('mountKeyedList handles clear (empty array)', () => {
|
|
2297
|
-
const el = container()
|
|
2298
|
-
const items = signal([
|
|
2299
|
-
{ id: 1, text: 'a' },
|
|
2300
|
-
{ id: 2, text: 'b' },
|
|
2301
|
-
])
|
|
2302
|
-
mount(
|
|
2303
|
-
h('ul', null, () => items().map((it) => h('li', { key: it.id }, it.text))),
|
|
2304
|
-
el,
|
|
2305
|
-
)
|
|
2306
|
-
items.set([])
|
|
2307
|
-
expect(el.querySelectorAll('li').length).toBe(0)
|
|
2308
|
-
})
|
|
2309
|
-
|
|
2310
|
-
test('mountKeyedList handles reorder', () => {
|
|
2311
|
-
const el = container()
|
|
2312
|
-
const items = signal([
|
|
2313
|
-
{ id: 1, text: 'a' },
|
|
2314
|
-
{ id: 2, text: 'b' },
|
|
2315
|
-
{ id: 3, text: 'c' },
|
|
2316
|
-
])
|
|
2317
|
-
mount(
|
|
2318
|
-
h('ul', null, () => items().map((it) => h('li', { key: it.id }, it.text))),
|
|
2319
|
-
el,
|
|
2320
|
-
)
|
|
2321
|
-
items.set([
|
|
2322
|
-
{ id: 3, text: 'c' },
|
|
2323
|
-
{ id: 1, text: 'a' },
|
|
2324
|
-
{ id: 2, text: 'b' },
|
|
2325
|
-
])
|
|
2326
|
-
const lis = el.querySelectorAll('li')
|
|
2327
|
-
expect(lis[0]?.textContent).toBe('c')
|
|
2328
|
-
expect(lis[1]?.textContent).toBe('a')
|
|
2329
|
-
expect(lis[2]?.textContent).toBe('b')
|
|
2330
|
-
})
|
|
2331
|
-
|
|
2332
|
-
test('mountKeyedList removes stale entries', () => {
|
|
2333
|
-
const el = container()
|
|
2334
|
-
const items = signal([
|
|
2335
|
-
{ id: 1, text: 'a' },
|
|
2336
|
-
{ id: 2, text: 'b' },
|
|
2337
|
-
{ id: 3, text: 'c' },
|
|
2338
|
-
])
|
|
2339
|
-
mount(
|
|
2340
|
-
h('ul', null, () => items().map((it) => h('li', { key: it.id }, it.text))),
|
|
2341
|
-
el,
|
|
2342
|
-
)
|
|
2343
|
-
items.set([{ id: 2, text: 'b' }])
|
|
2344
|
-
expect(el.querySelectorAll('li').length).toBe(1)
|
|
2345
|
-
expect(el.querySelectorAll('li')[0]?.textContent).toBe('b')
|
|
2346
|
-
})
|
|
2347
|
-
|
|
2348
|
-
test('mountKeyedList adds new entries', () => {
|
|
2349
|
-
const el = container()
|
|
2350
|
-
const items = signal([{ id: 1, text: 'a' }])
|
|
2351
|
-
mount(
|
|
2352
|
-
h('ul', null, () => items().map((it) => h('li', { key: it.id }, it.text))),
|
|
2353
|
-
el,
|
|
2354
|
-
)
|
|
2355
|
-
items.set([
|
|
2356
|
-
{ id: 1, text: 'a' },
|
|
2357
|
-
{ id: 2, text: 'b' },
|
|
2358
|
-
{ id: 3, text: 'c' },
|
|
2359
|
-
])
|
|
2360
|
-
expect(el.querySelectorAll('li').length).toBe(3)
|
|
2361
|
-
})
|
|
2362
|
-
|
|
2363
|
-
test('mountKeyedList cleanup disposes all entries', () => {
|
|
2364
|
-
const el = container()
|
|
2365
|
-
const items = signal([
|
|
2366
|
-
{ id: 1, text: 'a' },
|
|
2367
|
-
{ id: 2, text: 'b' },
|
|
2368
|
-
])
|
|
2369
|
-
const unmount = mount(
|
|
2370
|
-
h('ul', null, () => items().map((it) => h('li', { key: it.id }, it.text))),
|
|
2371
|
-
el,
|
|
2372
|
-
)
|
|
2373
|
-
unmount()
|
|
2374
|
-
expect(el.innerHTML).toBe('')
|
|
2375
|
-
})
|
|
2376
|
-
})
|
|
2377
|
-
|
|
2378
|
-
// ─── mountReactive — additional coverage ─────────────────────────────────────
|
|
2379
|
-
|
|
2380
|
-
describe('mountReactive — edge cases', () => {
|
|
2381
|
-
test('reactive accessor returning null then VNode', () => {
|
|
2382
|
-
const el = container()
|
|
2383
|
-
const show = signal(false)
|
|
2384
|
-
mount(
|
|
2385
|
-
h('div', null, () => (show() ? h('span', null, 'yes') : null)),
|
|
2386
|
-
el,
|
|
2387
|
-
)
|
|
2388
|
-
expect(el.querySelector('span')).toBeNull()
|
|
2389
|
-
show.set(true)
|
|
2390
|
-
expect(el.querySelector('span')?.textContent).toBe('yes')
|
|
2391
|
-
})
|
|
2392
|
-
|
|
2393
|
-
test('reactive accessor returning false', () => {
|
|
2394
|
-
const el = container()
|
|
2395
|
-
const show = signal(false)
|
|
2396
|
-
mount(
|
|
2397
|
-
h('div', null, () => (show() ? 'visible' : false)),
|
|
2398
|
-
el,
|
|
2399
|
-
)
|
|
2400
|
-
expect(el.querySelector('div')?.textContent).toBe('')
|
|
2401
|
-
show.set(true)
|
|
2402
|
-
expect(el.querySelector('div')?.textContent).toBe('visible')
|
|
2403
|
-
})
|
|
2404
|
-
|
|
2405
|
-
test('reactive text fast path — null/undefined fallback', () => {
|
|
2406
|
-
const el = container()
|
|
2407
|
-
const text = signal<string | null>('hello')
|
|
2408
|
-
mount(
|
|
2409
|
-
h('div', null, () => text()),
|
|
2410
|
-
el,
|
|
2411
|
-
)
|
|
2412
|
-
expect(el.querySelector('div')?.textContent).toBe('hello')
|
|
2413
|
-
text.set(null)
|
|
2414
|
-
expect(el.querySelector('div')?.textContent).toBe('')
|
|
2415
|
-
})
|
|
2416
|
-
|
|
2417
|
-
test('reactive boolean text fast path', () => {
|
|
2418
|
-
const el = container()
|
|
2419
|
-
const val = signal(true)
|
|
2420
|
-
mount(
|
|
2421
|
-
h('div', null, () => val()),
|
|
2422
|
-
el,
|
|
2423
|
-
)
|
|
2424
|
-
expect(el.querySelector('div')?.textContent).toBe('true')
|
|
2425
|
-
val.set(false)
|
|
2426
|
-
expect(el.querySelector('div')?.textContent).toBe('')
|
|
2427
|
-
})
|
|
2428
|
-
|
|
2429
|
-
test('mountReactive cleanup when anchor has no parent', () => {
|
|
2430
|
-
const el = container()
|
|
2431
|
-
const show = signal(true)
|
|
2432
|
-
const unmount = mount(
|
|
2433
|
-
h('div', null, () => (show() ? h('span', null, 'content') : null)),
|
|
2434
|
-
el,
|
|
2435
|
-
)
|
|
2436
|
-
unmount()
|
|
2437
|
-
// Should not throw even though marker may be detached
|
|
2438
|
-
expect(el.innerHTML).toBe('')
|
|
2439
|
-
})
|
|
2440
|
-
})
|
|
2441
|
-
|
|
2442
|
-
// ─── mount.ts — component branches ──────────────────────────────────────────
|
|
2443
|
-
|
|
2444
|
-
describe('mount — component branches', () => {
|
|
2445
|
-
test('component returning Fragment', () => {
|
|
2446
|
-
const el = container()
|
|
2447
|
-
const FragComp = defineComponent(() =>
|
|
2448
|
-
h(Fragment, null, h('span', null, 'a'), h('span', null, 'b')),
|
|
2449
|
-
)
|
|
2450
|
-
mount(h(FragComp, null), el)
|
|
2451
|
-
expect(el.querySelectorAll('span').length).toBe(2)
|
|
2452
|
-
})
|
|
2453
|
-
|
|
2454
|
-
test('component with onMount returning cleanup', async () => {
|
|
2455
|
-
const el = container()
|
|
2456
|
-
let cleaned = false
|
|
2457
|
-
const Comp = defineComponent(() => {
|
|
2458
|
-
onMount(() => () => {
|
|
2459
|
-
cleaned = true
|
|
2460
|
-
})
|
|
2461
|
-
return h('div', null, 'with-cleanup')
|
|
2462
|
-
})
|
|
2463
|
-
const unmount = mount(h(Comp, null), el)
|
|
2464
|
-
expect(cleaned).toBe(false)
|
|
2465
|
-
unmount()
|
|
2466
|
-
expect(cleaned).toBe(true)
|
|
2467
|
-
})
|
|
2468
|
-
|
|
2469
|
-
test('component with onUnmount hook', async () => {
|
|
2470
|
-
const el = container()
|
|
2471
|
-
let unmounted = false
|
|
2472
|
-
const Comp = defineComponent(() => {
|
|
2473
|
-
onUnmount(() => {
|
|
2474
|
-
unmounted = true
|
|
2475
|
-
})
|
|
2476
|
-
return h('div', null, 'unmount-test')
|
|
2477
|
-
})
|
|
2478
|
-
const unmount = mount(h(Comp, null), el)
|
|
2479
|
-
expect(unmounted).toBe(false)
|
|
2480
|
-
unmount()
|
|
2481
|
-
expect(unmounted).toBe(true)
|
|
2482
|
-
})
|
|
2483
|
-
|
|
2484
|
-
test('component with onUpdate hook', async () => {
|
|
2485
|
-
const el = container()
|
|
2486
|
-
const Comp = defineComponent(() => {
|
|
2487
|
-
const count = signal(0)
|
|
2488
|
-
onUpdate(() => {
|
|
2489
|
-
/* update tracked */
|
|
2490
|
-
})
|
|
2491
|
-
return h(
|
|
2492
|
-
'div',
|
|
2493
|
-
null,
|
|
2494
|
-
h('span', null, () => String(count())),
|
|
2495
|
-
h('button', { onClick: () => count.update((n: number) => n + 1) }, '+'),
|
|
2496
|
-
)
|
|
2497
|
-
})
|
|
2498
|
-
mount(h(Comp, null), el)
|
|
2499
|
-
// Click to trigger update
|
|
2500
|
-
el.querySelector('button')?.click()
|
|
2501
|
-
// onUpdate fires via microtask
|
|
2502
|
-
})
|
|
2503
|
-
|
|
2504
|
-
test('component children merge into props.children', () => {
|
|
2505
|
-
const el = container()
|
|
2506
|
-
const Parent = defineComponent((props: { children?: VNodeChild }) =>
|
|
2507
|
-
h('div', { id: 'parent' }, props.children),
|
|
2508
|
-
)
|
|
2509
|
-
mount(h(Parent, null, h('span', null, 'child1'), h('span', null, 'child2')), el)
|
|
2510
|
-
const spans = el.querySelectorAll('#parent span')
|
|
2511
|
-
expect(spans.length).toBe(2)
|
|
2512
|
-
})
|
|
2513
|
-
|
|
2514
|
-
test('component with single child merges as singular children prop', () => {
|
|
2515
|
-
const el = container()
|
|
2516
|
-
const Parent = defineComponent((props: { children?: VNodeChild }) =>
|
|
2517
|
-
h('div', { id: 'single' }, props.children),
|
|
2518
|
-
)
|
|
2519
|
-
mount(h(Parent, null, h('b', null, 'only')), el)
|
|
2520
|
-
expect(el.querySelector('#single b')?.textContent).toBe('only')
|
|
2521
|
-
})
|
|
2522
|
-
|
|
2523
|
-
test('component props.children already set — no merge', () => {
|
|
2524
|
-
const el = container()
|
|
2525
|
-
const Parent = defineComponent((props: { children?: VNodeChild }) =>
|
|
2526
|
-
h('div', { id: 'no-merge' }, props.children),
|
|
2527
|
-
)
|
|
2528
|
-
mount(h(Parent, { children: h('em', null, 'explicit') }, h('b', null, 'ignored')), el)
|
|
2529
|
-
expect(el.querySelector('#no-merge em')?.textContent).toBe('explicit')
|
|
2530
|
-
})
|
|
2531
|
-
|
|
2532
|
-
test('anonymous component name fallback', () => {
|
|
2533
|
-
const el = container()
|
|
2534
|
-
// Use an anonymous arrow function
|
|
2535
|
-
const comp = (() => h('span', null, 'anon')) as unknown as ReturnType<typeof defineComponent>
|
|
2536
|
-
mount(h(comp, null), el)
|
|
2537
|
-
expect(el.querySelector('span')?.textContent).toBe('anon')
|
|
2538
|
-
})
|
|
2539
|
-
})
|
|
2540
|
-
|
|
2541
|
-
// ─── props.ts — additional coverage ──────────────────────────────────────────
|
|
2542
|
-
|
|
2543
|
-
describe('props — additional coverage', () => {
|
|
2544
|
-
test('reactive prop via function', () => {
|
|
2545
|
-
const el = container()
|
|
2546
|
-
const title = signal('hello')
|
|
2547
|
-
mount(h('div', { title: () => title() }), el)
|
|
2548
|
-
const div = el.querySelector('div') as HTMLElement
|
|
2549
|
-
expect(div.title).toBe('hello')
|
|
2550
|
-
title.set('world')
|
|
2551
|
-
expect(div.title).toBe('world')
|
|
2552
|
-
})
|
|
2553
|
-
|
|
2554
|
-
test('null value removes attribute', () => {
|
|
2555
|
-
const el = container()
|
|
2556
|
-
mount(h('div', { 'data-x': 'initial' }), el)
|
|
2557
|
-
const div = el.querySelector('div') as HTMLElement
|
|
2558
|
-
expect(div.getAttribute('data-x')).toBe('initial')
|
|
2559
|
-
})
|
|
2560
|
-
|
|
2561
|
-
test('key and ref props are skipped in applyProps', () => {
|
|
2562
|
-
const el = container()
|
|
2563
|
-
const ref = createRef<HTMLDivElement>()
|
|
2564
|
-
mount(h('div', { key: 'k', ref, 'data-test': 'yes' }), el)
|
|
2565
|
-
const div = el.querySelector('div') as HTMLElement
|
|
2566
|
-
// key should not be an attribute
|
|
2567
|
-
expect(div.hasAttribute('key')).toBe(false)
|
|
2568
|
-
// ref should not be an attribute
|
|
2569
|
-
expect(div.hasAttribute('ref')).toBe(false)
|
|
2570
|
-
// data-test should be set
|
|
2571
|
-
expect(div.getAttribute('data-test')).toBe('yes')
|
|
2572
|
-
})
|
|
2573
|
-
|
|
2574
|
-
test('sanitizes javascript: in action attribute', () => {
|
|
2575
|
-
const el = container()
|
|
2576
|
-
mount(h('form', { action: 'javascript:void(0)' }), el)
|
|
2577
|
-
const form = el.querySelector('form') as HTMLFormElement
|
|
2578
|
-
expect(form.getAttribute('action')).not.toBe('javascript:void(0)')
|
|
2579
|
-
})
|
|
2580
|
-
|
|
2581
|
-
test('sanitizes data: in formaction', () => {
|
|
2582
|
-
const el = container()
|
|
2583
|
-
mount(h('button', { formaction: 'data:text/html,<script>alert(1)</script>' }), el)
|
|
2584
|
-
const btn = el.querySelector('button') as HTMLButtonElement
|
|
2585
|
-
expect(btn.getAttribute('formaction')).not.toBe('data:text/html,<script>alert(1)</script>')
|
|
2586
|
-
})
|
|
2587
|
-
|
|
2588
|
-
test('sanitizes javascript: with leading whitespace', () => {
|
|
2589
|
-
const el = container()
|
|
2590
|
-
mount(h('a', { href: ' javascript:alert(1)' }), el)
|
|
2591
|
-
const a = el.querySelector('a') as HTMLAnchorElement
|
|
2592
|
-
expect(a.getAttribute('href')).not.toBe(' javascript:alert(1)')
|
|
2593
|
-
})
|
|
2594
|
-
|
|
2595
|
-
test('sanitizeHtml preserves safe tags', async () => {
|
|
2596
|
-
const result = sanitizeHtml('<b>bold</b><em>italic</em>')
|
|
2597
|
-
expect(result).toContain('<b>bold</b>')
|
|
2598
|
-
expect(result).toContain('<em>italic</em>')
|
|
2599
|
-
})
|
|
2600
|
-
|
|
2601
|
-
test('sanitizeHtml strips script tags', async () => {
|
|
2602
|
-
const result = sanitizeHtml('<div>safe</div><script>alert(1)</script>')
|
|
2603
|
-
expect(result).toContain('safe')
|
|
2604
|
-
expect(result).not.toContain('<script>')
|
|
2605
|
-
})
|
|
2606
|
-
|
|
2607
|
-
test('sanitizeHtml strips event handler attributes', async () => {
|
|
2608
|
-
const result = sanitizeHtml('<div onclick="alert(1)">hello</div>')
|
|
2609
|
-
expect(result).toContain('hello')
|
|
2610
|
-
expect(result).not.toContain('onclick')
|
|
2611
|
-
})
|
|
2612
|
-
|
|
2613
|
-
test('sanitizeHtml strips javascript: URLs', async () => {
|
|
2614
|
-
const result = sanitizeHtml('<a href="javascript:alert(1)">link</a>')
|
|
2615
|
-
expect(result).not.toContain('javascript:')
|
|
2616
|
-
})
|
|
2617
|
-
|
|
2618
|
-
test('setSanitizer overrides built-in sanitizer', async () => {
|
|
2619
|
-
// Set custom sanitizer that uppercases everything
|
|
2620
|
-
setSanitizer((html: string) => html.toUpperCase())
|
|
2621
|
-
expect(sanitizeHtml('<b>hello</b>')).toBe('<B>HELLO</B>')
|
|
2622
|
-
// Reset to built-in
|
|
2623
|
-
setSanitizer(null)
|
|
2624
|
-
// Built-in should work again
|
|
2625
|
-
const result = sanitizeHtml('<b>safe</b><script>bad</script>')
|
|
2626
|
-
expect(result).toContain('safe')
|
|
2627
|
-
expect(result).not.toContain('<script>')
|
|
2628
|
-
})
|
|
2629
|
-
|
|
2630
|
-
test('sanitizeHtml strips iframe and object tags', async () => {
|
|
2631
|
-
const result = sanitizeHtml(
|
|
2632
|
-
'<div>ok</div><iframe src="evil"></iframe><object data="x"></object>',
|
|
2633
|
-
)
|
|
2634
|
-
expect(result).toContain('ok')
|
|
2635
|
-
expect(result).not.toContain('<iframe')
|
|
2636
|
-
expect(result).not.toContain('<object')
|
|
2637
|
-
})
|
|
2638
|
-
|
|
2639
|
-
test('sanitizeHtml handles nested unsafe elements', async () => {
|
|
2640
|
-
const result = sanitizeHtml('<div><script><script>alert(1)</script></script></div>')
|
|
2641
|
-
expect(result).not.toContain('<script')
|
|
2642
|
-
})
|
|
2643
|
-
|
|
2644
|
-
test('DOM property for known properties like value', () => {
|
|
2645
|
-
const el = container()
|
|
2646
|
-
mount(h('input', { value: 'test', type: 'text' }), el)
|
|
2647
|
-
const input = el.querySelector('input') as HTMLInputElement
|
|
2648
|
-
expect(input.value).toBe('test')
|
|
2649
|
-
})
|
|
2650
|
-
|
|
2651
|
-
test('setAttribute fallback for unknown attributes', () => {
|
|
2652
|
-
const el = container()
|
|
2653
|
-
mount(h('div', { 'aria-label': 'test label' }), el)
|
|
2654
|
-
const div = el.querySelector('div') as HTMLElement
|
|
2655
|
-
expect(div.getAttribute('aria-label')).toBe('test label')
|
|
2656
|
-
})
|
|
2657
|
-
})
|
|
2658
|
-
|
|
2659
|
-
// ─── DevTools ────────────────────────────────────────────────────────────────
|
|
2660
|
-
|
|
2661
|
-
describe('DevTools', () => {
|
|
2662
|
-
test('installDevTools sets __PYREON_DEVTOOLS__ on window', async () => {
|
|
2663
|
-
installDevTools()
|
|
2664
|
-
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as Record<
|
|
2665
|
-
string,
|
|
2666
|
-
unknown
|
|
2667
|
-
>
|
|
2668
|
-
expect(devtools).not.toBeNull()
|
|
2669
|
-
expect(devtools.version).toBe('0.1.0')
|
|
2670
|
-
})
|
|
2671
|
-
|
|
2672
|
-
test('registerComponent and getAllComponents', async () => {
|
|
2673
|
-
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
2674
|
-
getAllComponents: () => {
|
|
2675
|
-
id: string
|
|
2676
|
-
name: string
|
|
2677
|
-
parentId: string | null
|
|
2678
|
-
childIds: string[]
|
|
2679
|
-
}[]
|
|
2680
|
-
getComponentTree: () => { id: string; name: string; parentId: string | null }[]
|
|
2681
|
-
highlight: (id: string) => void
|
|
2682
|
-
onComponentMount: (cb: (entry: { id: string }) => void) => () => void
|
|
2683
|
-
onComponentUnmount: (cb: (id: string) => void) => () => void
|
|
2684
|
-
}
|
|
2685
|
-
|
|
2686
|
-
registerComponent('test-1', 'TestComp', null, null)
|
|
2687
|
-
const all = devtools.getAllComponents()
|
|
2688
|
-
const found = all.find((c: { id: string }) => c.id === 'test-1')
|
|
2689
|
-
expect(found).not.toBeUndefined()
|
|
2690
|
-
expect(found?.name).toBe('TestComp')
|
|
2691
|
-
|
|
2692
|
-
// Cleanup
|
|
2693
|
-
unregisterComponent('test-1')
|
|
2694
|
-
})
|
|
2695
|
-
|
|
2696
|
-
test('registerComponent with parentId creates parent-child relationship', async () => {
|
|
2697
|
-
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
2698
|
-
getAllComponents: () => {
|
|
2699
|
-
id: string
|
|
2700
|
-
name: string
|
|
2701
|
-
parentId: string | null
|
|
2702
|
-
childIds: string[]
|
|
2703
|
-
}[]
|
|
2704
|
-
}
|
|
2705
|
-
|
|
2706
|
-
registerComponent('parent-1', 'Parent', null, null)
|
|
2707
|
-
registerComponent('child-1', 'Child', null, 'parent-1')
|
|
2708
|
-
|
|
2709
|
-
const parent = devtools.getAllComponents().find((c: { id: string }) => c.id === 'parent-1')
|
|
2710
|
-
expect(parent?.childIds).toContain('child-1')
|
|
2711
|
-
|
|
2712
|
-
unregisterComponent('child-1')
|
|
2713
|
-
const parentAfter = devtools.getAllComponents().find((c: { id: string }) => c.id === 'parent-1')
|
|
2714
|
-
expect(parentAfter?.childIds).not.toContain('child-1')
|
|
2715
|
-
|
|
2716
|
-
unregisterComponent('parent-1')
|
|
2717
|
-
})
|
|
2718
|
-
|
|
2719
|
-
test('getComponentTree returns only root components', async () => {
|
|
2720
|
-
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
2721
|
-
getComponentTree: () => { id: string; parentId: string | null }[]
|
|
2722
|
-
}
|
|
2723
|
-
|
|
2724
|
-
registerComponent('root-1', 'Root', null, null)
|
|
2725
|
-
registerComponent('sub-1', 'Sub', null, 'root-1')
|
|
2726
|
-
|
|
2727
|
-
const tree = devtools.getComponentTree()
|
|
2728
|
-
const rootInTree = tree.find((c: { id: string }) => c.id === 'root-1')
|
|
2729
|
-
const subInTree = tree.find((c: { id: string }) => c.id === 'sub-1')
|
|
2730
|
-
expect(rootInTree).not.toBeUndefined()
|
|
2731
|
-
expect(subInTree).toBeUndefined() // sub is not root
|
|
2732
|
-
|
|
2733
|
-
unregisterComponent('sub-1')
|
|
2734
|
-
unregisterComponent('root-1')
|
|
2735
|
-
})
|
|
2736
|
-
|
|
2737
|
-
test('highlight adds and removes outline', async () => {
|
|
2738
|
-
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
2739
|
-
highlight: (id: string) => void
|
|
2740
|
-
}
|
|
2741
|
-
const el = document.createElement('div')
|
|
2742
|
-
document.body.appendChild(el)
|
|
2743
|
-
registerComponent('hl-1', 'Highlight', el, null)
|
|
2744
|
-
devtools.highlight('hl-1')
|
|
2745
|
-
expect(el.style.outline).toContain('#00b4d8')
|
|
2746
|
-
|
|
2747
|
-
// Highlight non-existent — should not throw
|
|
2748
|
-
devtools.highlight('non-existent')
|
|
2749
|
-
|
|
2750
|
-
unregisterComponent('hl-1')
|
|
2751
|
-
el.remove()
|
|
2752
|
-
})
|
|
2753
|
-
|
|
2754
|
-
test('onComponentMount and onComponentUnmount listeners', async () => {
|
|
2755
|
-
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
2756
|
-
onComponentMount: (cb: (entry: { id: string; name: string }) => void) => () => void
|
|
2757
|
-
onComponentUnmount: (cb: (id: string) => void) => () => void
|
|
2758
|
-
}
|
|
2759
|
-
|
|
2760
|
-
const mountedIds: string[] = []
|
|
2761
|
-
const unmountedIds: string[] = []
|
|
2762
|
-
const unsubMount = devtools.onComponentMount((entry) => mountedIds.push(entry.id))
|
|
2763
|
-
const unsubUnmount = devtools.onComponentUnmount((id) => unmountedIds.push(id))
|
|
2764
|
-
|
|
2765
|
-
registerComponent('listen-1', 'ListenComp', null, null)
|
|
2766
|
-
expect(mountedIds).toContain('listen-1')
|
|
2767
|
-
|
|
2768
|
-
unregisterComponent('listen-1')
|
|
2769
|
-
expect(unmountedIds).toContain('listen-1')
|
|
2770
|
-
|
|
2771
|
-
// Unsub and verify listeners are removed
|
|
2772
|
-
unsubMount()
|
|
2773
|
-
unsubUnmount()
|
|
2774
|
-
registerComponent('listen-2', 'ListenComp2', null, null)
|
|
2775
|
-
expect(mountedIds).not.toContain('listen-2')
|
|
2776
|
-
unregisterComponent('listen-2')
|
|
2777
|
-
expect(unmountedIds).not.toContain('listen-2')
|
|
2778
|
-
})
|
|
2779
|
-
|
|
2780
|
-
test('unregisterComponent is noop for unknown id', async () => {
|
|
2781
|
-
// Should not throw
|
|
2782
|
-
unregisterComponent('does-not-exist')
|
|
2783
|
-
})
|
|
2784
|
-
|
|
2785
|
-
test('highlight with no el is noop', async () => {
|
|
2786
|
-
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
2787
|
-
highlight: (id: string) => void
|
|
2788
|
-
}
|
|
2789
|
-
registerComponent('no-el', 'NoEl', null, null)
|
|
2790
|
-
// Should not throw
|
|
2791
|
-
devtools.highlight('no-el')
|
|
2792
|
-
unregisterComponent('no-el')
|
|
2793
|
-
})
|
|
2794
|
-
})
|
|
2795
|
-
|
|
2796
|
-
// ─── TransitionGroup ─────────────────────────────────────────────────────────
|
|
2797
|
-
|
|
2798
|
-
describe('TransitionGroup', () => {
|
|
2799
|
-
test('renders container element with specified tag', async () => {
|
|
2800
|
-
const el = container()
|
|
2801
|
-
const items = signal([{ id: 1 }, { id: 2 }])
|
|
2802
|
-
mount(
|
|
2803
|
-
h(TransitionGroup, {
|
|
2804
|
-
tag: 'ul',
|
|
2805
|
-
name: 'list',
|
|
2806
|
-
items,
|
|
2807
|
-
keyFn: (item: { id: number }) => item.id,
|
|
2808
|
-
render: (item: { id: number }) => h('li', null, String(item.id)),
|
|
2809
|
-
}),
|
|
2810
|
-
el,
|
|
2811
|
-
)
|
|
2812
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
2813
|
-
expect(el.querySelector('ul')).not.toBeNull()
|
|
2814
|
-
})
|
|
2815
|
-
|
|
2816
|
-
test('renders initial items', async () => {
|
|
2817
|
-
const el = container()
|
|
2818
|
-
const items = signal([{ id: 1 }, { id: 2 }, { id: 3 }])
|
|
2819
|
-
mount(
|
|
2820
|
-
h(TransitionGroup, {
|
|
2821
|
-
tag: 'div',
|
|
2822
|
-
items,
|
|
2823
|
-
keyFn: (item: { id: number }) => item.id,
|
|
2824
|
-
render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
|
|
2825
|
-
}),
|
|
2826
|
-
el,
|
|
2827
|
-
)
|
|
2828
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
2829
|
-
const spans = el.querySelectorAll('span.item')
|
|
2830
|
-
expect(spans.length).toBe(3)
|
|
2831
|
-
expect(spans[0]?.textContent).toBe('1')
|
|
2832
|
-
expect(spans[2]?.textContent).toBe('3')
|
|
2833
|
-
})
|
|
2834
|
-
|
|
2835
|
-
test('adding items triggers enter animation', async () => {
|
|
2836
|
-
const el = container()
|
|
2837
|
-
const items = signal([{ id: 1 }])
|
|
2838
|
-
mount(
|
|
2839
|
-
h(TransitionGroup, {
|
|
2840
|
-
tag: 'div',
|
|
2841
|
-
name: 'fade',
|
|
2842
|
-
items,
|
|
2843
|
-
keyFn: (item: { id: number }) => item.id,
|
|
2844
|
-
render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
|
|
2845
|
-
}),
|
|
2846
|
-
el,
|
|
2847
|
-
)
|
|
2848
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
2849
|
-
expect(el.querySelectorAll('span.item').length).toBe(1)
|
|
2850
|
-
|
|
2851
|
-
// Add a new item
|
|
2852
|
-
items.set([{ id: 1 }, { id: 2 }])
|
|
2853
|
-
// Wait for the microtask (enter animation scheduled via queueMicrotask)
|
|
2854
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
2855
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
2856
|
-
expect(el.querySelectorAll('span.item').length).toBe(2)
|
|
2857
|
-
})
|
|
2858
|
-
|
|
2859
|
-
test('removing items keeps element during leave animation', async () => {
|
|
2860
|
-
const el = container()
|
|
2861
|
-
const items = signal([{ id: 1 }, { id: 2 }])
|
|
2862
|
-
mount(
|
|
2863
|
-
h(TransitionGroup, {
|
|
2864
|
-
tag: 'div',
|
|
2865
|
-
name: 'fade',
|
|
2866
|
-
items,
|
|
2867
|
-
keyFn: (item: { id: number }) => item.id,
|
|
2868
|
-
render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
|
|
2869
|
-
}),
|
|
2870
|
-
el,
|
|
2871
|
-
)
|
|
2872
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
2873
|
-
expect(el.querySelectorAll('span.item').length).toBe(2)
|
|
2874
|
-
|
|
2875
|
-
// Remove item 2
|
|
2876
|
-
items.set([{ id: 1 }])
|
|
2877
|
-
// Element should still be in DOM during leave animation
|
|
2878
|
-
expect(el.querySelectorAll('span.item').length).toBeGreaterThanOrEqual(1)
|
|
2879
|
-
})
|
|
2880
|
-
|
|
2881
|
-
test('default tag is div and default name is pyreon', async () => {
|
|
2882
|
-
const el = container()
|
|
2883
|
-
const items = signal([{ id: 1 }])
|
|
2884
|
-
mount(
|
|
2885
|
-
h(TransitionGroup, {
|
|
2886
|
-
items,
|
|
2887
|
-
keyFn: (item: { id: number }) => item.id,
|
|
2888
|
-
render: (item: { id: number }) => h('span', null, String(item.id)),
|
|
2889
|
-
}),
|
|
2890
|
-
el,
|
|
2891
|
-
)
|
|
2892
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
2893
|
-
// Default tag is div
|
|
2894
|
-
expect(el.querySelector('div')).not.toBeNull()
|
|
2895
|
-
})
|
|
2896
|
-
|
|
2897
|
-
test('appear option triggers enter on initial mount', async () => {
|
|
2898
|
-
const el = container()
|
|
2899
|
-
let enterCalled = false
|
|
2900
|
-
const items = signal([{ id: 1 }])
|
|
2901
|
-
mount(
|
|
2902
|
-
h(TransitionGroup, {
|
|
2903
|
-
tag: 'div',
|
|
2904
|
-
name: 'test',
|
|
2905
|
-
appear: true,
|
|
2906
|
-
items,
|
|
2907
|
-
keyFn: (item: { id: number }) => item.id,
|
|
2908
|
-
render: (item: { id: number }) => h('span', { class: 'appear-item' }, String(item.id)),
|
|
2909
|
-
onBeforeEnter: () => {
|
|
2910
|
-
enterCalled = true
|
|
2911
|
-
},
|
|
2912
|
-
}),
|
|
2913
|
-
el,
|
|
2914
|
-
)
|
|
2915
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
2916
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
2917
|
-
expect(enterCalled).toBe(true)
|
|
2918
|
-
})
|
|
2919
|
-
|
|
2920
|
-
test('custom class name overrides', async () => {
|
|
2921
|
-
const el = container()
|
|
2922
|
-
const items = signal([{ id: 1 }])
|
|
2923
|
-
mount(
|
|
2924
|
-
h(TransitionGroup, {
|
|
2925
|
-
tag: 'div',
|
|
2926
|
-
enterFrom: 'my-enter-from',
|
|
2927
|
-
enterActive: 'my-enter-active',
|
|
2928
|
-
enterTo: 'my-enter-to',
|
|
2929
|
-
leaveFrom: 'my-leave-from',
|
|
2930
|
-
leaveActive: 'my-leave-active',
|
|
2931
|
-
leaveTo: 'my-leave-to',
|
|
2932
|
-
moveClass: 'my-move',
|
|
2933
|
-
items,
|
|
2934
|
-
keyFn: (item: { id: number }) => item.id,
|
|
2935
|
-
render: (item: { id: number }) => h('span', null, String(item.id)),
|
|
2936
|
-
}),
|
|
2937
|
-
el,
|
|
2938
|
-
)
|
|
2939
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
2940
|
-
expect(el.querySelectorAll('span').length).toBe(1)
|
|
2941
|
-
})
|
|
2942
|
-
|
|
2943
|
-
test('leave callback with no ref.current removes entry immediately', async () => {
|
|
2944
|
-
const el = container()
|
|
2945
|
-
const items = signal([{ id: 1 }, { id: 2 }])
|
|
2946
|
-
mount(
|
|
2947
|
-
h(TransitionGroup, {
|
|
2948
|
-
tag: 'div',
|
|
2949
|
-
items,
|
|
2950
|
-
keyFn: (item: { id: number }) => item.id,
|
|
2951
|
-
// Return a non-element VNode (component VNode) so ref won't be injected
|
|
2952
|
-
render: (item: { id: number }) => {
|
|
2953
|
-
const Comp = () => h('span', null, String(item.id))
|
|
2954
|
-
return h(Comp, null) as unknown as ReturnType<typeof h>
|
|
2955
|
-
},
|
|
2956
|
-
}),
|
|
2957
|
-
el,
|
|
2958
|
-
)
|
|
2959
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
2960
|
-
// Remove an item — since ref.current will be null for component VNodes,
|
|
2961
|
-
// the entry is cleaned up immediately
|
|
2962
|
-
items.set([{ id: 1 }])
|
|
2963
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
2964
|
-
})
|
|
2965
|
-
|
|
2966
|
-
test('reorder triggers move animation setup', async () => {
|
|
2967
|
-
const el = container()
|
|
2968
|
-
const items = signal([{ id: 1 }, { id: 2 }, { id: 3 }])
|
|
2969
|
-
mount(
|
|
2970
|
-
h(TransitionGroup, {
|
|
2971
|
-
tag: 'div',
|
|
2972
|
-
name: 'list',
|
|
2973
|
-
items,
|
|
2974
|
-
keyFn: (item: { id: number }) => item.id,
|
|
2975
|
-
render: (item: { id: number }) => h('span', { class: 'reorder-item' }, String(item.id)),
|
|
2976
|
-
}),
|
|
2977
|
-
el,
|
|
2978
|
-
)
|
|
2979
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
2980
|
-
expect(el.querySelectorAll('span.reorder-item').length).toBe(3)
|
|
2981
|
-
|
|
2982
|
-
// Reorder items
|
|
2983
|
-
items.set([{ id: 3 }, { id: 1 }, { id: 2 }])
|
|
2984
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
2985
|
-
|
|
2986
|
-
// Items should be reordered
|
|
2987
|
-
const spans = el.querySelectorAll('span.reorder-item')
|
|
2988
|
-
expect(spans[0]?.textContent).toBe('3')
|
|
2989
|
-
expect(spans[1]?.textContent).toBe('1')
|
|
2990
|
-
expect(spans[2]?.textContent).toBe('2')
|
|
2991
|
-
})
|
|
2992
|
-
|
|
2993
|
-
test('onAfterEnter callback fires after enter transition', async () => {
|
|
2994
|
-
const el = container()
|
|
2995
|
-
let afterEnterCalled = false
|
|
2996
|
-
const items = signal<{ id: number }[]>([])
|
|
2997
|
-
mount(
|
|
2998
|
-
h(TransitionGroup, {
|
|
2999
|
-
tag: 'div',
|
|
3000
|
-
name: 'fade',
|
|
3001
|
-
items,
|
|
3002
|
-
keyFn: (item: { id: number }) => item.id,
|
|
3003
|
-
render: (item: { id: number }) => h('span', null, String(item.id)),
|
|
3004
|
-
onAfterEnter: () => {
|
|
3005
|
-
afterEnterCalled = true
|
|
3006
|
-
},
|
|
3007
|
-
}),
|
|
3008
|
-
el,
|
|
3009
|
-
)
|
|
3010
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
3011
|
-
|
|
3012
|
-
// Add item (not first run, so enter animation triggers)
|
|
3013
|
-
items.set([{ id: 1 }])
|
|
3014
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
3015
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
3016
|
-
|
|
3017
|
-
// Trigger rAF and transitionend
|
|
3018
|
-
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
3019
|
-
const span = el.querySelector('span')
|
|
3020
|
-
if (span) {
|
|
3021
|
-
span.dispatchEvent(new Event('transitionend'))
|
|
3022
|
-
}
|
|
3023
|
-
expect(afterEnterCalled).toBe(true)
|
|
3024
|
-
})
|
|
3025
|
-
|
|
3026
|
-
test('onBeforeLeave and onAfterLeave callbacks fire', async () => {
|
|
3027
|
-
const el = container()
|
|
3028
|
-
let beforeLeaveCalled = false
|
|
3029
|
-
const items = signal([{ id: 1 }])
|
|
3030
|
-
mount(
|
|
3031
|
-
h(TransitionGroup, {
|
|
3032
|
-
tag: 'div',
|
|
3033
|
-
name: 'fade',
|
|
3034
|
-
items,
|
|
3035
|
-
keyFn: (item: { id: number }) => item.id,
|
|
3036
|
-
render: (item: { id: number }) => h('span', { class: 'leave-item' }, String(item.id)),
|
|
3037
|
-
onBeforeLeave: () => {
|
|
3038
|
-
beforeLeaveCalled = true
|
|
3039
|
-
},
|
|
3040
|
-
onAfterLeave: () => {
|
|
3041
|
-
/* after leave tracked */
|
|
3042
|
-
},
|
|
3043
|
-
}),
|
|
3044
|
-
el,
|
|
3045
|
-
)
|
|
3046
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
3047
|
-
|
|
3048
|
-
// Remove item
|
|
3049
|
-
items.set([])
|
|
3050
|
-
expect(beforeLeaveCalled).toBe(true)
|
|
3051
|
-
|
|
3052
|
-
// Trigger leave animation completion
|
|
3053
|
-
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
3054
|
-
const span = el.querySelector('span.leave-item')
|
|
3055
|
-
if (span) {
|
|
3056
|
-
span.dispatchEvent(new Event('transitionend'))
|
|
3057
|
-
}
|
|
3058
|
-
// afterLeave fires inside rAF callback, might need another tick
|
|
3059
|
-
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
3060
|
-
if (span) {
|
|
3061
|
-
span.dispatchEvent(new Event('transitionend'))
|
|
3062
|
-
}
|
|
3063
|
-
})
|
|
3064
|
-
})
|
|
3065
|
-
|
|
3066
|
-
// ─── Hydration debug ────────────────────────────────────────────────────────
|
|
3067
|
-
|
|
3068
|
-
describe('hydration warnings', () => {
|
|
3069
|
-
test('enableHydrationWarnings and disableHydrationWarnings', async () => {
|
|
3070
|
-
// Should not throw
|
|
3071
|
-
enableHydrationWarnings()
|
|
3072
|
-
disableHydrationWarnings()
|
|
3073
|
-
enableHydrationWarnings() // re-enable for other tests
|
|
3074
|
-
})
|
|
3075
|
-
})
|
|
3076
|
-
|
|
3077
|
-
// ─── Additional hydrate.ts branch coverage ───────────────────────────────────
|
|
3078
|
-
|
|
3079
|
-
describe('hydrateRoot — branch coverage', () => {
|
|
3080
|
-
test('hydrates raw array child (non-Fragment array path)', async () => {
|
|
3081
|
-
const el = container()
|
|
3082
|
-
el.innerHTML = '<span>a</span><span>b</span>'
|
|
3083
|
-
// Pass an array directly — hits the Array.isArray branch in hydrateChild
|
|
3084
|
-
const cleanup = hydrateRoot(el, [h('span', null, 'a'), h('span', null, 'b')])
|
|
3085
|
-
expect(el.querySelectorAll('span').length).toBe(2)
|
|
3086
|
-
cleanup()
|
|
3087
|
-
})
|
|
3088
|
-
|
|
3089
|
-
test('hydrates For with SSR markers (start/end comment pair)', async () => {
|
|
3090
|
-
const el = container()
|
|
3091
|
-
el.innerHTML = '<div><!--pyreon-for--><li>item1</li><li>item2</li><!--/pyreon-for--></div>'
|
|
3092
|
-
const items = signal([
|
|
3093
|
-
{ id: 1, label: 'item1' },
|
|
3094
|
-
{ id: 2, label: 'item2' },
|
|
3095
|
-
])
|
|
3096
|
-
const cleanup = hydrateRoot(
|
|
3097
|
-
el,
|
|
3098
|
-
h(
|
|
3099
|
-
'div',
|
|
3100
|
-
null,
|
|
3101
|
-
For({
|
|
3102
|
-
each: items,
|
|
3103
|
-
by: (r: { id: number }) => r.id,
|
|
3104
|
-
children: (r: { id: number; label: string }) => h('li', null, r.label),
|
|
3105
|
-
}),
|
|
3106
|
-
),
|
|
3107
|
-
)
|
|
3108
|
-
cleanup()
|
|
3109
|
-
})
|
|
3110
|
-
|
|
3111
|
-
test('hydrates reactive accessor returning null with no domNode', async () => {
|
|
3112
|
-
const el = container()
|
|
3113
|
-
el.innerHTML = '<div></div>'
|
|
3114
|
-
const show = signal<VNodeChild>(null)
|
|
3115
|
-
// The div has no children, so domNode will be null inside
|
|
3116
|
-
const cleanup = hydrateRoot(el, h('div', null, (() => show()) as unknown as VNodeChild))
|
|
3117
|
-
show.set('hello')
|
|
3118
|
-
cleanup()
|
|
3119
|
-
})
|
|
3120
|
-
|
|
3121
|
-
test('hydrates reactive VNode accessor with marker when no domNode', async () => {
|
|
3122
|
-
const el = container()
|
|
3123
|
-
el.innerHTML = '<div></div>'
|
|
3124
|
-
const content = signal<VNodeChild>(h('span', null, 'initial'))
|
|
3125
|
-
const cleanup = hydrateRoot(el, h('div', null, (() => content()) as unknown as VNodeChild))
|
|
3126
|
-
cleanup()
|
|
3127
|
-
})
|
|
3128
|
-
|
|
3129
|
-
test('hydrates unknown symbol vnode type — returns noop', async () => {
|
|
3130
|
-
const el = container()
|
|
3131
|
-
el.innerHTML = '<div></div>'
|
|
3132
|
-
const weirdVNode = { type: Symbol('weird'), props: {}, children: [], key: null }
|
|
3133
|
-
const cleanup = hydrateRoot(el, h('div', null, weirdVNode as VNodeChild))
|
|
3134
|
-
cleanup()
|
|
3135
|
-
})
|
|
3136
|
-
|
|
3137
|
-
test('hydration of text that matches existing text node — cleanup removes it', async () => {
|
|
3138
|
-
const el = container()
|
|
3139
|
-
el.innerHTML = 'hello'
|
|
3140
|
-
const cleanup = hydrateRoot(el, 'hello')
|
|
3141
|
-
expect(el.textContent).toBe('hello')
|
|
3142
|
-
cleanup()
|
|
3143
|
-
})
|
|
3144
|
-
|
|
3145
|
-
test('For with no SSR markers and domNode present', async () => {
|
|
3146
|
-
const el = container()
|
|
3147
|
-
el.innerHTML = '<div><span>existing</span></div>'
|
|
3148
|
-
const items = signal([{ id: 1, label: 'a' }])
|
|
3149
|
-
const cleanup = hydrateRoot(
|
|
3150
|
-
el,
|
|
3151
|
-
h(
|
|
3152
|
-
'div',
|
|
3153
|
-
null,
|
|
3154
|
-
For({
|
|
3155
|
-
each: items,
|
|
3156
|
-
by: (r: { id: number }) => r.id,
|
|
3157
|
-
children: (r: { id: number; label: string }) => h('li', null, r.label),
|
|
3158
|
-
}),
|
|
3159
|
-
),
|
|
3160
|
-
)
|
|
3161
|
-
cleanup()
|
|
3162
|
-
})
|
|
3163
|
-
|
|
3164
|
-
test('For with no SSR markers and no domNode', async () => {
|
|
3165
|
-
const el = container()
|
|
3166
|
-
el.innerHTML = '<div></div>'
|
|
3167
|
-
const items = signal([{ id: 1, label: 'a' }])
|
|
3168
|
-
const cleanup = hydrateRoot(
|
|
3169
|
-
el,
|
|
3170
|
-
h(
|
|
3171
|
-
'div',
|
|
3172
|
-
null,
|
|
3173
|
-
For({
|
|
3174
|
-
each: items,
|
|
3175
|
-
by: (r: { id: number }) => r.id,
|
|
3176
|
-
children: (r: { id: number; label: string }) => h('li', null, r.label),
|
|
3177
|
-
}),
|
|
3178
|
-
),
|
|
3179
|
-
)
|
|
3180
|
-
cleanup()
|
|
3181
|
-
})
|
|
3182
|
-
|
|
3183
|
-
test('reactive accessor returning null when domNode exists', async () => {
|
|
3184
|
-
const el = container()
|
|
3185
|
-
// Put a real DOM node that will be domNode, but accessor returns null
|
|
3186
|
-
el.innerHTML = '<div><span>existing</span></div>'
|
|
3187
|
-
const show = signal<VNodeChild>(null)
|
|
3188
|
-
// The span is the domNode, but accessor returns null — hits line 91-92
|
|
3189
|
-
const cleanup = hydrateRoot(
|
|
3190
|
-
el,
|
|
3191
|
-
h('div', null, (() => show()) as unknown as VNodeChild, h('span', null, 'existing')),
|
|
3192
|
-
)
|
|
3193
|
-
cleanup()
|
|
3194
|
-
})
|
|
3195
|
-
})
|
|
3196
|
-
|
|
3197
|
-
// ─── mount.ts — error handling branches ──────────────────────────────────────
|
|
3198
|
-
|
|
3199
|
-
describe('mount — error handling branches', () => {
|
|
3200
|
-
test('mountChild with raw array', async () => {
|
|
3201
|
-
const el = container()
|
|
3202
|
-
// Pass an array directly to mountChild (line 72)
|
|
3203
|
-
const cleanup = mountChild([h('span', null, 'x'), h('span', null, 'y')], el, null)
|
|
3204
|
-
expect(el.querySelectorAll('span').length).toBe(2)
|
|
3205
|
-
cleanup()
|
|
3206
|
-
})
|
|
3207
|
-
|
|
3208
|
-
test('component subtree throw is caught', () => {
|
|
3209
|
-
const el = container()
|
|
3210
|
-
// A component whose render output itself causes an error during mount
|
|
3211
|
-
const BadRender = defineComponent(() => {
|
|
3212
|
-
// Return a VNode that includes a broken component child
|
|
3213
|
-
const Throws = defineComponent((): never => {
|
|
3214
|
-
throw new Error('subtree error')
|
|
3215
|
-
})
|
|
3216
|
-
return h(Throws, null)
|
|
3217
|
-
})
|
|
3218
|
-
// Should not throw — error is caught in mountComponent's subtree try/catch
|
|
3219
|
-
mount(h(BadRender, null), el)
|
|
3220
|
-
})
|
|
3221
|
-
|
|
3222
|
-
test('onMount hook that throws is caught', async () => {
|
|
3223
|
-
const el = container()
|
|
3224
|
-
const Comp = defineComponent(() => {
|
|
3225
|
-
onMount(() => {
|
|
3226
|
-
throw new Error('onMount error')
|
|
3227
|
-
})
|
|
3228
|
-
return h('div', null, 'content')
|
|
3229
|
-
})
|
|
3230
|
-
// Should not throw
|
|
3231
|
-
mount(h(Comp, null), el)
|
|
3232
|
-
expect(el.querySelector('div')?.textContent).toBe('content')
|
|
3233
|
-
})
|
|
3234
|
-
|
|
3235
|
-
test('onUnmount hook that throws is caught', async () => {
|
|
3236
|
-
const el = container()
|
|
3237
|
-
const Comp = defineComponent(() => {
|
|
3238
|
-
onUnmount(() => {
|
|
3239
|
-
throw new Error('onUnmount error')
|
|
3240
|
-
})
|
|
3241
|
-
return h('div', null, 'content')
|
|
3242
|
-
})
|
|
3243
|
-
const unmount = mount(h(Comp, null), el)
|
|
3244
|
-
// Should not throw
|
|
3245
|
-
unmount()
|
|
3246
|
-
})
|
|
3247
|
-
})
|
|
3248
|
-
|
|
3249
|
-
// ─── TransitionGroup — unmount cleanup ───────────────────────────────────────
|
|
3250
|
-
|
|
3251
|
-
describe('TransitionGroup — cleanup', () => {
|
|
3252
|
-
test('unmount disposes effect and cleans up entries', async () => {
|
|
3253
|
-
const el = container()
|
|
3254
|
-
const items = signal([{ id: 1 }, { id: 2 }])
|
|
3255
|
-
const unmount = mount(
|
|
3256
|
-
h(TransitionGroup, {
|
|
3257
|
-
tag: 'div',
|
|
3258
|
-
items,
|
|
3259
|
-
keyFn: (item: { id: number }) => item.id,
|
|
3260
|
-
render: (item: { id: number }) => h('span', null, String(item.id)),
|
|
3261
|
-
}),
|
|
3262
|
-
el,
|
|
3263
|
-
)
|
|
3264
|
-
await new Promise<void>((r) => queueMicrotask(r))
|
|
3265
|
-
expect(el.querySelectorAll('span').length).toBe(2)
|
|
3266
|
-
unmount()
|
|
3267
|
-
expect(el.innerHTML).toBe('')
|
|
3268
|
-
})
|
|
3269
|
-
})
|
|
3270
|
-
|
|
3271
|
-
// ─── TransitionGroup — leak regression tests ─────────────────────────────────
|
|
3272
|
-
// Regression for the two fixes:
|
|
3273
|
-
// 1. No safety timeout on applyLeave meant an item whose transition never
|
|
3274
|
-
// fired stayed in the `entries` Map forever (`entries.delete(key)` was
|
|
3275
|
-
// gated on the `done` callback firing).
|
|
3276
|
-
// 2. Unmount during in-flight transition left the 5s safety timer running,
|
|
3277
|
-
// firing `onAfterEnter` / `onAfterLeave` on detached elements.
|
|
3278
|
-
|
|
3279
|
-
describe('TransitionGroup — leak regressions', () => {
|
|
3280
|
-
beforeEach(() => {
|
|
3281
|
-
vi.useFakeTimers()
|
|
3282
|
-
})
|
|
3283
|
-
afterEach(() => {
|
|
3284
|
-
vi.useRealTimers()
|
|
3285
|
-
})
|
|
3286
|
-
|
|
3287
|
-
test('onAfterLeave fires via 5s safety timeout when transitionend never fires', async () => {
|
|
3288
|
-
const el = container()
|
|
3289
|
-
const items = signal([{ id: 1 }, { id: 2 }])
|
|
3290
|
-
const onAfterLeave = vi.fn()
|
|
3291
|
-
mount(
|
|
3292
|
-
h(TransitionGroup, {
|
|
3293
|
-
tag: 'div',
|
|
3294
|
-
name: 'fade',
|
|
3295
|
-
items,
|
|
3296
|
-
keyFn: (item: { id: number }) => item.id,
|
|
3297
|
-
render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
|
|
3298
|
-
onAfterLeave,
|
|
3299
|
-
}),
|
|
3300
|
-
el,
|
|
3301
|
-
)
|
|
3302
|
-
await vi.advanceTimersByTimeAsync(20)
|
|
3303
|
-
items.set([{ id: 1 }])
|
|
3304
|
-
await vi.advanceTimersByTimeAsync(20)
|
|
3305
|
-
// transitionend never fires — before the fix this would leak forever.
|
|
3306
|
-
expect(onAfterLeave).not.toHaveBeenCalled()
|
|
3307
|
-
await vi.advanceTimersByTimeAsync(5100)
|
|
3308
|
-
expect(onAfterLeave).toHaveBeenCalledTimes(1)
|
|
3309
|
-
})
|
|
3310
|
-
|
|
3311
|
-
test('onAfterEnter does NOT fire after container unmount during in-flight enter', async () => {
|
|
3312
|
-
const el = container()
|
|
3313
|
-
const items = signal<{ id: number }[]>([])
|
|
3314
|
-
const onAfterEnter = vi.fn()
|
|
3315
|
-
const dispose = mount(
|
|
3316
|
-
h(TransitionGroup, {
|
|
3317
|
-
tag: 'div',
|
|
3318
|
-
name: 'fade',
|
|
3319
|
-
items,
|
|
3320
|
-
keyFn: (item: { id: number }) => item.id,
|
|
3321
|
-
render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
|
|
3322
|
-
onAfterEnter,
|
|
3323
|
-
}),
|
|
3324
|
-
el,
|
|
3325
|
-
)
|
|
3326
|
-
await vi.advanceTimersByTimeAsync(20)
|
|
3327
|
-
items.set([{ id: 1 }])
|
|
3328
|
-
await vi.advanceTimersByTimeAsync(20)
|
|
3329
|
-
// Mid-transition — unmount. The 5s safety timer must NOT fire the
|
|
3330
|
-
// callback on a detached element.
|
|
3331
|
-
dispose()
|
|
3332
|
-
await vi.advanceTimersByTimeAsync(6000)
|
|
3333
|
-
expect(onAfterEnter).not.toHaveBeenCalled()
|
|
3334
|
-
})
|
|
3335
|
-
|
|
3336
|
-
test('onAfterLeave does NOT fire after container unmount during in-flight leave', async () => {
|
|
3337
|
-
const el = container()
|
|
3338
|
-
const items = signal([{ id: 1 }])
|
|
3339
|
-
const onAfterLeave = vi.fn()
|
|
3340
|
-
const dispose = mount(
|
|
3341
|
-
h(TransitionGroup, {
|
|
3342
|
-
tag: 'div',
|
|
3343
|
-
name: 'fade',
|
|
3344
|
-
items,
|
|
3345
|
-
keyFn: (item: { id: number }) => item.id,
|
|
3346
|
-
render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
|
|
3347
|
-
onAfterLeave,
|
|
3348
|
-
}),
|
|
3349
|
-
el,
|
|
3350
|
-
)
|
|
3351
|
-
await vi.advanceTimersByTimeAsync(20)
|
|
3352
|
-
items.set([])
|
|
3353
|
-
await vi.advanceTimersByTimeAsync(20)
|
|
3354
|
-
dispose()
|
|
3355
|
-
await vi.advanceTimersByTimeAsync(6000)
|
|
3356
|
-
expect(onAfterLeave).not.toHaveBeenCalled()
|
|
3357
|
-
})
|
|
3358
|
-
})
|
|
3359
|
-
|
|
3360
|
-
// ─── Error paths (no ErrorBoundary) ──────────────────────────────────────────
|
|
3361
|
-
|
|
3362
|
-
describe('mount — error paths', () => {
|
|
3363
|
-
test('component that throws during setup fires console.error', () => {
|
|
3364
|
-
const el = container()
|
|
3365
|
-
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
3366
|
-
|
|
3367
|
-
const Broken: ComponentFn = () => {
|
|
3368
|
-
throw new Error('setup boom')
|
|
3369
|
-
}
|
|
3370
|
-
mount(h(Broken, null), el)
|
|
3371
|
-
|
|
3372
|
-
expect(spy).toHaveBeenCalledWith(
|
|
3373
|
-
expect.stringContaining('threw during setup'),
|
|
3374
|
-
expect.any(Error),
|
|
3375
|
-
)
|
|
3376
|
-
spy.mockRestore()
|
|
3377
|
-
})
|
|
3378
|
-
|
|
3379
|
-
test('component that throws during render fires console.error', () => {
|
|
3380
|
-
const el = container()
|
|
3381
|
-
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
3382
|
-
|
|
3383
|
-
const BrokenChild: ComponentFn = () => {
|
|
3384
|
-
throw new Error('render boom')
|
|
3385
|
-
}
|
|
3386
|
-
// Parent returns a child that throws when mounted (render phase)
|
|
3387
|
-
const Parent: ComponentFn = () => h(BrokenChild, null)
|
|
3388
|
-
mount(h(Parent, null), el)
|
|
3389
|
-
|
|
3390
|
-
expect(spy).toHaveBeenCalledWith(expect.stringContaining('threw during'), expect.any(Error))
|
|
3391
|
-
spy.mockRestore()
|
|
3392
|
-
})
|
|
3393
|
-
})
|
|
3394
|
-
|
|
3395
|
-
// ─── SVG namespace ──────────────────────────────────────────────────────────
|
|
3396
|
-
|
|
3397
|
-
describe('mount — SVG namespace', () => {
|
|
3398
|
-
const SVG_NS = 'http://www.w3.org/2000/svg'
|
|
3399
|
-
|
|
3400
|
-
test('svg element gets SVG namespace', () => {
|
|
3401
|
-
const el = container()
|
|
3402
|
-
mount(h('svg', null), el)
|
|
3403
|
-
const svg = el.firstElementChild!
|
|
3404
|
-
expect(svg.namespaceURI).toBe(SVG_NS)
|
|
3405
|
-
})
|
|
3406
|
-
|
|
3407
|
-
test('nested element inside svg inherits SVG namespace', () => {
|
|
3408
|
-
const el = container()
|
|
3409
|
-
// Use null props to avoid happy-dom SVG property setter issues
|
|
3410
|
-
mount(h('svg', null, h('circle', null)), el)
|
|
3411
|
-
const svg = el.firstElementChild!
|
|
3412
|
-
const circle = svg.firstElementChild!
|
|
3413
|
-
expect(svg.namespaceURI).toBe(SVG_NS)
|
|
3414
|
-
expect(circle.namespaceURI).toBe(SVG_NS)
|
|
3415
|
-
expect(circle.tagName.toLowerCase()).toBe('circle')
|
|
3416
|
-
})
|
|
3417
|
-
|
|
3418
|
-
test('deeply nested SVG elements inherit namespace', () => {
|
|
3419
|
-
const el = container()
|
|
3420
|
-
mount(
|
|
3421
|
-
h('svg', null, h('g', null, h('rect', null))),
|
|
3422
|
-
el,
|
|
3423
|
-
)
|
|
3424
|
-
const svg = el.firstElementChild!
|
|
3425
|
-
const g = svg.firstElementChild!
|
|
3426
|
-
const rect = g.firstElementChild!
|
|
3427
|
-
expect(g.namespaceURI).toBe(SVG_NS)
|
|
3428
|
-
expect(rect.namespaceURI).toBe(SVG_NS)
|
|
3429
|
-
})
|
|
3430
|
-
|
|
3431
|
-
test('SVG marker mounts cleanly with read-only animated-length attributes', () => {
|
|
3432
|
-
// Regression test for the bug fixed via the SVG namespace
|
|
3433
|
-
// special case in setStaticProp: SVGMarkerElement properties
|
|
3434
|
-
// like markerWidth, markerHeight, refX, refY are read-only
|
|
3435
|
-
// SVGAnimatedLength getters. Before the fix, the runtime tried
|
|
3436
|
-
// `el[key] = value` and crashed with `Cannot set property
|
|
3437
|
-
// markerWidth of [object Object] which has only a getter`.
|
|
3438
|
-
// Same for SVGRectElement.x/y/width/height and many others.
|
|
3439
|
-
//
|
|
3440
|
-
// The fix: SVG and MathML elements always go through
|
|
3441
|
-
// setAttribute() instead of property assignment. This test
|
|
3442
|
-
// mounts a marker with all the offending attributes and asserts
|
|
3443
|
-
// they were applied via setAttribute.
|
|
3444
|
-
const el = container()
|
|
3445
|
-
mount(
|
|
3446
|
-
h(
|
|
3447
|
-
'svg',
|
|
3448
|
-
null,
|
|
3449
|
-
h(
|
|
3450
|
-
'defs',
|
|
3451
|
-
null,
|
|
3452
|
-
h(
|
|
3453
|
-
'marker',
|
|
3454
|
-
{
|
|
3455
|
-
id: 'arrowhead',
|
|
3456
|
-
markerWidth: '10',
|
|
3457
|
-
markerHeight: '7',
|
|
3458
|
-
refX: '10',
|
|
3459
|
-
refY: '3.5',
|
|
3460
|
-
orient: 'auto',
|
|
3461
|
-
},
|
|
3462
|
-
h('polygon', { points: '0 0, 10 3.5, 0 7', fill: '#999' }),
|
|
3463
|
-
),
|
|
3464
|
-
),
|
|
3465
|
-
),
|
|
3466
|
-
el,
|
|
3467
|
-
)
|
|
3468
|
-
|
|
3469
|
-
const marker = el.querySelector('marker#arrowhead')
|
|
3470
|
-
expect(marker).not.toBeNull()
|
|
3471
|
-
expect(marker?.getAttribute('markerWidth')).toBe('10')
|
|
3472
|
-
expect(marker?.getAttribute('markerHeight')).toBe('7')
|
|
3473
|
-
expect(marker?.getAttribute('refX')).toBe('10')
|
|
3474
|
-
expect(marker?.getAttribute('refY')).toBe('3.5')
|
|
3475
|
-
expect(marker?.getAttribute('orient')).toBe('auto')
|
|
3476
|
-
})
|
|
3477
|
-
|
|
3478
|
-
test('SVG rect mounts cleanly with read-only x/y/width/height attributes', () => {
|
|
3479
|
-
// Same regression class as the marker test above. SVGRectElement
|
|
3480
|
-
// exposes x, y, width, height as read-only SVGAnimatedLength.
|
|
3481
|
-
const el = container()
|
|
3482
|
-
mount(
|
|
3483
|
-
h(
|
|
3484
|
-
'svg',
|
|
3485
|
-
null,
|
|
3486
|
-
h('rect', { x: '5', y: '10', width: '100', height: '50', fill: 'red' }),
|
|
3487
|
-
),
|
|
3488
|
-
el,
|
|
3489
|
-
)
|
|
3490
|
-
|
|
3491
|
-
const rect = el.querySelector('rect')
|
|
3492
|
-
expect(rect).not.toBeNull()
|
|
3493
|
-
expect(rect?.getAttribute('x')).toBe('5')
|
|
3494
|
-
expect(rect?.getAttribute('y')).toBe('10')
|
|
3495
|
-
expect(rect?.getAttribute('width')).toBe('100')
|
|
3496
|
-
expect(rect?.getAttribute('height')).toBe('50')
|
|
3497
|
-
expect(rect?.getAttribute('fill')).toBe('red')
|
|
3498
|
-
})
|
|
3499
|
-
|
|
3500
|
-
test('elements outside svg do not get SVG namespace', () => {
|
|
3501
|
-
const el = container()
|
|
3502
|
-
mount(
|
|
3503
|
-
h(Fragment, null, h('svg', null, h('circle', null)), h('div', null, 'text')),
|
|
3504
|
-
el,
|
|
3505
|
-
)
|
|
3506
|
-
const svg = el.querySelector('svg')!
|
|
3507
|
-
const circle = svg.firstElementChild!
|
|
3508
|
-
const div = el.querySelector('div')!
|
|
3509
|
-
expect(svg.namespaceURI).toBe(SVG_NS)
|
|
3510
|
-
expect(circle.namespaceURI).toBe(SVG_NS)
|
|
3511
|
-
expect(div.namespaceURI).not.toBe(SVG_NS)
|
|
3512
|
-
})
|
|
3513
|
-
|
|
3514
|
-
test('svg with multiple children all get SVG namespace', () => {
|
|
3515
|
-
const el = container()
|
|
3516
|
-
mount(
|
|
3517
|
-
h('svg', null,
|
|
3518
|
-
h('circle', null),
|
|
3519
|
-
h('rect', null),
|
|
3520
|
-
h('path', null),
|
|
3521
|
-
),
|
|
3522
|
-
el,
|
|
3523
|
-
)
|
|
3524
|
-
const svg = el.firstElementChild!
|
|
3525
|
-
for (const child of Array.from(svg.children)) {
|
|
3526
|
-
expect(child.namespaceURI).toBe(SVG_NS)
|
|
3527
|
-
}
|
|
3528
|
-
})
|
|
3529
|
-
})
|