@pyreon/runtime-dom 0.11.2 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/delegate.ts","../../../src/devtools.ts","../../../src/hydrate.ts","../../../src/hydration-debug.ts","../../../src/keep-alive.ts","../../../src/mount.ts","../../../src/props.ts","../../../src/template.ts","../../../src/transition.ts","../../../src/transition-group.ts","../../../src/index.ts"],"mappings":"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"}
1
+ {"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/delegate.ts","../../../src/devtools.ts","../../../src/hydrate.ts","../../../src/hydration-debug.ts","../../../src/keep-alive.ts","../../../src/mount.ts","../../../src/props.ts","../../../src/template.ts","../../../src/transition.ts","../../../src/transition-group.ts","../../../src/index.ts"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/runtime-dom",
3
- "version": "0.11.2",
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.2",
43
- "@pyreon/reactivity": "^0.11.2"
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/delegate.ts CHANGED
@@ -68,8 +68,8 @@ export function setupDelegation(container: Element): void {
68
68
  container.addEventListener(eventName, (e: Event) => {
69
69
  let el = e.target as (HTMLElement & Record<string, unknown>) | null
70
70
  while (el && el !== container) {
71
- const handler = el[prop] as EventListener | undefined
72
- if (handler) {
71
+ const handler = el[prop]
72
+ if (typeof handler === "function") {
73
73
  batch(() => handler(e))
74
74
  // Don't break — allow ancestor handlers too (consistent with addEventListener)
75
75
  // But if stopPropagation was called, stop walking
package/src/hydrate.ts CHANGED
@@ -150,7 +150,7 @@ function hydrateVNode(
150
150
  path: string,
151
151
  ): [Cleanup, ChildNode | null] {
152
152
  if (vnode.type === Fragment) {
153
- return hydrateChildren(vnode.children, domNode, parent, anchor, path)
153
+ return hydrateChildren(vnode.children ?? [], domNode, parent, anchor, path)
154
154
  }
155
155
 
156
156
  if (vnode.type === ForSymbol) {
@@ -241,7 +241,7 @@ function hydrateElement(
241
241
 
242
242
  // Hydrate children
243
243
  const firstChild = firstReal(el.firstChild as ChildNode | null)
244
- const [childCleanup] = hydrateChildren(vnode.children, firstChild, el, null, elPath)
244
+ const [childCleanup] = hydrateChildren(vnode.children ?? [], firstChild, el, null, elPath)
245
245
  cleanups.push(childCleanup)
246
246
 
247
247
  // Set ref
@@ -320,10 +320,14 @@ function hydrateComponent(
320
320
  // Function.name is always a string per spec; || handles empty string, avoids uncoverable ?? branch
321
321
  const componentName = ((vnode.type as ComponentFn).name || "Anonymous") as string
322
322
  const mergedProps =
323
- vnode.children.length > 0 && (vnode.props as Record<string, unknown>).children === undefined
323
+ (vnode.children ?? []).length > 0 &&
324
+ (vnode.props as Record<string, unknown>).children === undefined
324
325
  ? {
325
326
  ...vnode.props,
326
- children: vnode.children.length === 1 ? vnode.children[0] : vnode.children,
327
+ children:
328
+ (vnode.children ?? []).length === 1
329
+ ? (vnode.children ?? [])[0]
330
+ : (vnode.children ?? []),
327
331
  }
328
332
  : vnode.props
329
333
 
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 as HTMLElement
47
+ const container = containerRef.current
48
+ if (!container) return
48
49
 
49
50
  const e = effect(() => {
50
51
  const isActive = props.active?.() ?? true
package/src/mount.ts CHANGED
@@ -118,7 +118,7 @@ export function mountChild(
118
118
  // VNode — element, component, fragment, For, Portal
119
119
  const vnode = child as VNode
120
120
 
121
- if (vnode.type === Fragment) return mountChildren(vnode.children, parent, anchor)
121
+ if (vnode.type === Fragment) return mountChildren(vnode.children ?? [], parent, anchor)
122
122
 
123
123
  if (vnode.type === (ForSymbol as unknown as string)) {
124
124
  const { each, by, children } = vnode.props as unknown as ForProps<unknown>
@@ -183,7 +183,7 @@ const VOID_ELEMENTS = new Set([
183
183
  function mountElement(vnode: VNode, parent: Node, anchor: Node | null): Cleanup {
184
184
  const el = document.createElement(vnode.type as string)
185
185
 
186
- if (__DEV__ && vnode.children.length > 0 && VOID_ELEMENTS.has(vnode.type as string)) {
186
+ if (__DEV__ && (vnode.children?.length ?? 0) > 0 && VOID_ELEMENTS.has(vnode.type as string)) {
187
187
  console.warn(
188
188
  `[Pyreon] <${vnode.type as string}> is a void element and cannot have children. ` +
189
189
  "Children passed to void elements will be ignored by the browser.",
@@ -196,7 +196,7 @@ function mountElement(vnode: VNode, parent: Node, anchor: Node | null): Cleanup
196
196
 
197
197
  // Mount children inside element context — nested elements can skip DOM removal closures
198
198
  _elementDepth++
199
- const childCleanup = mountChildren(vnode.children, el, null)
199
+ const childCleanup = mountChildren(vnode.children ?? [], el, null)
200
200
  _elementDepth--
201
201
 
202
202
  parent.insertBefore(el, anchor)
@@ -259,11 +259,12 @@ function mountComponent(
259
259
  _mountingStack.push(compId)
260
260
 
261
261
  // Merge vnode.children into props.children if not already set
262
+ const children = vnode.children ?? []
262
263
  const mergedProps =
263
- vnode.children.length > 0 && (vnode.props as Record<string, unknown>).children === undefined
264
+ children.length > 0 && (vnode.props as Record<string, unknown>).children === undefined
264
265
  ? {
265
266
  ...vnode.props,
266
- children: vnode.children.length === 1 ? vnode.children[0] : vnode.children,
267
+ children: children.length === 1 ? children[0] : children,
267
268
  }
268
269
  : vnode.props
269
270
 
package/src/props.ts CHANGED
@@ -203,11 +203,13 @@ export function applyProps(el: Element, props: Props): Cleanup | null {
203
203
  * Bind an event handler (onClick → "click") with batching + delegation support.
204
204
  */
205
205
  function applyEventProp(el: Element, key: string, value: unknown): Cleanup | null {
206
- if (__DEV__ && typeof value !== "function") {
207
- console.warn(
208
- `[Pyreon] Event handler "${key}" received a non-function value (${typeof value}). ` +
209
- `Expected a function. Did you mean ${key}={() => ...}?`,
210
- )
206
+ if (typeof value !== "function") {
207
+ if (__DEV__) {
208
+ console.warn(
209
+ `[Pyreon] Event handler "${key}" received a non-function value (${typeof value}). ` +
210
+ `Expected a function. Did you mean ${key}={() => ...}?`,
211
+ )
212
+ }
211
213
  return null
212
214
  }
213
215
  const eventName = key[2]?.toLowerCase() + key.slice(3)
@@ -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
+ })