@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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +14 -12
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/delegate.ts +2 -2
- package/src/hydrate.ts +8 -4
- package/src/keep-alive.ts +2 -1
- package/src/mount.ts +6 -5
- package/src/props.ts +7 -5
- package/src/tests/props.test.ts +463 -0
- package/src/tests/show-context.test.ts +177 -0
- package/src/tests/transition.test.ts +550 -0
package/lib/types/index.d.ts.map
CHANGED
|
@@ -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":";;;;;;AAoBA;;;;;AA8BA;;;;;AAWA;;;;cAzCa,gBAAA,EAAgB,GAAA;;;;ACJ7B;iBDkCgB,iBAAA,CAAkB,SAAA;;;;;iBAWlB,eAAA,CAAgB,SAAA,EAAW,OAAA;;;;;;AAzC3C;;;;;AA8BA;;;;;AAWA;;UC7CiB,sBAAA;EACf,EAAA;EACA,IAAA;;EAEA,EAAA,EAAI,OAAA;EACJ,QAAA;EACA,QAAA;AAAA;AAAA,UAGe,cAAA;EAAA,SACN,OAAA;EACT,gBAAA,IAAoB,sBAAA;EACpB,gBAAA,IAAoB,sBAAA;EACpB,SAAA,CAAU,EAAA;EACV,gBAAA,CAAiB,EAAA,GAAK,KAAA,EAAO,sBAAA;EAC7B,kBAAA,CAAmB,EAAA,GAAK,EAAA;EATxB;EAWA,aAAA;EACA,cAAA;AAAA;;;;;;AAlBF;;;;;;;;;;;
|
|
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.
|
|
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/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]
|
|
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 &&
|
|
323
|
+
(vnode.children ?? []).length > 0 &&
|
|
324
|
+
(vnode.props as Record<string, unknown>).children === undefined
|
|
324
325
|
? {
|
|
325
326
|
...vnode.props,
|
|
326
|
-
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
|
|
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
|
|
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
|
-
|
|
264
|
+
children.length > 0 && (vnode.props as Record<string, unknown>).children === undefined
|
|
264
265
|
? {
|
|
265
266
|
...vnode.props,
|
|
266
|
-
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 (
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
`
|
|
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
|
+
})
|