@pyreon/runtime-dom 0.11.3 → 0.11.4
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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/package.json +3 -3
- package/src/keep-alive.ts +2 -1
- package/src/tests/props.test.ts +463 -0
- package/src/tests/transition.test.ts +550 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/runtime-dom",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.4",
|
|
4
4
|
"description": "DOM renderer for Pyreon",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"prepublishOnly": "bun run build"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@pyreon/core": "^0.11.
|
|
43
|
-
"@pyreon/reactivity": "^0.11.
|
|
42
|
+
"@pyreon/core": "^0.11.4",
|
|
43
|
+
"@pyreon/reactivity": "^0.11.4"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@happy-dom/global-registrator": "^20.8.3",
|
package/src/keep-alive.ts
CHANGED
|
@@ -44,7 +44,8 @@ export function KeepAlive(props: KeepAliveProps): VNodeChild {
|
|
|
44
44
|
let childMounted = false
|
|
45
45
|
|
|
46
46
|
onMount(() => {
|
|
47
|
-
const container = containerRef.current
|
|
47
|
+
const container = containerRef.current
|
|
48
|
+
if (!container) return
|
|
48
49
|
|
|
49
50
|
const e = effect(() => {
|
|
50
51
|
const isActive = props.active?.() ?? true
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import { signal } from "@pyreon/reactivity"
|
|
2
|
+
import { DELEGATED_EVENTS, delegatedPropName, setupDelegation } from "../delegate"
|
|
3
|
+
import { applyProp, applyProps, sanitizeHtml, setSanitizer } from "../props"
|
|
4
|
+
|
|
5
|
+
// ─── applyProps ──────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
describe("applyProps", () => {
|
|
8
|
+
test("skips key, ref, and children props", () => {
|
|
9
|
+
const el = document.createElement("div")
|
|
10
|
+
const cleanup = applyProps(el, {
|
|
11
|
+
key: "k1",
|
|
12
|
+
ref: { current: null },
|
|
13
|
+
children: "text",
|
|
14
|
+
id: "test",
|
|
15
|
+
})
|
|
16
|
+
expect(el.getAttribute("id")).toBe("test")
|
|
17
|
+
// key, ref, children should not appear as attributes
|
|
18
|
+
expect(el.hasAttribute("key")).toBe(false)
|
|
19
|
+
expect(el.hasAttribute("ref")).toBe(false)
|
|
20
|
+
expect(el.hasAttribute("children")).toBe(false)
|
|
21
|
+
cleanup?.()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("returns null when no props produce cleanup", () => {
|
|
25
|
+
const el = document.createElement("div")
|
|
26
|
+
const cleanup = applyProps(el, { id: "static", title: "hello" })
|
|
27
|
+
expect(cleanup).toBeNull()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("returns single cleanup when one prop needs it", () => {
|
|
31
|
+
const el = document.createElement("div")
|
|
32
|
+
const cleanup = applyProps(el, { onClick: () => {} })
|
|
33
|
+
expect(cleanup).not.toBeNull()
|
|
34
|
+
expect(typeof cleanup).toBe("function")
|
|
35
|
+
cleanup?.()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("returns chained cleanup when multiple props need cleanup", () => {
|
|
39
|
+
const el = document.createElement("div")
|
|
40
|
+
const s1 = signal("a")
|
|
41
|
+
const s2 = signal("b")
|
|
42
|
+
const cleanup = applyProps(el, {
|
|
43
|
+
onClick: () => {},
|
|
44
|
+
class: s1,
|
|
45
|
+
title: s2,
|
|
46
|
+
})
|
|
47
|
+
expect(cleanup).not.toBeNull()
|
|
48
|
+
cleanup?.()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("chains 3+ cleanups into array-based cleanup", () => {
|
|
52
|
+
const el = document.createElement("div")
|
|
53
|
+
const cleanup = applyProps(el, {
|
|
54
|
+
onClick: () => {},
|
|
55
|
+
onInput: () => {},
|
|
56
|
+
class: signal("x"),
|
|
57
|
+
})
|
|
58
|
+
expect(typeof cleanup).toBe("function")
|
|
59
|
+
cleanup?.()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// ─── applyProp — style ────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
describe("applyProp — style", () => {
|
|
66
|
+
test("applies style as string via cssText", () => {
|
|
67
|
+
const el = document.createElement("div")
|
|
68
|
+
applyProp(el, "style", "color: red; font-size: 14px")
|
|
69
|
+
expect(el.style.cssText).toContain("color")
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test("applies style as object with camelCase properties", () => {
|
|
73
|
+
const el = document.createElement("div")
|
|
74
|
+
applyProp(el, "style", { fontSize: "14px", color: "blue" })
|
|
75
|
+
// Check that setProperty was called (kebab-case conversion)
|
|
76
|
+
expect(el.style.getPropertyValue("font-size") || el.style.fontSize).toBeTruthy()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("applies style with CSS custom properties (--var)", () => {
|
|
80
|
+
const el = document.createElement("div")
|
|
81
|
+
applyProp(el, "style", { "--main-color": "red" })
|
|
82
|
+
expect(el.style.getPropertyValue("--main-color")).toBe("red")
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test("ignores null/undefined style", () => {
|
|
86
|
+
const el = document.createElement("div")
|
|
87
|
+
// null style removes the attribute
|
|
88
|
+
applyProp(el, "style", null)
|
|
89
|
+
expect(el.hasAttribute("style")).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// ─── applyProp — class ───────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
describe("applyProp — class", () => {
|
|
96
|
+
test("applies class as string", () => {
|
|
97
|
+
const el = document.createElement("div")
|
|
98
|
+
applyProp(el, "class", "foo bar")
|
|
99
|
+
expect(el.getAttribute("class")).toBe("foo bar")
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test("applies class as array", () => {
|
|
103
|
+
const el = document.createElement("div")
|
|
104
|
+
applyProp(el, "class", ["foo", "bar"])
|
|
105
|
+
expect(el.getAttribute("class")).toBe("foo bar")
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("applies class as object (conditionals)", () => {
|
|
109
|
+
const el = document.createElement("div")
|
|
110
|
+
applyProp(el, "class", { active: true, disabled: false, highlight: true })
|
|
111
|
+
const cls = el.getAttribute("class") ?? ""
|
|
112
|
+
expect(cls).toContain("active")
|
|
113
|
+
expect(cls).toContain("highlight")
|
|
114
|
+
expect(cls).not.toContain("disabled")
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test("applies className as alias for class", () => {
|
|
118
|
+
const el = document.createElement("div")
|
|
119
|
+
applyProp(el, "className", "my-class")
|
|
120
|
+
expect(el.getAttribute("class")).toBe("my-class")
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test("sets empty class attribute when value resolves to empty", () => {
|
|
124
|
+
const el = document.createElement("div")
|
|
125
|
+
applyProp(el, "class", "")
|
|
126
|
+
expect(el.getAttribute("class")).toBe("")
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// ─── applyProp — events ──────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
describe("applyProp — events", () => {
|
|
133
|
+
test("adds non-delegated event listener and returns cleanup", () => {
|
|
134
|
+
const el = document.createElement("div")
|
|
135
|
+
const handler = vi.fn()
|
|
136
|
+
// onScroll is non-delegated (scroll doesn't bubble)
|
|
137
|
+
const cleanup = applyProp(el, "onScroll", handler)
|
|
138
|
+
expect(cleanup).not.toBeNull()
|
|
139
|
+
// Dispatch scroll event — non-delegated events use addEventListener directly
|
|
140
|
+
el.dispatchEvent(new Event("scroll"))
|
|
141
|
+
expect(handler).toHaveBeenCalled()
|
|
142
|
+
cleanup?.()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test("adds delegated event via expando property", () => {
|
|
146
|
+
const el = document.createElement("div")
|
|
147
|
+
const handler = vi.fn()
|
|
148
|
+
const cleanup = applyProp(el, "onClick", handler)
|
|
149
|
+
expect(cleanup).not.toBeNull()
|
|
150
|
+
// Check that the delegated expando property is set
|
|
151
|
+
const prop = delegatedPropName("click")
|
|
152
|
+
expect(typeof (el as unknown as Record<string, unknown>)[prop]).toBe("function")
|
|
153
|
+
cleanup?.()
|
|
154
|
+
// After cleanup, expando should be undefined
|
|
155
|
+
expect((el as unknown as Record<string, unknown>)[prop]).toBeUndefined()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test("warns on non-function event handler in dev mode", () => {
|
|
159
|
+
const el = document.createElement("div")
|
|
160
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
161
|
+
const cleanup = applyProp(el, "onClick", "not-a-function")
|
|
162
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("non-function"))
|
|
163
|
+
expect(cleanup).toBeNull()
|
|
164
|
+
warnSpy.mockRestore()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test("cleanup removes non-delegated event listener", () => {
|
|
168
|
+
const el = document.createElement("div")
|
|
169
|
+
const handler = vi.fn()
|
|
170
|
+
const cleanup = applyProp(el, "onScroll", handler)
|
|
171
|
+
cleanup?.()
|
|
172
|
+
el.dispatchEvent(new Event("scroll"))
|
|
173
|
+
expect(handler).not.toHaveBeenCalled()
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// ─── applyProp — reactive (function) values ──────────────────────────────────
|
|
178
|
+
|
|
179
|
+
describe("applyProp — reactive values", () => {
|
|
180
|
+
test("wraps function value in renderEffect", () => {
|
|
181
|
+
const el = document.createElement("div")
|
|
182
|
+
const s = signal("hello")
|
|
183
|
+
const cleanup = applyProp(el, "title", () => s())
|
|
184
|
+
expect(el.getAttribute("title")).toBe("hello")
|
|
185
|
+
s.set("world")
|
|
186
|
+
expect(el.getAttribute("title")).toBe("world")
|
|
187
|
+
cleanup?.()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test("stops tracking after disposal", () => {
|
|
191
|
+
const el = document.createElement("div")
|
|
192
|
+
const s = signal("a")
|
|
193
|
+
const cleanup = applyProp(el, "title", () => s())
|
|
194
|
+
cleanup?.()
|
|
195
|
+
s.set("b")
|
|
196
|
+
expect(el.getAttribute("title")).toBe("a")
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// ─── applyProp — static values ───────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
describe("applyProp — static values", () => {
|
|
203
|
+
test("sets string attribute", () => {
|
|
204
|
+
const el = document.createElement("div")
|
|
205
|
+
applyProp(el, "data-testid", "hello")
|
|
206
|
+
expect(el.getAttribute("data-testid")).toBe("hello")
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test("removes attribute when value is null", () => {
|
|
210
|
+
const el = document.createElement("div")
|
|
211
|
+
el.setAttribute("data-x", "val")
|
|
212
|
+
applyProp(el, "data-x", null)
|
|
213
|
+
expect(el.hasAttribute("data-x")).toBe(false)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test("removes attribute when value is undefined", () => {
|
|
217
|
+
const el = document.createElement("div")
|
|
218
|
+
el.setAttribute("data-x", "val")
|
|
219
|
+
applyProp(el, "data-x", undefined)
|
|
220
|
+
expect(el.hasAttribute("data-x")).toBe(false)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test("sets boolean true as empty attribute", () => {
|
|
224
|
+
const el = document.createElement("input") as HTMLInputElement
|
|
225
|
+
applyProp(el, "disabled", true)
|
|
226
|
+
expect(el.hasAttribute("disabled")).toBe(true)
|
|
227
|
+
expect(el.getAttribute("disabled")).toBe("")
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test("removes attribute for boolean false", () => {
|
|
231
|
+
const el = document.createElement("input") as HTMLInputElement
|
|
232
|
+
el.setAttribute("disabled", "")
|
|
233
|
+
applyProp(el, "disabled", false)
|
|
234
|
+
expect(el.hasAttribute("disabled")).toBe(false)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test("sets DOM property directly when key exists on element", () => {
|
|
238
|
+
const el = document.createElement("input") as HTMLInputElement
|
|
239
|
+
applyProp(el, "value", "hello")
|
|
240
|
+
expect(el.value).toBe("hello")
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test("falls back to setAttribute for unknown attributes", () => {
|
|
244
|
+
const el = document.createElement("div")
|
|
245
|
+
applyProp(el, "data-custom", 42)
|
|
246
|
+
expect(el.getAttribute("data-custom")).toBe("42")
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// ─── applyProp — innerHTML / dangerouslySetInnerHTML ─────────────────────────
|
|
251
|
+
|
|
252
|
+
describe("applyProp — innerHTML", () => {
|
|
253
|
+
test("innerHTML is sanitized", () => {
|
|
254
|
+
const el = document.createElement("div")
|
|
255
|
+
applyProp(el, "innerHTML", '<b>bold</b><script>alert("xss")</script>')
|
|
256
|
+
// Script tag should be stripped by sanitizer
|
|
257
|
+
expect(el.innerHTML).toContain("<b>bold</b>")
|
|
258
|
+
expect(el.innerHTML).not.toContain("<script>")
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test("dangerouslySetInnerHTML bypasses sanitization", () => {
|
|
262
|
+
const el = document.createElement("div")
|
|
263
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
264
|
+
applyProp(el, "dangerouslySetInnerHTML", { __html: "<em>raw</em>" })
|
|
265
|
+
expect(el.innerHTML).toBe("<em>raw</em>")
|
|
266
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
267
|
+
expect.stringContaining("dangerouslySetInnerHTML bypasses sanitization"),
|
|
268
|
+
)
|
|
269
|
+
warnSpy.mockRestore()
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
// ─── applyProp — URL safety ──────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
describe("applyProp — URL safety", () => {
|
|
276
|
+
test("blocks javascript: in href", () => {
|
|
277
|
+
const el = document.createElement("a")
|
|
278
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
279
|
+
applyProp(el, "href", "javascript:alert(1)")
|
|
280
|
+
expect(el.hasAttribute("href")).toBe(false)
|
|
281
|
+
warnSpy.mockRestore()
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test("blocks data: in src", () => {
|
|
285
|
+
const el = document.createElement("img")
|
|
286
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
287
|
+
applyProp(el, "src", "data:text/html,<script>alert(1)</script>")
|
|
288
|
+
expect(el.hasAttribute("src")).toBe(false)
|
|
289
|
+
warnSpy.mockRestore()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test("allows safe URLs", () => {
|
|
293
|
+
const el = document.createElement("a")
|
|
294
|
+
applyProp(el, "href", "https://example.com")
|
|
295
|
+
expect(el.getAttribute("href")).toBe("https://example.com")
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
// ─── sanitizeHtml ────────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
describe("sanitizeHtml", () => {
|
|
302
|
+
test("strips script tags", () => {
|
|
303
|
+
const result = sanitizeHtml('<div>hello</div><script>alert("xss")</script>')
|
|
304
|
+
expect(result).not.toContain("<script>")
|
|
305
|
+
expect(result).toContain("hello")
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test("strips event handler attributes", () => {
|
|
309
|
+
const result = sanitizeHtml('<div onclick="alert(1)">text</div>')
|
|
310
|
+
expect(result).not.toContain("onclick")
|
|
311
|
+
expect(result).toContain("text")
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test("strips javascript: URLs from href", () => {
|
|
315
|
+
const result = sanitizeHtml('<a href="javascript:alert(1)">click</a>')
|
|
316
|
+
expect(result).not.toContain("javascript:")
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test("allows safe HTML tags", () => {
|
|
320
|
+
const result = sanitizeHtml("<b>bold</b> <em>italic</em> <p>paragraph</p>")
|
|
321
|
+
expect(result).toContain("<b>bold</b>")
|
|
322
|
+
expect(result).toContain("<em>italic</em>")
|
|
323
|
+
expect(result).toContain("<p>paragraph</p>")
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test("uses custom sanitizer when set", () => {
|
|
327
|
+
const custom = vi.fn((html: string) => html.replace(/<[^>]+>/g, ""))
|
|
328
|
+
setSanitizer(custom)
|
|
329
|
+
const result = sanitizeHtml("<div>test</div>")
|
|
330
|
+
expect(custom).toHaveBeenCalledWith("<div>test</div>")
|
|
331
|
+
expect(result).toBe("test")
|
|
332
|
+
setSanitizer(null)
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
// ─── delegate.ts ─────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
describe("delegate", () => {
|
|
339
|
+
test("DELEGATED_EVENTS contains common bubbling events", () => {
|
|
340
|
+
expect(DELEGATED_EVENTS.has("click")).toBe(true)
|
|
341
|
+
expect(DELEGATED_EVENTS.has("input")).toBe(true)
|
|
342
|
+
expect(DELEGATED_EVENTS.has("keydown")).toBe(true)
|
|
343
|
+
expect(DELEGATED_EVENTS.has("submit")).toBe(true)
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
test("DELEGATED_EVENTS does not contain non-bubbling events", () => {
|
|
347
|
+
expect(DELEGATED_EVENTS.has("focus")).toBe(false)
|
|
348
|
+
expect(DELEGATED_EVENTS.has("blur")).toBe(false)
|
|
349
|
+
expect(DELEGATED_EVENTS.has("mouseenter")).toBe(false)
|
|
350
|
+
expect(DELEGATED_EVENTS.has("mouseleave")).toBe(false)
|
|
351
|
+
expect(DELEGATED_EVENTS.has("load")).toBe(false)
|
|
352
|
+
expect(DELEGATED_EVENTS.has("scroll")).toBe(false)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
test("delegatedPropName returns __ev_{eventName}", () => {
|
|
356
|
+
expect(delegatedPropName("click")).toBe("__ev_click")
|
|
357
|
+
expect(delegatedPropName("input")).toBe("__ev_input")
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test("setupDelegation installs listeners and dispatches to expandos", () => {
|
|
361
|
+
const container = document.createElement("div")
|
|
362
|
+
document.body.appendChild(container)
|
|
363
|
+
setupDelegation(container)
|
|
364
|
+
|
|
365
|
+
const child = document.createElement("button")
|
|
366
|
+
container.appendChild(child)
|
|
367
|
+
|
|
368
|
+
const handler = vi.fn()
|
|
369
|
+
const prop = delegatedPropName("click")
|
|
370
|
+
;(child as unknown as Record<string, unknown>)[prop] = handler
|
|
371
|
+
|
|
372
|
+
child.click()
|
|
373
|
+
expect(handler).toHaveBeenCalled()
|
|
374
|
+
|
|
375
|
+
container.remove()
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
test("setupDelegation is idempotent (safe to call twice)", () => {
|
|
379
|
+
const container = document.createElement("div")
|
|
380
|
+
document.body.appendChild(container)
|
|
381
|
+
// Should not throw when called twice
|
|
382
|
+
setupDelegation(container)
|
|
383
|
+
setupDelegation(container)
|
|
384
|
+
|
|
385
|
+
const child = document.createElement("span")
|
|
386
|
+
container.appendChild(child)
|
|
387
|
+
|
|
388
|
+
let callCount = 0
|
|
389
|
+
const prop = delegatedPropName("click")
|
|
390
|
+
;(child as unknown as Record<string, unknown>)[prop] = () => {
|
|
391
|
+
callCount++
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
child.click()
|
|
395
|
+
// Should only fire once (not duplicated by double setup)
|
|
396
|
+
expect(callCount).toBe(1)
|
|
397
|
+
|
|
398
|
+
container.remove()
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
test("delegation walks up the DOM tree (ancestor handlers fire)", () => {
|
|
402
|
+
const container = document.createElement("div")
|
|
403
|
+
document.body.appendChild(container)
|
|
404
|
+
setupDelegation(container)
|
|
405
|
+
|
|
406
|
+
const parent = document.createElement("div")
|
|
407
|
+
const child = document.createElement("span")
|
|
408
|
+
parent.appendChild(child)
|
|
409
|
+
container.appendChild(parent)
|
|
410
|
+
|
|
411
|
+
const parentHandler = vi.fn()
|
|
412
|
+
const childHandler = vi.fn()
|
|
413
|
+
const prop = delegatedPropName("click")
|
|
414
|
+
;(parent as unknown as Record<string, unknown>)[prop] = parentHandler
|
|
415
|
+
;(child as unknown as Record<string, unknown>)[prop] = childHandler
|
|
416
|
+
|
|
417
|
+
child.click()
|
|
418
|
+
expect(childHandler).toHaveBeenCalled()
|
|
419
|
+
expect(parentHandler).toHaveBeenCalled()
|
|
420
|
+
|
|
421
|
+
container.remove()
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
test("delegation respects stopPropagation", () => {
|
|
425
|
+
const container = document.createElement("div")
|
|
426
|
+
document.body.appendChild(container)
|
|
427
|
+
setupDelegation(container)
|
|
428
|
+
|
|
429
|
+
const parent = document.createElement("div")
|
|
430
|
+
const child = document.createElement("span")
|
|
431
|
+
parent.appendChild(child)
|
|
432
|
+
container.appendChild(parent)
|
|
433
|
+
|
|
434
|
+
const parentHandler = vi.fn()
|
|
435
|
+
const childHandler = vi.fn((e: Event) => e.stopPropagation())
|
|
436
|
+
const prop = delegatedPropName("click")
|
|
437
|
+
;(parent as unknown as Record<string, unknown>)[prop] = parentHandler
|
|
438
|
+
;(child as unknown as Record<string, unknown>)[prop] = childHandler
|
|
439
|
+
|
|
440
|
+
child.click()
|
|
441
|
+
expect(childHandler).toHaveBeenCalled()
|
|
442
|
+
expect(parentHandler).not.toHaveBeenCalled()
|
|
443
|
+
|
|
444
|
+
container.remove()
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test("delegation skips non-function expando values", () => {
|
|
448
|
+
const container = document.createElement("div")
|
|
449
|
+
document.body.appendChild(container)
|
|
450
|
+
setupDelegation(container)
|
|
451
|
+
|
|
452
|
+
const child = document.createElement("span")
|
|
453
|
+
container.appendChild(child)
|
|
454
|
+
|
|
455
|
+
const prop = delegatedPropName("click")
|
|
456
|
+
;(child as unknown as Record<string, unknown>)[prop] = "not-a-function"
|
|
457
|
+
|
|
458
|
+
// Should not throw
|
|
459
|
+
expect(() => child.click()).not.toThrow()
|
|
460
|
+
|
|
461
|
+
container.remove()
|
|
462
|
+
})
|
|
463
|
+
})
|