@pyreon/runtime-dom 0.24.5 → 0.24.6
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 +5 -9
- package/src/delegate.ts +0 -98
- package/src/devtools.ts +0 -339
- package/src/env.d.ts +0 -6
- package/src/hydrate.ts +0 -450
- package/src/hydration-debug.ts +0 -129
- package/src/index.ts +0 -83
- package/src/keep-alive-entry.ts +0 -3
- package/src/keep-alive.ts +0 -83
- package/src/manifest.ts +0 -236
- package/src/mount.ts +0 -597
- package/src/nodes.ts +0 -896
- package/src/props.ts +0 -474
- package/src/template.ts +0 -523
- package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
- package/src/tests/callback-ref-unmount.test.ts +0 -52
- package/src/tests/compiler-integration.test.tsx +0 -508
- package/src/tests/coverage-gaps.test.ts +0 -3183
- package/src/tests/coverage.test.ts +0 -1140
- package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
- package/src/tests/dev-gate-pattern.test.ts +0 -46
- package/src/tests/dev-gate-treeshake.test.ts +0 -256
- package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
- package/src/tests/fanout-repro.test.tsx +0 -219
- package/src/tests/hydration-integration.test.tsx +0 -540
- package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
- package/src/tests/lifecycle-integration.test.tsx +0 -342
- package/src/tests/lis-prepend.browser.test.ts +0 -99
- package/src/tests/manifest-snapshot.test.ts +0 -85
- package/src/tests/mount.test.ts +0 -3529
- package/src/tests/native-markers.test.ts +0 -19
- package/src/tests/props.test.ts +0 -581
- package/src/tests/reactive-props.test.ts +0 -270
- package/src/tests/real-world-integration.test.tsx +0 -714
- package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
- package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
- package/src/tests/rs-collapse-h.browser.test.ts +0 -152
- package/src/tests/rs-collapse-h.test.ts +0 -237
- package/src/tests/rs-collapse.browser.test.ts +0 -128
- package/src/tests/runtime-dom.browser.test.ts +0 -409
- package/src/tests/setup.ts +0 -3
- package/src/tests/show-context.test.ts +0 -270
- package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
- package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
- package/src/tests/style-key-removal.browser.test.ts +0 -54
- package/src/tests/style-key-removal.test.ts +0 -88
- package/src/tests/template.test.ts +0 -383
- package/src/tests/transition-timeout-leak.test.ts +0 -126
- package/src/tests/transition.test.ts +0 -568
- package/src/tests/verified-correct-probes.test.ts +0 -56
- package/src/transition-entry.ts +0 -7
- package/src/transition-group.ts +0 -350
- package/src/transition.ts +0 -245
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { isNativeCompat } from '@pyreon/core'
|
|
2
|
-
import { describe, expect, it } from 'vitest'
|
|
3
|
-
import { KeepAlive } from '../keep-alive'
|
|
4
|
-
import { Transition } from '../transition'
|
|
5
|
-
import { TransitionGroup } from '../transition-group'
|
|
6
|
-
|
|
7
|
-
// Marker-presence assertion (PR 3 lock-in). Bisect-verified: removing
|
|
8
|
-
// `nativeCompat(...)` from any of these files fails the corresponding test.
|
|
9
|
-
describe('native-compat markers — @pyreon/runtime-dom', () => {
|
|
10
|
-
it('Transition is marked native', () => {
|
|
11
|
-
expect(isNativeCompat(Transition)).toBe(true)
|
|
12
|
-
})
|
|
13
|
-
it('TransitionGroup is marked native', () => {
|
|
14
|
-
expect(isNativeCompat(TransitionGroup)).toBe(true)
|
|
15
|
-
})
|
|
16
|
-
it('KeepAlive is marked native', () => {
|
|
17
|
-
expect(isNativeCompat(KeepAlive)).toBe(true)
|
|
18
|
-
})
|
|
19
|
-
})
|
package/src/tests/props.test.ts
DELETED
|
@@ -1,581 +0,0 @@
|
|
|
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 (no warning — name is the warning, like React)', () => {
|
|
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
|
-
// No warning — the name "dangerouslySetInnerHTML" is the warning.
|
|
267
|
-
// React doesn't log here, neither do we. Previously this warned on
|
|
268
|
-
// every prop application, flooding the console on every re-render.
|
|
269
|
-
expect(warnSpy).not.toHaveBeenCalled()
|
|
270
|
-
warnSpy.mockRestore()
|
|
271
|
-
})
|
|
272
|
-
|
|
273
|
-
test('reactive innerHTML accessor — function value is called, not stringified', async () => {
|
|
274
|
-
// Regression: the JSX compiler emits `innerHTML={getIcon(props.x ? "a" : "b")}`
|
|
275
|
-
// as a `() => …` accessor. Without function-value handling here, the
|
|
276
|
-
// closure was set as literal text — `() => getIcon(...)` rendered
|
|
277
|
-
// verbatim instead of the SVG.
|
|
278
|
-
const { signal } = await import('@pyreon/reactivity')
|
|
279
|
-
const el = document.createElement('div')
|
|
280
|
-
const which = signal<'a' | 'b'>('a')
|
|
281
|
-
const cleanup = applyProp(el, 'innerHTML', () => `<span data-x="${which()}">x</span>`)
|
|
282
|
-
expect(el.querySelector('[data-x="a"]')).not.toBeNull()
|
|
283
|
-
expect(el.innerHTML).not.toContain('=>')
|
|
284
|
-
which.set('b')
|
|
285
|
-
expect(el.querySelector('[data-x="b"]')).not.toBeNull()
|
|
286
|
-
cleanup?.()
|
|
287
|
-
})
|
|
288
|
-
|
|
289
|
-
test('reactive dangerouslySetInnerHTML accessor — function value is called, not stringified', async () => {
|
|
290
|
-
const { signal } = await import('@pyreon/reactivity')
|
|
291
|
-
const el = document.createElement('div')
|
|
292
|
-
const html = signal('<em>one</em>')
|
|
293
|
-
const cleanup = applyProp(el, 'dangerouslySetInnerHTML', () => ({ __html: html() }))
|
|
294
|
-
expect(el.innerHTML).toBe('<em>one</em>')
|
|
295
|
-
html.set('<em>two</em>')
|
|
296
|
-
expect(el.innerHTML).toBe('<em>two</em>')
|
|
297
|
-
cleanup?.()
|
|
298
|
-
})
|
|
299
|
-
|
|
300
|
-
test('dev warning fires if a function reaches applyStaticProp directly (defensive guard)', () => {
|
|
301
|
-
// applyStaticProp is internal — reachable only if a future special-case
|
|
302
|
-
// branch in applyProp bypasses the reactive-wrap dance. The dev guard
|
|
303
|
-
// catches that regression at first render.
|
|
304
|
-
const el = document.createElement('div')
|
|
305
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
306
|
-
// Indirect: trigger by routing a function through `applyProp` for a
|
|
307
|
-
// key that DOESN'T have a special case — exercises the reactive path,
|
|
308
|
-
// which calls the accessor + passes the result. The accessor itself
|
|
309
|
-
// returning a function would surface the warning.
|
|
310
|
-
applyProp(el, 'innerHTML', () => () => '<em>nested</em>')
|
|
311
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
312
|
-
expect.stringContaining('applyStaticProp received a function for "innerHTML"'),
|
|
313
|
-
)
|
|
314
|
-
warnSpy.mockRestore()
|
|
315
|
-
})
|
|
316
|
-
})
|
|
317
|
-
|
|
318
|
-
// Comprehensive sweep: every string-typed sink must handle reactive
|
|
319
|
-
// (function) values. The original bug was specific to innerHTML, but the
|
|
320
|
-
// structural fix should cover ALL sinks the same way. These tests assert
|
|
321
|
-
// that.
|
|
322
|
-
describe('applyProp — reactive function values across all sink kinds', () => {
|
|
323
|
-
test('reactive href accessor on <a>', async () => {
|
|
324
|
-
const { signal } = await import('@pyreon/reactivity')
|
|
325
|
-
const el = document.createElement('a')
|
|
326
|
-
const path = signal('/one')
|
|
327
|
-
const cleanup = applyProp(el, 'href', () => path())
|
|
328
|
-
expect(el.getAttribute('href')).toBe('/one')
|
|
329
|
-
path.set('/two')
|
|
330
|
-
expect(el.getAttribute('href')).toBe('/two')
|
|
331
|
-
cleanup?.()
|
|
332
|
-
})
|
|
333
|
-
|
|
334
|
-
test('reactive src accessor on <img>', async () => {
|
|
335
|
-
const { signal } = await import('@pyreon/reactivity')
|
|
336
|
-
const el = document.createElement('img')
|
|
337
|
-
const url = signal('/a.png')
|
|
338
|
-
const cleanup = applyProp(el, 'src', () => url())
|
|
339
|
-
// <img> exposes `src` as a normalized absolute URL — assert via getAttribute
|
|
340
|
-
expect(el.getAttribute('src')).toBe('/a.png')
|
|
341
|
-
url.set('/b.png')
|
|
342
|
-
expect(el.getAttribute('src')).toBe('/b.png')
|
|
343
|
-
cleanup?.()
|
|
344
|
-
})
|
|
345
|
-
|
|
346
|
-
test('reactive value accessor on <input>', async () => {
|
|
347
|
-
const { signal } = await import('@pyreon/reactivity')
|
|
348
|
-
const el = document.createElement('input')
|
|
349
|
-
const val = signal('alpha')
|
|
350
|
-
const cleanup = applyProp(el, 'value', () => val())
|
|
351
|
-
expect((el as HTMLInputElement).value).toBe('alpha')
|
|
352
|
-
val.set('beta')
|
|
353
|
-
expect((el as HTMLInputElement).value).toBe('beta')
|
|
354
|
-
cleanup?.()
|
|
355
|
-
})
|
|
356
|
-
|
|
357
|
-
test('reactive title accessor (data attribute pattern)', async () => {
|
|
358
|
-
const { signal } = await import('@pyreon/reactivity')
|
|
359
|
-
const el = document.createElement('div')
|
|
360
|
-
const tip = signal('hello')
|
|
361
|
-
const cleanup = applyProp(el, 'title', () => tip())
|
|
362
|
-
expect(el.getAttribute('title')).toBe('hello')
|
|
363
|
-
tip.set('world')
|
|
364
|
-
expect(el.getAttribute('title')).toBe('world')
|
|
365
|
-
cleanup?.()
|
|
366
|
-
})
|
|
367
|
-
|
|
368
|
-
test('reactive class accessor (string form)', async () => {
|
|
369
|
-
const { signal } = await import('@pyreon/reactivity')
|
|
370
|
-
const el = document.createElement('div')
|
|
371
|
-
const cls = signal('one')
|
|
372
|
-
const cleanup = applyProp(el, 'class', () => cls())
|
|
373
|
-
expect(el.className).toBe('one')
|
|
374
|
-
cls.set('two')
|
|
375
|
-
expect(el.className).toBe('two')
|
|
376
|
-
cleanup?.()
|
|
377
|
-
})
|
|
378
|
-
|
|
379
|
-
test('reactive style accessor (object form)', async () => {
|
|
380
|
-
const { signal } = await import('@pyreon/reactivity')
|
|
381
|
-
const el = document.createElement('div')
|
|
382
|
-
const color = signal('red')
|
|
383
|
-
const cleanup = applyProp(el, 'style', () => ({ color: color() }))
|
|
384
|
-
expect(el.style.color).toBe('red')
|
|
385
|
-
color.set('blue')
|
|
386
|
-
expect(el.style.color).toBe('blue')
|
|
387
|
-
cleanup?.()
|
|
388
|
-
})
|
|
389
|
-
})
|
|
390
|
-
|
|
391
|
-
// ─── applyProp — URL safety ──────────────────────────────────────────────────
|
|
392
|
-
|
|
393
|
-
describe('applyProp — URL safety', () => {
|
|
394
|
-
test('blocks javascript: in href', () => {
|
|
395
|
-
const el = document.createElement('a')
|
|
396
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
397
|
-
applyProp(el, 'href', 'javascript:alert(1)')
|
|
398
|
-
expect(el.hasAttribute('href')).toBe(false)
|
|
399
|
-
warnSpy.mockRestore()
|
|
400
|
-
})
|
|
401
|
-
|
|
402
|
-
test('blocks data: in src', () => {
|
|
403
|
-
const el = document.createElement('img')
|
|
404
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
405
|
-
applyProp(el, 'src', 'data:text/html,<script>alert(1)</script>')
|
|
406
|
-
expect(el.hasAttribute('src')).toBe(false)
|
|
407
|
-
warnSpy.mockRestore()
|
|
408
|
-
})
|
|
409
|
-
|
|
410
|
-
test('allows safe URLs', () => {
|
|
411
|
-
const el = document.createElement('a')
|
|
412
|
-
applyProp(el, 'href', 'https://example.com')
|
|
413
|
-
expect(el.getAttribute('href')).toBe('https://example.com')
|
|
414
|
-
})
|
|
415
|
-
})
|
|
416
|
-
|
|
417
|
-
// ─── sanitizeHtml ────────────────────────────────────────────────────────────
|
|
418
|
-
|
|
419
|
-
describe('sanitizeHtml', () => {
|
|
420
|
-
test('strips script tags', () => {
|
|
421
|
-
const result = sanitizeHtml('<div>hello</div><script>alert("xss")</script>')
|
|
422
|
-
expect(result).not.toContain('<script>')
|
|
423
|
-
expect(result).toContain('hello')
|
|
424
|
-
})
|
|
425
|
-
|
|
426
|
-
test('strips event handler attributes', () => {
|
|
427
|
-
const result = sanitizeHtml('<div onclick="alert(1)">text</div>')
|
|
428
|
-
expect(result).not.toContain('onclick')
|
|
429
|
-
expect(result).toContain('text')
|
|
430
|
-
})
|
|
431
|
-
|
|
432
|
-
test('strips javascript: URLs from href', () => {
|
|
433
|
-
const result = sanitizeHtml('<a href="javascript:alert(1)">click</a>')
|
|
434
|
-
expect(result).not.toContain('javascript:')
|
|
435
|
-
})
|
|
436
|
-
|
|
437
|
-
test('allows safe HTML tags', () => {
|
|
438
|
-
const result = sanitizeHtml('<b>bold</b> <em>italic</em> <p>paragraph</p>')
|
|
439
|
-
expect(result).toContain('<b>bold</b>')
|
|
440
|
-
expect(result).toContain('<em>italic</em>')
|
|
441
|
-
expect(result).toContain('<p>paragraph</p>')
|
|
442
|
-
})
|
|
443
|
-
|
|
444
|
-
test('uses custom sanitizer when set', () => {
|
|
445
|
-
const custom = vi.fn((html: string) => html.replace(/<[^>]+>/g, ''))
|
|
446
|
-
setSanitizer(custom)
|
|
447
|
-
const result = sanitizeHtml('<div>test</div>')
|
|
448
|
-
expect(custom).toHaveBeenCalledWith('<div>test</div>')
|
|
449
|
-
expect(result).toBe('test')
|
|
450
|
-
setSanitizer(null)
|
|
451
|
-
})
|
|
452
|
-
})
|
|
453
|
-
|
|
454
|
-
// ─── delegate.ts ─────────────────────────────────────────────────────────────
|
|
455
|
-
|
|
456
|
-
describe('delegate', () => {
|
|
457
|
-
test('DELEGATED_EVENTS contains common bubbling events', () => {
|
|
458
|
-
expect(DELEGATED_EVENTS.has('click')).toBe(true)
|
|
459
|
-
expect(DELEGATED_EVENTS.has('input')).toBe(true)
|
|
460
|
-
expect(DELEGATED_EVENTS.has('keydown')).toBe(true)
|
|
461
|
-
expect(DELEGATED_EVENTS.has('submit')).toBe(true)
|
|
462
|
-
})
|
|
463
|
-
|
|
464
|
-
test('DELEGATED_EVENTS does not contain non-bubbling events', () => {
|
|
465
|
-
expect(DELEGATED_EVENTS.has('focus')).toBe(false)
|
|
466
|
-
expect(DELEGATED_EVENTS.has('blur')).toBe(false)
|
|
467
|
-
expect(DELEGATED_EVENTS.has('mouseenter')).toBe(false)
|
|
468
|
-
expect(DELEGATED_EVENTS.has('mouseleave')).toBe(false)
|
|
469
|
-
expect(DELEGATED_EVENTS.has('load')).toBe(false)
|
|
470
|
-
expect(DELEGATED_EVENTS.has('scroll')).toBe(false)
|
|
471
|
-
})
|
|
472
|
-
|
|
473
|
-
test('delegatedPropName returns __ev_{eventName}', () => {
|
|
474
|
-
expect(delegatedPropName('click')).toBe('__ev_click')
|
|
475
|
-
expect(delegatedPropName('input')).toBe('__ev_input')
|
|
476
|
-
})
|
|
477
|
-
|
|
478
|
-
test('setupDelegation installs listeners and dispatches to expandos', () => {
|
|
479
|
-
const container = document.createElement('div')
|
|
480
|
-
document.body.appendChild(container)
|
|
481
|
-
setupDelegation(container)
|
|
482
|
-
|
|
483
|
-
const child = document.createElement('button')
|
|
484
|
-
container.appendChild(child)
|
|
485
|
-
|
|
486
|
-
const handler = vi.fn()
|
|
487
|
-
const prop = delegatedPropName('click')
|
|
488
|
-
;(child as unknown as Record<string, unknown>)[prop] = handler
|
|
489
|
-
|
|
490
|
-
child.click()
|
|
491
|
-
expect(handler).toHaveBeenCalled()
|
|
492
|
-
|
|
493
|
-
container.remove()
|
|
494
|
-
})
|
|
495
|
-
|
|
496
|
-
test('setupDelegation is idempotent (safe to call twice)', () => {
|
|
497
|
-
const container = document.createElement('div')
|
|
498
|
-
document.body.appendChild(container)
|
|
499
|
-
// Should not throw when called twice
|
|
500
|
-
setupDelegation(container)
|
|
501
|
-
setupDelegation(container)
|
|
502
|
-
|
|
503
|
-
const child = document.createElement('span')
|
|
504
|
-
container.appendChild(child)
|
|
505
|
-
|
|
506
|
-
let callCount = 0
|
|
507
|
-
const prop = delegatedPropName('click')
|
|
508
|
-
;(child as unknown as Record<string, unknown>)[prop] = () => {
|
|
509
|
-
callCount++
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
child.click()
|
|
513
|
-
// Should only fire once (not duplicated by double setup)
|
|
514
|
-
expect(callCount).toBe(1)
|
|
515
|
-
|
|
516
|
-
container.remove()
|
|
517
|
-
})
|
|
518
|
-
|
|
519
|
-
test('delegation walks up the DOM tree (ancestor handlers fire)', () => {
|
|
520
|
-
const container = document.createElement('div')
|
|
521
|
-
document.body.appendChild(container)
|
|
522
|
-
setupDelegation(container)
|
|
523
|
-
|
|
524
|
-
const parent = document.createElement('div')
|
|
525
|
-
const child = document.createElement('span')
|
|
526
|
-
parent.appendChild(child)
|
|
527
|
-
container.appendChild(parent)
|
|
528
|
-
|
|
529
|
-
const parentHandler = vi.fn()
|
|
530
|
-
const childHandler = vi.fn()
|
|
531
|
-
const prop = delegatedPropName('click')
|
|
532
|
-
;(parent as unknown as Record<string, unknown>)[prop] = parentHandler
|
|
533
|
-
;(child as unknown as Record<string, unknown>)[prop] = childHandler
|
|
534
|
-
|
|
535
|
-
child.click()
|
|
536
|
-
expect(childHandler).toHaveBeenCalled()
|
|
537
|
-
expect(parentHandler).toHaveBeenCalled()
|
|
538
|
-
|
|
539
|
-
container.remove()
|
|
540
|
-
})
|
|
541
|
-
|
|
542
|
-
test('delegation respects stopPropagation', () => {
|
|
543
|
-
const container = document.createElement('div')
|
|
544
|
-
document.body.appendChild(container)
|
|
545
|
-
setupDelegation(container)
|
|
546
|
-
|
|
547
|
-
const parent = document.createElement('div')
|
|
548
|
-
const child = document.createElement('span')
|
|
549
|
-
parent.appendChild(child)
|
|
550
|
-
container.appendChild(parent)
|
|
551
|
-
|
|
552
|
-
const parentHandler = vi.fn()
|
|
553
|
-
const childHandler = vi.fn((e: Event) => e.stopPropagation())
|
|
554
|
-
const prop = delegatedPropName('click')
|
|
555
|
-
;(parent as unknown as Record<string, unknown>)[prop] = parentHandler
|
|
556
|
-
;(child as unknown as Record<string, unknown>)[prop] = childHandler
|
|
557
|
-
|
|
558
|
-
child.click()
|
|
559
|
-
expect(childHandler).toHaveBeenCalled()
|
|
560
|
-
expect(parentHandler).not.toHaveBeenCalled()
|
|
561
|
-
|
|
562
|
-
container.remove()
|
|
563
|
-
})
|
|
564
|
-
|
|
565
|
-
test('delegation skips non-function expando values', () => {
|
|
566
|
-
const container = document.createElement('div')
|
|
567
|
-
document.body.appendChild(container)
|
|
568
|
-
setupDelegation(container)
|
|
569
|
-
|
|
570
|
-
const child = document.createElement('span')
|
|
571
|
-
container.appendChild(child)
|
|
572
|
-
|
|
573
|
-
const prop = delegatedPropName('click')
|
|
574
|
-
;(child as unknown as Record<string, unknown>)[prop] = 'not-a-function'
|
|
575
|
-
|
|
576
|
-
// Should not throw
|
|
577
|
-
expect(() => child.click()).not.toThrow()
|
|
578
|
-
|
|
579
|
-
container.remove()
|
|
580
|
-
})
|
|
581
|
-
})
|