@pyreon/runtime-dom 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +1909 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +1845 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +355 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +48 -0
- package/src/devtools.ts +304 -0
- package/src/hydrate.ts +385 -0
- package/src/hydration-debug.ts +39 -0
- package/src/index.ts +43 -0
- package/src/keep-alive.ts +71 -0
- package/src/mount.ts +367 -0
- package/src/nodes.ts +741 -0
- package/src/props.ts +328 -0
- package/src/template.ts +81 -0
- package/src/tests/coverage-gaps.test.ts +2488 -0
- package/src/tests/coverage.test.ts +1123 -0
- package/src/tests/mount.test.ts +3098 -0
- package/src/tests/setup.ts +3 -0
- package/src/transition-group.ts +264 -0
- package/src/transition.ts +184 -0
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Targeted tests to increase code coverage above 95% on all metrics.
|
|
3
|
+
* Covers gaps in: devtools.ts, template.ts, mount.ts, transition.ts,
|
|
4
|
+
* hydrate.ts, transition-group.ts, nodes.ts, props.ts
|
|
5
|
+
*/
|
|
6
|
+
import type { ComponentFn, VNodeChild } from "@pyreon/core"
|
|
7
|
+
import {
|
|
8
|
+
createRef,
|
|
9
|
+
defineComponent,
|
|
10
|
+
For,
|
|
11
|
+
Fragment,
|
|
12
|
+
h,
|
|
13
|
+
onMount,
|
|
14
|
+
onUnmount,
|
|
15
|
+
onUpdate,
|
|
16
|
+
} from "@pyreon/core"
|
|
17
|
+
import { signal } from "@pyreon/reactivity"
|
|
18
|
+
import { installDevTools, registerComponent, unregisterComponent } from "../devtools"
|
|
19
|
+
import {
|
|
20
|
+
Transition as _Transition,
|
|
21
|
+
TransitionGroup as _TransitionGroup,
|
|
22
|
+
_tpl,
|
|
23
|
+
hydrateRoot,
|
|
24
|
+
mount,
|
|
25
|
+
sanitizeHtml,
|
|
26
|
+
setSanitizer,
|
|
27
|
+
} from "../index"
|
|
28
|
+
import { mountChild } from "../mount"
|
|
29
|
+
import type { Directive } from "../props"
|
|
30
|
+
|
|
31
|
+
const Transition = _Transition as unknown as ComponentFn<Record<string, unknown>>
|
|
32
|
+
const TransitionGroup = _TransitionGroup as unknown as ComponentFn<Record<string, unknown>>
|
|
33
|
+
|
|
34
|
+
function container(): HTMLElement {
|
|
35
|
+
const el = document.createElement("div")
|
|
36
|
+
document.body.appendChild(el)
|
|
37
|
+
return el
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── template.ts — _tpl() compiler API (lines 72-80) ─────────────────────────
|
|
41
|
+
|
|
42
|
+
describe("_tpl — compiler-facing template API", () => {
|
|
43
|
+
test("creates a NativeItem from HTML string and bind function", () => {
|
|
44
|
+
const el = container()
|
|
45
|
+
const native = _tpl('<div class="box"><span></span></div>', (root) => {
|
|
46
|
+
const span = root.querySelector("span")!
|
|
47
|
+
span.textContent = "hello"
|
|
48
|
+
return null
|
|
49
|
+
})
|
|
50
|
+
expect(native.__isNative).toBe(true)
|
|
51
|
+
expect(native.el).toBeInstanceOf(HTMLElement)
|
|
52
|
+
el.appendChild(native.el)
|
|
53
|
+
expect(el.querySelector(".box span")?.textContent).toBe("hello")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("caches template elements — same HTML string reuses template", () => {
|
|
57
|
+
const html = '<p class="cached"><em></em></p>'
|
|
58
|
+
const n1 = _tpl(html, (root) => {
|
|
59
|
+
root.querySelector("em")!.textContent = "first"
|
|
60
|
+
return null
|
|
61
|
+
})
|
|
62
|
+
const n2 = _tpl(html, (root) => {
|
|
63
|
+
root.querySelector("em")!.textContent = "second"
|
|
64
|
+
return null
|
|
65
|
+
})
|
|
66
|
+
// Both produce valid elements but they are separate clones
|
|
67
|
+
expect(n1.el).not.toBe(n2.el)
|
|
68
|
+
expect((n1.el as HTMLElement).querySelector("em")?.textContent).toBe("first")
|
|
69
|
+
expect((n2.el as HTMLElement).querySelector("em")?.textContent).toBe("second")
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test("bind function can return a cleanup", () => {
|
|
73
|
+
let cleaned = false
|
|
74
|
+
const native = _tpl("<div></div>", () => {
|
|
75
|
+
return () => {
|
|
76
|
+
cleaned = true
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
expect(native.cleanup).not.toBeNull()
|
|
80
|
+
native.cleanup?.()
|
|
81
|
+
expect(cleaned).toBe(true)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("mountChild handles NativeItem from _tpl", () => {
|
|
85
|
+
const el = container()
|
|
86
|
+
const native = _tpl("<span>tpl</span>", () => null)
|
|
87
|
+
const cleanup = mountChild(native as unknown as VNodeChild, el, null)
|
|
88
|
+
expect(el.querySelector("span")?.textContent).toBe("tpl")
|
|
89
|
+
cleanup()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test("mountChild handles NativeItem with cleanup from _tpl", () => {
|
|
93
|
+
const el = container()
|
|
94
|
+
let cleaned = false
|
|
95
|
+
const native = _tpl("<span>tpl2</span>", () => () => {
|
|
96
|
+
cleaned = true
|
|
97
|
+
})
|
|
98
|
+
const cleanup = mountChild(native as unknown as VNodeChild, el, null)
|
|
99
|
+
expect(el.querySelector("span")?.textContent).toBe("tpl2")
|
|
100
|
+
cleanup()
|
|
101
|
+
expect(cleaned).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// ─── devtools.ts — overlay and $p helpers (lines 155-258, 267-290) ────────────
|
|
106
|
+
|
|
107
|
+
describe("DevTools — overlay and $p console helpers", () => {
|
|
108
|
+
beforeAll(() => {
|
|
109
|
+
installDevTools()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("enableOverlay and disableOverlay toggle overlay state", () => {
|
|
113
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
114
|
+
enableOverlay: () => void
|
|
115
|
+
disableOverlay: () => void
|
|
116
|
+
}
|
|
117
|
+
// Enable overlay
|
|
118
|
+
devtools.enableOverlay()
|
|
119
|
+
expect(document.body.style.cursor).toBe("crosshair")
|
|
120
|
+
|
|
121
|
+
// Enable again — should be noop (already active)
|
|
122
|
+
devtools.enableOverlay()
|
|
123
|
+
expect(document.body.style.cursor).toBe("crosshair")
|
|
124
|
+
|
|
125
|
+
// Disable overlay
|
|
126
|
+
devtools.disableOverlay()
|
|
127
|
+
expect(document.body.style.cursor).toBe("")
|
|
128
|
+
|
|
129
|
+
// Disable again — should be noop (already disabled)
|
|
130
|
+
devtools.disableOverlay()
|
|
131
|
+
expect(document.body.style.cursor).toBe("")
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test("overlay creates overlay and tooltip elements", () => {
|
|
135
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
136
|
+
enableOverlay: () => void
|
|
137
|
+
disableOverlay: () => void
|
|
138
|
+
}
|
|
139
|
+
devtools.enableOverlay()
|
|
140
|
+
expect(document.getElementById("__pyreon-overlay")).not.toBeNull()
|
|
141
|
+
devtools.disableOverlay()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test("overlay mousemove with no registered component hides overlay", () => {
|
|
145
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
146
|
+
enableOverlay: () => void
|
|
147
|
+
disableOverlay: () => void
|
|
148
|
+
}
|
|
149
|
+
devtools.enableOverlay()
|
|
150
|
+
|
|
151
|
+
// Simulate mousemove over an unregistered element
|
|
152
|
+
const target = document.createElement("div")
|
|
153
|
+
document.body.appendChild(target)
|
|
154
|
+
const event = new MouseEvent("mousemove", { clientX: 10, clientY: 10, bubbles: true })
|
|
155
|
+
document.dispatchEvent(event)
|
|
156
|
+
|
|
157
|
+
devtools.disableOverlay()
|
|
158
|
+
target.remove()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test("overlay mousemove highlights registered component", () => {
|
|
162
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
163
|
+
enableOverlay: () => void
|
|
164
|
+
disableOverlay: () => void
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const target = document.createElement("div")
|
|
168
|
+
target.style.cssText = "width:100px;height:100px;position:fixed;top:0;left:0;"
|
|
169
|
+
document.body.appendChild(target)
|
|
170
|
+
registerComponent("overlay-test", "OverlayComp", target, null)
|
|
171
|
+
|
|
172
|
+
devtools.enableOverlay()
|
|
173
|
+
|
|
174
|
+
// Simulate mousemove over the registered element
|
|
175
|
+
const event = new MouseEvent("mousemove", { clientX: 50, clientY: 50, bubbles: true })
|
|
176
|
+
document.dispatchEvent(event)
|
|
177
|
+
|
|
178
|
+
// Simulate same element again — should be noop (same _currentHighlight)
|
|
179
|
+
const event2 = new MouseEvent("mousemove", { clientX: 50, clientY: 50, bubbles: true })
|
|
180
|
+
document.dispatchEvent(event2)
|
|
181
|
+
|
|
182
|
+
devtools.disableOverlay()
|
|
183
|
+
unregisterComponent("overlay-test")
|
|
184
|
+
target.remove()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test("overlay click logs component and disables overlay", () => {
|
|
188
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
189
|
+
enableOverlay: () => void
|
|
190
|
+
disableOverlay: () => void
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const target = document.createElement("div")
|
|
194
|
+
target.style.cssText = "width:100px;height:100px;position:fixed;top:0;left:0;"
|
|
195
|
+
document.body.appendChild(target)
|
|
196
|
+
|
|
197
|
+
// Register parent and child for parent logging branch
|
|
198
|
+
registerComponent("click-parent", "ClickParent", null, null)
|
|
199
|
+
registerComponent("click-test", "ClickComp", target, "click-parent")
|
|
200
|
+
|
|
201
|
+
devtools.enableOverlay()
|
|
202
|
+
|
|
203
|
+
const event = new MouseEvent("click", { clientX: 50, clientY: 50, bubbles: true })
|
|
204
|
+
document.dispatchEvent(event)
|
|
205
|
+
|
|
206
|
+
// In happy-dom elementFromPoint returns null, so the click handler
|
|
207
|
+
// returns early without calling disableOverlay. Manually disable.
|
|
208
|
+
devtools.disableOverlay()
|
|
209
|
+
expect(document.body.style.cursor).toBe("")
|
|
210
|
+
|
|
211
|
+
unregisterComponent("click-test")
|
|
212
|
+
unregisterComponent("click-parent")
|
|
213
|
+
target.remove()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test("overlay click on unregistered element — no console log", () => {
|
|
217
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
218
|
+
enableOverlay: () => void
|
|
219
|
+
disableOverlay: () => void
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
devtools.enableOverlay()
|
|
223
|
+
|
|
224
|
+
// Click on area with no component
|
|
225
|
+
const event = new MouseEvent("click", { clientX: 0, clientY: 0, bubbles: true })
|
|
226
|
+
document.dispatchEvent(event)
|
|
227
|
+
|
|
228
|
+
// In happy-dom elementFromPoint returns null, so click handler returns
|
|
229
|
+
// early. Manually disable overlay and verify cursor is restored.
|
|
230
|
+
devtools.disableOverlay()
|
|
231
|
+
expect(document.body.style.cursor).toBe("")
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test("Escape key disables overlay", () => {
|
|
235
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
236
|
+
enableOverlay: () => void
|
|
237
|
+
disableOverlay: () => void
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
devtools.enableOverlay()
|
|
241
|
+
expect(document.body.style.cursor).toBe("crosshair")
|
|
242
|
+
|
|
243
|
+
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
|
|
244
|
+
document.dispatchEvent(event)
|
|
245
|
+
expect(document.body.style.cursor).toBe("")
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test("Ctrl+Shift+P toggles overlay", () => {
|
|
249
|
+
// Enable via Ctrl+Shift+P
|
|
250
|
+
const enableEvent = new KeyboardEvent("keydown", {
|
|
251
|
+
key: "P",
|
|
252
|
+
ctrlKey: true,
|
|
253
|
+
shiftKey: true,
|
|
254
|
+
bubbles: true,
|
|
255
|
+
})
|
|
256
|
+
window.dispatchEvent(enableEvent)
|
|
257
|
+
expect(document.body.style.cursor).toBe("crosshair")
|
|
258
|
+
|
|
259
|
+
// Disable via Ctrl+Shift+P
|
|
260
|
+
const disableEvent = new KeyboardEvent("keydown", {
|
|
261
|
+
key: "P",
|
|
262
|
+
ctrlKey: true,
|
|
263
|
+
shiftKey: true,
|
|
264
|
+
bubbles: true,
|
|
265
|
+
})
|
|
266
|
+
window.dispatchEvent(disableEvent)
|
|
267
|
+
expect(document.body.style.cursor).toBe("")
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test("overlay with component that has children shows child count", () => {
|
|
271
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
272
|
+
enableOverlay: () => void
|
|
273
|
+
disableOverlay: () => void
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const target = document.createElement("div")
|
|
277
|
+
target.style.cssText = "width:100px;height:100px;position:fixed;top:50px;left:50px;"
|
|
278
|
+
document.body.appendChild(target)
|
|
279
|
+
|
|
280
|
+
registerComponent("parent-ov", "ParentOv", target, null)
|
|
281
|
+
registerComponent("child-ov-1", "ChildOv1", null, "parent-ov")
|
|
282
|
+
|
|
283
|
+
devtools.enableOverlay()
|
|
284
|
+
|
|
285
|
+
const event = new MouseEvent("mousemove", { clientX: 75, clientY: 75, bubbles: true })
|
|
286
|
+
document.dispatchEvent(event)
|
|
287
|
+
|
|
288
|
+
devtools.disableOverlay()
|
|
289
|
+
unregisterComponent("child-ov-1")
|
|
290
|
+
unregisterComponent("parent-ov")
|
|
291
|
+
target.remove()
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test("$p console helpers exist and work", () => {
|
|
295
|
+
const $p = (window as unknown as Record<string, unknown>).$p as {
|
|
296
|
+
components: () => unknown[]
|
|
297
|
+
tree: () => unknown[]
|
|
298
|
+
highlight: (id: string) => void
|
|
299
|
+
inspect: () => void
|
|
300
|
+
stats: () => { total: number; roots: number }
|
|
301
|
+
help: () => void
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
expect($p).toBeDefined()
|
|
305
|
+
|
|
306
|
+
// $p.components()
|
|
307
|
+
registerComponent("$p-test", "$pTest", null, null)
|
|
308
|
+
const comps = $p.components()
|
|
309
|
+
expect(comps.length).toBeGreaterThan(0)
|
|
310
|
+
|
|
311
|
+
// $p.tree()
|
|
312
|
+
const tree = $p.tree()
|
|
313
|
+
expect(Array.isArray(tree)).toBe(true)
|
|
314
|
+
|
|
315
|
+
// $p.highlight()
|
|
316
|
+
$p.highlight("$p-test")
|
|
317
|
+
|
|
318
|
+
// $p.inspect() — toggles overlay on
|
|
319
|
+
$p.inspect()
|
|
320
|
+
expect(document.body.style.cursor).toBe("crosshair")
|
|
321
|
+
// $p.inspect() — toggles overlay off
|
|
322
|
+
$p.inspect()
|
|
323
|
+
expect(document.body.style.cursor).toBe("")
|
|
324
|
+
|
|
325
|
+
// $p.stats()
|
|
326
|
+
const stats = $p.stats()
|
|
327
|
+
expect(stats.total).toBeGreaterThan(0)
|
|
328
|
+
expect(typeof stats.roots).toBe("number")
|
|
329
|
+
|
|
330
|
+
// $p.help()
|
|
331
|
+
$p.help()
|
|
332
|
+
|
|
333
|
+
unregisterComponent("$p-test")
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test("$p.stats shows singular 'root' for 1 root", () => {
|
|
337
|
+
// Clear all components first
|
|
338
|
+
const $p = (window as unknown as Record<string, unknown>).$p as {
|
|
339
|
+
stats: () => { total: number; roots: number }
|
|
340
|
+
}
|
|
341
|
+
registerComponent("sole-root", "SoleRoot", null, null)
|
|
342
|
+
const stats = $p.stats()
|
|
343
|
+
expect(stats.roots).toBeGreaterThanOrEqual(1)
|
|
344
|
+
unregisterComponent("sole-root")
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
// ─── mount.ts — uncovered branches ───────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
describe("mount.ts — uncovered branches", () => {
|
|
351
|
+
test("component returning invalid value triggers dev warning (line 283)", () => {
|
|
352
|
+
const el = container()
|
|
353
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
354
|
+
|
|
355
|
+
// Component returns an object without 'type' property — triggers invalid return warning
|
|
356
|
+
const BadComp = (() => ({ weird: true })) as unknown as ComponentFn
|
|
357
|
+
mount(h(BadComp, null), el)
|
|
358
|
+
|
|
359
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("returned an invalid value"))
|
|
360
|
+
warnSpy.mockRestore()
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
test("component subtree mount error with propagateError (lines 298-309)", () => {
|
|
364
|
+
const el = container()
|
|
365
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
|
|
366
|
+
|
|
367
|
+
// Component whose subtree throws during mount
|
|
368
|
+
const Outer = defineComponent(() => {
|
|
369
|
+
// Inner component throws during mount (not setup)
|
|
370
|
+
const Inner = defineComponent(() => {
|
|
371
|
+
// Return a VNode that will throw when mounted
|
|
372
|
+
return h("div", null, (() => {
|
|
373
|
+
throw new Error("subtree mount error")
|
|
374
|
+
}) as unknown as VNodeChild)
|
|
375
|
+
})
|
|
376
|
+
return h(Inner, null)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
mount(h(Outer, null), el)
|
|
380
|
+
errorSpy.mockRestore()
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
test("mountChildren with >2 children and cleanups (line 387)", () => {
|
|
384
|
+
const el = container()
|
|
385
|
+
const s1 = signal("a")
|
|
386
|
+
const s2 = signal("b")
|
|
387
|
+
const s3 = signal("c")
|
|
388
|
+
|
|
389
|
+
// 3 reactive children will all have cleanups, hitting the map+cleanup path
|
|
390
|
+
const unmount = mount(
|
|
391
|
+
h(
|
|
392
|
+
"div",
|
|
393
|
+
null,
|
|
394
|
+
() => s1(),
|
|
395
|
+
() => s2(),
|
|
396
|
+
() => s3(),
|
|
397
|
+
),
|
|
398
|
+
el,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
expect(el.querySelector("div")?.textContent).toContain("a")
|
|
402
|
+
expect(el.querySelector("div")?.textContent).toContain("b")
|
|
403
|
+
expect(el.querySelector("div")?.textContent).toContain("c")
|
|
404
|
+
|
|
405
|
+
unmount()
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
test("mountElement with ref + propCleanup at _elementDepth > 0", () => {
|
|
409
|
+
const el = container()
|
|
410
|
+
const ref = createRef<HTMLElement>()
|
|
411
|
+
const cls = signal("foo")
|
|
412
|
+
|
|
413
|
+
// Nested element with ref AND reactive prop — exercises the combined cleanup path
|
|
414
|
+
mount(h("div", null, h("span", { ref, class: () => cls() }, "inner")), el)
|
|
415
|
+
|
|
416
|
+
expect(ref.current).not.toBeNull()
|
|
417
|
+
expect(ref.current?.className).toBe("foo")
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
test("mountElement with propCleanup only at _elementDepth > 0", () => {
|
|
421
|
+
const el = container()
|
|
422
|
+
const cls = signal("bar")
|
|
423
|
+
|
|
424
|
+
// Nested element with reactive prop but no ref
|
|
425
|
+
const unmount = mount(h("div", null, h("span", { class: () => cls() }, "inner")), el)
|
|
426
|
+
|
|
427
|
+
expect(el.querySelector("span")?.className).toBe("bar")
|
|
428
|
+
cls.set("baz")
|
|
429
|
+
expect(el.querySelector("span")?.className).toBe("baz")
|
|
430
|
+
unmount()
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
test("reactive text at _elementDepth > 0 returns just dispose", () => {
|
|
434
|
+
const el = container()
|
|
435
|
+
const text = signal("nested")
|
|
436
|
+
|
|
437
|
+
// Reactive text inside a parent element — should skip DOM removal closure
|
|
438
|
+
const unmount = mount(
|
|
439
|
+
h("div", null, () => text()),
|
|
440
|
+
el,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
expect(el.querySelector("div")?.textContent).toBe("nested")
|
|
444
|
+
text.set("updated")
|
|
445
|
+
expect(el.querySelector("div")?.textContent).toBe("updated")
|
|
446
|
+
unmount()
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
test("NativeItem without cleanup at _elementDepth > 0", () => {
|
|
450
|
+
const el = container()
|
|
451
|
+
const native = _tpl("<b>native</b>", () => null)
|
|
452
|
+
|
|
453
|
+
// Mount NativeItem inside a parent element
|
|
454
|
+
mount(h("div", null, native as unknown as VNodeChild), el)
|
|
455
|
+
expect(el.querySelector("b")?.textContent).toBe("native")
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
test("NativeItem with cleanup at _elementDepth > 0", () => {
|
|
459
|
+
const el = container()
|
|
460
|
+
let _cleaned = false
|
|
461
|
+
const native = _tpl("<b>native2</b>", () => () => {
|
|
462
|
+
_cleaned = true
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
mount(h("div", null, native as unknown as VNodeChild), el)
|
|
466
|
+
expect(el.querySelector("b")?.textContent).toBe("native2")
|
|
467
|
+
})
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
// ─── transition.ts — uncovered branches (lines 152, 165, 170-175) ────────────
|
|
471
|
+
|
|
472
|
+
describe("Transition — uncovered branches", () => {
|
|
473
|
+
test("onUnmount cancels pending leave (line 152)", async () => {
|
|
474
|
+
const el = container()
|
|
475
|
+
const visible = signal(true)
|
|
476
|
+
|
|
477
|
+
const unmount = mount(
|
|
478
|
+
h(Transition, {
|
|
479
|
+
show: visible,
|
|
480
|
+
name: "fade",
|
|
481
|
+
children: h("div", { id: "unmount-leave" }, "content"),
|
|
482
|
+
}),
|
|
483
|
+
el,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
// Start leave animation
|
|
487
|
+
visible.set(false)
|
|
488
|
+
|
|
489
|
+
// Unmount before leave completes — should cancel pending leave
|
|
490
|
+
unmount()
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
test("non-object/array child returns rawChild (line 165)", () => {
|
|
494
|
+
const el = container()
|
|
495
|
+
const visible = signal(true)
|
|
496
|
+
|
|
497
|
+
// Pass a non-object, non-array child (string) — hits line 165
|
|
498
|
+
mount(
|
|
499
|
+
h(Transition, {
|
|
500
|
+
show: visible,
|
|
501
|
+
children: "just text" as unknown as VNodeChild,
|
|
502
|
+
}),
|
|
503
|
+
el,
|
|
504
|
+
)
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
test("array child returns rawChild (line 164)", () => {
|
|
508
|
+
const el = container()
|
|
509
|
+
const visible = signal(true)
|
|
510
|
+
|
|
511
|
+
// Pass an array child — hits the Array.isArray branch
|
|
512
|
+
mount(
|
|
513
|
+
h(Transition, {
|
|
514
|
+
show: visible,
|
|
515
|
+
children: [h("span", null, "a"), h("span", null, "b")] as unknown as VNodeChild,
|
|
516
|
+
}),
|
|
517
|
+
el,
|
|
518
|
+
)
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
test("component child warns and returns vnode (lines 170-175)", () => {
|
|
522
|
+
const el = container()
|
|
523
|
+
const visible = signal(true)
|
|
524
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
525
|
+
|
|
526
|
+
const Inner = defineComponent(() => h("span", null, "comp child"))
|
|
527
|
+
|
|
528
|
+
mount(
|
|
529
|
+
h(Transition, {
|
|
530
|
+
show: visible,
|
|
531
|
+
children: h(Inner, null),
|
|
532
|
+
}),
|
|
533
|
+
el,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Transition child is a component"))
|
|
537
|
+
warnSpy.mockRestore()
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
test("leave with no ref.current sets isMounted false immediately (line 142-144)", async () => {
|
|
541
|
+
const el = container()
|
|
542
|
+
const visible = signal(true)
|
|
543
|
+
|
|
544
|
+
// Use a non-string type (like a component) so ref won't be injected
|
|
545
|
+
const Comp = () => h("span", null, "no-ref")
|
|
546
|
+
mount(
|
|
547
|
+
h(Transition, {
|
|
548
|
+
show: visible,
|
|
549
|
+
children: h(Comp, null) as VNodeChild,
|
|
550
|
+
}),
|
|
551
|
+
el,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
// Toggle off — ref.current will be null for component children
|
|
555
|
+
visible.set(false)
|
|
556
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
test("onAfterEnter callback fires after enter transition", async () => {
|
|
560
|
+
const el = container()
|
|
561
|
+
const visible = signal(false)
|
|
562
|
+
let afterEnterCalled = false
|
|
563
|
+
|
|
564
|
+
mount(
|
|
565
|
+
h(Transition, {
|
|
566
|
+
show: visible,
|
|
567
|
+
name: "fade",
|
|
568
|
+
onAfterEnter: () => {
|
|
569
|
+
afterEnterCalled = true
|
|
570
|
+
},
|
|
571
|
+
children: h("div", { id: "after-enter-test" }, "content"),
|
|
572
|
+
}),
|
|
573
|
+
el,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
visible.set(true)
|
|
577
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
578
|
+
|
|
579
|
+
// Trigger rAF
|
|
580
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
581
|
+
const target = el.querySelector("#after-enter-test")
|
|
582
|
+
if (target) {
|
|
583
|
+
target.dispatchEvent(new Event("transitionend"))
|
|
584
|
+
}
|
|
585
|
+
expect(afterEnterCalled).toBe(true)
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
test("onAfterLeave callback fires after leave transition", async () => {
|
|
589
|
+
const el = container()
|
|
590
|
+
const visible = signal(true)
|
|
591
|
+
let afterLeaveCalled = false
|
|
592
|
+
|
|
593
|
+
mount(
|
|
594
|
+
h(Transition, {
|
|
595
|
+
show: visible,
|
|
596
|
+
name: "fade",
|
|
597
|
+
onAfterLeave: () => {
|
|
598
|
+
afterLeaveCalled = true
|
|
599
|
+
},
|
|
600
|
+
children: h("div", { id: "after-leave-test" }, "content"),
|
|
601
|
+
}),
|
|
602
|
+
el,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
const target = el.querySelector("#after-leave-test")
|
|
606
|
+
visible.set(false)
|
|
607
|
+
|
|
608
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
609
|
+
if (target) {
|
|
610
|
+
target.dispatchEvent(new Event("transitionend"))
|
|
611
|
+
}
|
|
612
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
613
|
+
expect(afterLeaveCalled).toBe(true)
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
test("tooltip repositions when near top of viewport", () => {
|
|
617
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
618
|
+
enableOverlay: () => void
|
|
619
|
+
disableOverlay: () => void
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const target = document.createElement("div")
|
|
623
|
+
// Position near top so tooltip moves below element (rect.top < 35)
|
|
624
|
+
target.style.cssText = "width:100px;height:20px;position:fixed;top:10px;left:10px;"
|
|
625
|
+
document.body.appendChild(target)
|
|
626
|
+
registerComponent("top-comp", "TopComp", target, null)
|
|
627
|
+
|
|
628
|
+
devtools.enableOverlay()
|
|
629
|
+
|
|
630
|
+
const event = new MouseEvent("mousemove", { clientX: 50, clientY: 15, bubbles: true })
|
|
631
|
+
document.dispatchEvent(event)
|
|
632
|
+
|
|
633
|
+
devtools.disableOverlay()
|
|
634
|
+
unregisterComponent("top-comp")
|
|
635
|
+
target.remove()
|
|
636
|
+
})
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
// ─── hydrate.ts — uncovered branches (lines 162-183, 338) ────────────────────
|
|
640
|
+
|
|
641
|
+
describe("hydrate.ts — uncovered branches", () => {
|
|
642
|
+
test("For hydration with SSR markers — full path with afterEnd (lines 162-183)", () => {
|
|
643
|
+
const el = container()
|
|
644
|
+
// SSR markers with content after the end marker
|
|
645
|
+
el.innerHTML = "<!--pyreon-for--><li>a</li><li>b</li><!--/pyreon-for--><p>after</p>"
|
|
646
|
+
const items = signal([
|
|
647
|
+
{ id: 1, label: "a" },
|
|
648
|
+
{ id: 2, label: "b" },
|
|
649
|
+
])
|
|
650
|
+
const cleanup = hydrateRoot(
|
|
651
|
+
el,
|
|
652
|
+
h(
|
|
653
|
+
Fragment,
|
|
654
|
+
null,
|
|
655
|
+
For({
|
|
656
|
+
each: items,
|
|
657
|
+
by: (r: { id: number }) => r.id,
|
|
658
|
+
children: (r: { id: number; label: string }) => h("li", null, r.label),
|
|
659
|
+
}),
|
|
660
|
+
h("p", null, "after"),
|
|
661
|
+
),
|
|
662
|
+
)
|
|
663
|
+
cleanup()
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
test("component with onUpdate hooks during hydration (line 338)", () => {
|
|
667
|
+
const el = container()
|
|
668
|
+
el.innerHTML = "<span>update-test</span>"
|
|
669
|
+
let _updateCalled = false
|
|
670
|
+
|
|
671
|
+
const Comp = defineComponent(() => {
|
|
672
|
+
onUpdate(() => {
|
|
673
|
+
_updateCalled = true
|
|
674
|
+
})
|
|
675
|
+
return h("span", null, "update-test")
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
const cleanup = hydrateRoot(el, h(Comp, null))
|
|
679
|
+
cleanup()
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
test("component with onUnmount hook during hydration cleanup", () => {
|
|
683
|
+
const el = container()
|
|
684
|
+
el.innerHTML = "<span>unmount-test</span>"
|
|
685
|
+
let unmountCalled = false
|
|
686
|
+
|
|
687
|
+
const Comp = defineComponent(() => {
|
|
688
|
+
onUnmount(() => {
|
|
689
|
+
unmountCalled = true
|
|
690
|
+
})
|
|
691
|
+
return h("span", null, "unmount-test")
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
const cleanup = hydrateRoot(el, h(Comp, null))
|
|
695
|
+
cleanup()
|
|
696
|
+
expect(unmountCalled).toBe(true)
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
test("component with mount cleanup during hydration", () => {
|
|
700
|
+
const el = container()
|
|
701
|
+
el.innerHTML = "<span>mount-cleanup</span>"
|
|
702
|
+
let mountCleanupCalled = false
|
|
703
|
+
|
|
704
|
+
const Comp = defineComponent(() => {
|
|
705
|
+
onMount(() => () => {
|
|
706
|
+
mountCleanupCalled = true
|
|
707
|
+
})
|
|
708
|
+
return h("span", null, "mount-cleanup")
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
const cleanup = hydrateRoot(el, h(Comp, null))
|
|
712
|
+
cleanup()
|
|
713
|
+
expect(mountCleanupCalled).toBe(true)
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
test("hydrates component with children merge", () => {
|
|
717
|
+
const el = container()
|
|
718
|
+
el.innerHTML = "<div><b>child</b></div>"
|
|
719
|
+
|
|
720
|
+
const Wrapper = defineComponent((props: { children?: VNodeChild }) =>
|
|
721
|
+
h("div", null, props.children),
|
|
722
|
+
)
|
|
723
|
+
const cleanup = hydrateRoot(el, h(Wrapper, null, h("b", null, "child")))
|
|
724
|
+
cleanup()
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
test("hydrates reactive accessor returning VNode with domNode present", () => {
|
|
728
|
+
const el = container()
|
|
729
|
+
el.innerHTML = "<div><span>initial</span></div>"
|
|
730
|
+
const content = signal<VNodeChild>(h("span", null, "initial"))
|
|
731
|
+
// Reactive accessor returns a VNode — goes through the complex reactive path with marker
|
|
732
|
+
const cleanup = hydrateRoot(el, h("div", null, (() => content()) as unknown as VNodeChild))
|
|
733
|
+
cleanup()
|
|
734
|
+
})
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
// ─── transition-group.ts — FLIP move animation (lines 209-218) ───────────────
|
|
738
|
+
|
|
739
|
+
describe("TransitionGroup — FLIP move animation", () => {
|
|
740
|
+
test("FLIP animation fires for moved items", async () => {
|
|
741
|
+
const el = container()
|
|
742
|
+
const items = signal([
|
|
743
|
+
{ id: 1, label: "a" },
|
|
744
|
+
{ id: 2, label: "b" },
|
|
745
|
+
{ id: 3, label: "c" },
|
|
746
|
+
])
|
|
747
|
+
|
|
748
|
+
mount(
|
|
749
|
+
h(TransitionGroup, {
|
|
750
|
+
tag: "div",
|
|
751
|
+
name: "list",
|
|
752
|
+
items: () => items(),
|
|
753
|
+
keyFn: (item: { id: number }) => item.id,
|
|
754
|
+
render: (item: { id: number; label: string }) =>
|
|
755
|
+
h("span", { class: "flip-item" }, item.label),
|
|
756
|
+
}),
|
|
757
|
+
el,
|
|
758
|
+
)
|
|
759
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
760
|
+
expect(el.querySelectorAll("span.flip-item").length).toBe(3)
|
|
761
|
+
|
|
762
|
+
// Reorder to trigger FLIP
|
|
763
|
+
items.set([
|
|
764
|
+
{ id: 3, label: "c" },
|
|
765
|
+
{ id: 1, label: "a" },
|
|
766
|
+
{ id: 2, label: "b" },
|
|
767
|
+
])
|
|
768
|
+
|
|
769
|
+
// Wait for the effect and rAF chains
|
|
770
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
771
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
772
|
+
// Second rAF for the inner requestAnimationFrame in FLIP
|
|
773
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
774
|
+
|
|
775
|
+
// Fire transitionend to clean up move class
|
|
776
|
+
const spans = el.querySelectorAll("span.flip-item")
|
|
777
|
+
for (const span of spans) {
|
|
778
|
+
span.dispatchEvent(new Event("transitionend"))
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Items should be reordered
|
|
782
|
+
const reorderedSpans = el.querySelectorAll("span.flip-item")
|
|
783
|
+
expect(reorderedSpans[0]?.textContent).toBe("c")
|
|
784
|
+
expect(reorderedSpans[1]?.textContent).toBe("a")
|
|
785
|
+
expect(reorderedSpans[2]?.textContent).toBe("b")
|
|
786
|
+
})
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
// ─── nodes.ts — empty mount placeholder paths (lines 433-435, 493-496) ───────
|
|
790
|
+
|
|
791
|
+
describe("nodes.ts — placeholder comment paths", () => {
|
|
792
|
+
test("mountFor fresh render — component returning null uses placeholder", () => {
|
|
793
|
+
const el = container()
|
|
794
|
+
const items = signal([{ id: 1 }, { id: 2 }])
|
|
795
|
+
|
|
796
|
+
// Component that returns null — mount produces no DOM nodes, so
|
|
797
|
+
// the mountFor fresh render path needs a placeholder comment anchor
|
|
798
|
+
const NullComp = defineComponent(() => null)
|
|
799
|
+
|
|
800
|
+
mount(
|
|
801
|
+
h(
|
|
802
|
+
"div",
|
|
803
|
+
null,
|
|
804
|
+
For({
|
|
805
|
+
each: items,
|
|
806
|
+
by: (r: { id: number }) => r.id,
|
|
807
|
+
children: (r: { id: number }) => h(NullComp, { key: r.id }),
|
|
808
|
+
}),
|
|
809
|
+
),
|
|
810
|
+
el,
|
|
811
|
+
)
|
|
812
|
+
})
|
|
813
|
+
|
|
814
|
+
test("mountFor replace-all — component returning null uses placeholder", () => {
|
|
815
|
+
const el = container()
|
|
816
|
+
const items = signal([{ id: 1 }])
|
|
817
|
+
|
|
818
|
+
const NullComp = defineComponent(() => null)
|
|
819
|
+
|
|
820
|
+
mount(
|
|
821
|
+
h(
|
|
822
|
+
"div",
|
|
823
|
+
null,
|
|
824
|
+
For({
|
|
825
|
+
each: items,
|
|
826
|
+
by: (r: { id: number }) => r.id,
|
|
827
|
+
children: (r: { id: number }) => h(NullComp, { key: r.id }),
|
|
828
|
+
}),
|
|
829
|
+
),
|
|
830
|
+
el,
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
// Replace all with new keys
|
|
834
|
+
items.set([{ id: 10 }, { id: 11 }])
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
test("mountFor step 3 — new entries with component returning null (lines 493-496)", () => {
|
|
838
|
+
const el = container()
|
|
839
|
+
const items = signal([{ id: 1 }, { id: 2 }])
|
|
840
|
+
|
|
841
|
+
const NullComp = defineComponent(() => null)
|
|
842
|
+
|
|
843
|
+
mount(
|
|
844
|
+
h(
|
|
845
|
+
"div",
|
|
846
|
+
null,
|
|
847
|
+
For({
|
|
848
|
+
each: items,
|
|
849
|
+
by: (r: { id: number }) => r.id,
|
|
850
|
+
children: (r: { id: number }) => h(NullComp, { key: r.id }),
|
|
851
|
+
}),
|
|
852
|
+
),
|
|
853
|
+
el,
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
// Add new items — step 3 mount new entries path
|
|
857
|
+
items.set([{ id: 1 }, { id: 2 }, { id: 3 }])
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
test("mountFor with NativeItem having cleanup in replace-all path", () => {
|
|
861
|
+
const el = container()
|
|
862
|
+
type R = { id: number; label: string }
|
|
863
|
+
let cleanupCount = 0
|
|
864
|
+
|
|
865
|
+
const items = signal<R[]>([{ id: 1, label: "old" }])
|
|
866
|
+
|
|
867
|
+
mount(
|
|
868
|
+
h(
|
|
869
|
+
"div",
|
|
870
|
+
null,
|
|
871
|
+
For({
|
|
872
|
+
each: items,
|
|
873
|
+
by: (r) => r.id,
|
|
874
|
+
children: (r) => {
|
|
875
|
+
const native = _tpl("<b></b>", (root) => {
|
|
876
|
+
root.textContent = r.label
|
|
877
|
+
return () => {
|
|
878
|
+
cleanupCount++
|
|
879
|
+
}
|
|
880
|
+
})
|
|
881
|
+
return native as unknown as ReturnType<typeof h>
|
|
882
|
+
},
|
|
883
|
+
}),
|
|
884
|
+
),
|
|
885
|
+
el,
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
// Replace all — should call cleanup on old entries
|
|
889
|
+
items.set([{ id: 10, label: "new" }])
|
|
890
|
+
expect(cleanupCount).toBe(1)
|
|
891
|
+
})
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
// ─── props.ts — uncovered branches (lines 213, 242, 273-277) ─────────────────
|
|
895
|
+
|
|
896
|
+
describe("props.ts — uncovered branches", () => {
|
|
897
|
+
test("multiple prop cleanups chain correctly (line 213)", () => {
|
|
898
|
+
const el = container()
|
|
899
|
+
const cls = signal("a")
|
|
900
|
+
const title = signal("t")
|
|
901
|
+
|
|
902
|
+
// Two reactive props => two cleanups that chain
|
|
903
|
+
const unmount = mount(h("div", { class: () => cls(), title: () => title() }), el)
|
|
904
|
+
|
|
905
|
+
const div = el.querySelector("div") as HTMLElement
|
|
906
|
+
expect(div.className).toBe("a")
|
|
907
|
+
expect(div.title).toBe("t")
|
|
908
|
+
|
|
909
|
+
cls.set("b")
|
|
910
|
+
title.set("u")
|
|
911
|
+
expect(div.className).toBe("b")
|
|
912
|
+
expect(div.title).toBe("u")
|
|
913
|
+
|
|
914
|
+
unmount()
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
test("n-show prop toggles display (lines 273-277)", () => {
|
|
918
|
+
const el = container()
|
|
919
|
+
const visible = signal(true)
|
|
920
|
+
|
|
921
|
+
mount(h("div", { "n-show": () => visible() }), el)
|
|
922
|
+
const div = el.querySelector("div") as HTMLElement
|
|
923
|
+
expect(div.style.display).toBe("")
|
|
924
|
+
|
|
925
|
+
visible.set(false)
|
|
926
|
+
expect(div.style.display).toBe("none")
|
|
927
|
+
|
|
928
|
+
visible.set(true)
|
|
929
|
+
expect(div.style.display).toBe("")
|
|
930
|
+
})
|
|
931
|
+
|
|
932
|
+
test("innerHTML with setHTML method (line 242)", async () => {
|
|
933
|
+
const el = container()
|
|
934
|
+
const div = document.createElement("div")
|
|
935
|
+
el.appendChild(div)
|
|
936
|
+
|
|
937
|
+
// Mock setHTML on the element
|
|
938
|
+
let setHTMLCalled = false
|
|
939
|
+
;(div as unknown as Record<string, unknown>).setHTML = (html: string) => {
|
|
940
|
+
setHTMLCalled = true
|
|
941
|
+
div.innerHTML = html
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Use applyProp directly for this test
|
|
945
|
+
const { applyProp } = await import("../props")
|
|
946
|
+
applyProp(div, "innerHTML", "<b>via setHTML</b>")
|
|
947
|
+
expect(setHTMLCalled).toBe(true)
|
|
948
|
+
expect(div.innerHTML).toBe("<b>via setHTML</b>")
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
test("multiple chained prop cleanups (3+ reactive props)", () => {
|
|
952
|
+
const el = container()
|
|
953
|
+
const a = signal("a")
|
|
954
|
+
const b = signal("b")
|
|
955
|
+
const c = signal("c")
|
|
956
|
+
|
|
957
|
+
const unmount = mount(
|
|
958
|
+
h("div", {
|
|
959
|
+
class: () => a(),
|
|
960
|
+
title: () => b(),
|
|
961
|
+
"data-x": () => c(),
|
|
962
|
+
}),
|
|
963
|
+
el,
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
const div = el.querySelector("div") as HTMLElement
|
|
967
|
+
expect(div.className).toBe("a")
|
|
968
|
+
expect(div.title).toBe("b")
|
|
969
|
+
expect(div.getAttribute("data-x")).toBe("c")
|
|
970
|
+
|
|
971
|
+
unmount()
|
|
972
|
+
})
|
|
973
|
+
|
|
974
|
+
test("directive without cleanup returns null", () => {
|
|
975
|
+
const el = container()
|
|
976
|
+
let called = false
|
|
977
|
+
const nSimple: Directive = () => {
|
|
978
|
+
called = true
|
|
979
|
+
// No addCleanup call
|
|
980
|
+
}
|
|
981
|
+
mount(h("div", { "n-simple": nSimple }), el)
|
|
982
|
+
expect(called).toBe(true)
|
|
983
|
+
})
|
|
984
|
+
|
|
985
|
+
test("sanitizeHtml with no DOMParser or Sanitizer falls back to tag stripping", () => {
|
|
986
|
+
// This path is hard to test in happy-dom since DOMParser exists,
|
|
987
|
+
// but we can test the custom sanitizer path
|
|
988
|
+
setSanitizer((html) => html.replace(/<[^>]*>/g, ""))
|
|
989
|
+
const result = sanitizeHtml("<b>bold</b><script>bad</script>")
|
|
990
|
+
expect(result).toBe("boldbad")
|
|
991
|
+
setSanitizer(null)
|
|
992
|
+
})
|
|
993
|
+
|
|
994
|
+
test("dangerouslySetInnerHTML warns in dev mode", () => {
|
|
995
|
+
const el = container()
|
|
996
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
997
|
+
|
|
998
|
+
mount(h("div", { dangerouslySetInnerHTML: { __html: "<em>raw</em>" } }), el)
|
|
999
|
+
|
|
1000
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("dangerouslySetInnerHTML"))
|
|
1001
|
+
warnSpy.mockRestore()
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
test("style as null/undefined does nothing", () => {
|
|
1005
|
+
const el = container()
|
|
1006
|
+
mount(h("div", { style: null as unknown as string }), el)
|
|
1007
|
+
// Should not throw
|
|
1008
|
+
expect(el.querySelector("div")).not.toBeNull()
|
|
1009
|
+
})
|
|
1010
|
+
})
|
|
1011
|
+
|
|
1012
|
+
// ─── Additional edge cases for mount.ts ──────────────────────────────────────
|
|
1013
|
+
|
|
1014
|
+
describe("mount.ts — additional edge cases", () => {
|
|
1015
|
+
test("mountElement no reactive work and no ref at depth > 0 returns noop", () => {
|
|
1016
|
+
const el = container()
|
|
1017
|
+
// Static nested element with no reactive props, no ref — returns noop at _elementDepth > 0
|
|
1018
|
+
const unmount = mount(h("div", null, h("span", null, "static")), el)
|
|
1019
|
+
expect(el.querySelector("span")?.textContent).toBe("static")
|
|
1020
|
+
unmount()
|
|
1021
|
+
})
|
|
1022
|
+
|
|
1023
|
+
test("mountChildren 2-child path where one cleanup is noop", () => {
|
|
1024
|
+
const el = container()
|
|
1025
|
+
// 2 children: one static (noop cleanup) and one with cleanup
|
|
1026
|
+
const cls = signal("x")
|
|
1027
|
+
mount(h("div", null, h("span", null, "static"), h("b", { class: () => cls() }, "reactive")), el)
|
|
1028
|
+
expect(el.querySelectorAll("span").length).toBe(1)
|
|
1029
|
+
expect(el.querySelector("b")?.className).toBe("x")
|
|
1030
|
+
})
|
|
1031
|
+
|
|
1032
|
+
test("mountChildren 2-child path where both cleanups are noop", () => {
|
|
1033
|
+
const el = container()
|
|
1034
|
+
// 2 static children — both noop cleanup
|
|
1035
|
+
mount(h("div", null, h("span", null, "a"), h("b", null, "b")), el)
|
|
1036
|
+
expect(el.querySelector("span")?.textContent).toBe("a")
|
|
1037
|
+
expect(el.querySelector("b")?.textContent).toBe("b")
|
|
1038
|
+
})
|
|
1039
|
+
|
|
1040
|
+
test("mountChildren 2-child path where first cleanup is noop", () => {
|
|
1041
|
+
const el = container()
|
|
1042
|
+
const cls = signal("x")
|
|
1043
|
+
// First child static (noop), second child reactive
|
|
1044
|
+
mount(h("div", null, "text", h("b", { class: () => cls() }, "reactive")), el)
|
|
1045
|
+
})
|
|
1046
|
+
|
|
1047
|
+
test("isKeyedArray returns false for empty array", () => {
|
|
1048
|
+
const el = container()
|
|
1049
|
+
const items = signal<{ id: number }[]>([])
|
|
1050
|
+
// Reactive accessor returning empty array — should not use keyed reconciler
|
|
1051
|
+
mount(
|
|
1052
|
+
h("div", null, () => items().map((it) => h("span", { key: it.id }))),
|
|
1053
|
+
el,
|
|
1054
|
+
)
|
|
1055
|
+
expect(el.querySelector("span")).toBeNull()
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1058
|
+
test("isKeyedArray returns false for non-keyed vnodes", () => {
|
|
1059
|
+
const el = container()
|
|
1060
|
+
const items = signal([1, 2, 3])
|
|
1061
|
+
// VNodes without keys — should NOT use keyed reconciler
|
|
1062
|
+
mount(
|
|
1063
|
+
h("div", null, () => items().map((n) => h("span", null, String(n)))),
|
|
1064
|
+
el,
|
|
1065
|
+
)
|
|
1066
|
+
expect(el.querySelectorAll("span").length).toBe(3)
|
|
1067
|
+
})
|
|
1068
|
+
})
|
|
1069
|
+
|
|
1070
|
+
// ─── hydrate.ts — additional branches ────────────────────────────────────────
|
|
1071
|
+
|
|
1072
|
+
describe("hydrate.ts — additional branches", () => {
|
|
1073
|
+
test("hydrates component returning null", () => {
|
|
1074
|
+
const el = container()
|
|
1075
|
+
el.innerHTML = ""
|
|
1076
|
+
const NullComp = defineComponent(() => null)
|
|
1077
|
+
const cleanup = hydrateRoot(el, h(NullComp, null))
|
|
1078
|
+
cleanup()
|
|
1079
|
+
})
|
|
1080
|
+
|
|
1081
|
+
test("hydrates element mismatch — element found but wrong tag", () => {
|
|
1082
|
+
const el = container()
|
|
1083
|
+
el.innerHTML = "<div>wrong tag</div>"
|
|
1084
|
+
// Expect a <p> but find <div>
|
|
1085
|
+
const cleanup = hydrateRoot(el, h("p", null, "right"))
|
|
1086
|
+
cleanup()
|
|
1087
|
+
})
|
|
1088
|
+
|
|
1089
|
+
test("hydrates For without SSR markers but with existing domNode (non-comment)", () => {
|
|
1090
|
+
const el = container()
|
|
1091
|
+
// Existing element (not a comment) — takes the no-markers path
|
|
1092
|
+
el.innerHTML = "<span>not a for marker</span>"
|
|
1093
|
+
const items = signal([{ id: 1, label: "a" }])
|
|
1094
|
+
const cleanup = hydrateRoot(
|
|
1095
|
+
el,
|
|
1096
|
+
For({
|
|
1097
|
+
each: items,
|
|
1098
|
+
by: (r: { id: number }) => r.id,
|
|
1099
|
+
children: (r: { id: number; label: string }) => h("li", null, r.label),
|
|
1100
|
+
}),
|
|
1101
|
+
)
|
|
1102
|
+
cleanup()
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
test("hydrates PortalSymbol — always remounts", async () => {
|
|
1106
|
+
const el = container()
|
|
1107
|
+
const target = container()
|
|
1108
|
+
el.innerHTML = ""
|
|
1109
|
+
|
|
1110
|
+
const { Portal } = await import("@pyreon/core")
|
|
1111
|
+
const cleanup = hydrateRoot(el, Portal({ target, children: h("span", null, "portal") }))
|
|
1112
|
+
expect(target.querySelector("span")?.textContent).toBe("portal")
|
|
1113
|
+
cleanup()
|
|
1114
|
+
})
|
|
1115
|
+
|
|
1116
|
+
test("reactive accessor with complex VNode and existing domNode inserts marker before domNode", () => {
|
|
1117
|
+
const el = container()
|
|
1118
|
+
el.innerHTML = "<span>existing</span>"
|
|
1119
|
+
const content = signal<VNodeChild>(h("b", null, "complex"))
|
|
1120
|
+
const cleanup = hydrateRoot(el, (() => content()) as unknown as VNodeChild)
|
|
1121
|
+
cleanup()
|
|
1122
|
+
})
|
|
1123
|
+
})
|