@pyreon/runtime-dom 0.11.3 → 0.11.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/runtime-dom",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
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.3",
43
- "@pyreon/reactivity": "^0.11.3"
42
+ "@pyreon/core": "^0.11.5",
43
+ "@pyreon/reactivity": "^0.11.5"
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 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
@@ -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
+ })