@pyreon/runtime-dom 0.11.5 → 0.11.7
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/README.md +16 -22
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +4 -3
- package/lib/index.js.map +1 -1
- package/package.json +12 -12
- package/src/delegate.ts +25 -25
- package/src/devtools.ts +37 -37
- package/src/hydrate.ts +30 -30
- package/src/hydration-debug.ts +2 -2
- package/src/index.ts +20 -20
- package/src/keep-alive.ts +6 -6
- package/src/mount.ts +46 -46
- package/src/nodes.ts +31 -19
- package/src/props.ts +93 -93
- package/src/template.ts +6 -6
- package/src/tests/coverage-gaps.test.ts +669 -669
- package/src/tests/coverage.test.ts +299 -299
- package/src/tests/mount.test.ts +1183 -1183
- package/src/tests/props.test.ts +219 -219
- package/src/tests/setup.ts +1 -1
- package/src/tests/show-context.test.ts +43 -43
- package/src/tests/template.test.ts +71 -71
- package/src/tests/transition.test.ts +124 -124
- package/src/transition-group.ts +22 -22
- package/src/transition.ts +18 -18
package/src/mount.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
RefProp,
|
|
7
7
|
VNode,
|
|
8
8
|
VNodeChild,
|
|
9
|
-
} from
|
|
9
|
+
} from '@pyreon/core'
|
|
10
10
|
import {
|
|
11
11
|
dispatchToErrorBoundary,
|
|
12
12
|
EMPTY_PROPS,
|
|
@@ -16,13 +16,13 @@ import {
|
|
|
16
16
|
propagateError,
|
|
17
17
|
reportError,
|
|
18
18
|
runWithHooks,
|
|
19
|
-
} from
|
|
20
|
-
import { effectScope, renderEffect, runUntracked, setCurrentScope } from
|
|
21
|
-
import { registerComponent, unregisterComponent } from
|
|
22
|
-
import { mountFor, mountKeyedList, mountReactive } from
|
|
23
|
-
import { applyProps } from
|
|
19
|
+
} from '@pyreon/core'
|
|
20
|
+
import { effectScope, renderEffect, runUntracked, setCurrentScope } from '@pyreon/reactivity'
|
|
21
|
+
import { registerComponent, unregisterComponent } from './devtools'
|
|
22
|
+
import { mountFor, mountKeyedList, mountReactive } from './nodes'
|
|
23
|
+
import { applyProps } from './props'
|
|
24
24
|
|
|
25
|
-
const __DEV__ = typeof process !==
|
|
25
|
+
const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
|
|
26
26
|
|
|
27
27
|
type Cleanup = () => void
|
|
28
28
|
const noop: Cleanup = () => {
|
|
@@ -51,7 +51,7 @@ export function mountChild(
|
|
|
51
51
|
anchor: Node | null = null,
|
|
52
52
|
): Cleanup {
|
|
53
53
|
// Reactive accessor — function that reads signals
|
|
54
|
-
if (typeof child ===
|
|
54
|
+
if (typeof child === 'function') {
|
|
55
55
|
const sample = runUntracked(() => (child as () => VNodeChild | VNodeChild[])())
|
|
56
56
|
if (isKeyedArray(sample)) {
|
|
57
57
|
const prevDepth = _elementDepth
|
|
@@ -63,12 +63,12 @@ export function mountChild(
|
|
|
63
63
|
return cleanup
|
|
64
64
|
}
|
|
65
65
|
// Text fast path: reactive string/number/boolean — update text.data in-place
|
|
66
|
-
if (typeof sample ===
|
|
67
|
-
const text = document.createTextNode(sample
|
|
66
|
+
if (typeof sample === 'string' || typeof sample === 'number' || typeof sample === 'boolean') {
|
|
67
|
+
const text = document.createTextNode(sample === false ? '' : String(sample))
|
|
68
68
|
parent.insertBefore(text, anchor)
|
|
69
69
|
const dispose = renderEffect(() => {
|
|
70
70
|
const v = (child as () => unknown)()
|
|
71
|
-
text.data = v == null || v === false ?
|
|
71
|
+
text.data = v == null || v === false ? '' : String(v as string | number)
|
|
72
72
|
})
|
|
73
73
|
if (_elementDepth > 0) return dispose
|
|
74
74
|
return () => {
|
|
@@ -91,7 +91,7 @@ export function mountChild(
|
|
|
91
91
|
if (child == null || child === false) return noop
|
|
92
92
|
|
|
93
93
|
// Primitive — text node (static, no reactive effects to tear down).
|
|
94
|
-
if (typeof child !==
|
|
94
|
+
if (typeof child !== 'object') {
|
|
95
95
|
parent.insertBefore(document.createTextNode(String(child)), anchor)
|
|
96
96
|
return noop
|
|
97
97
|
}
|
|
@@ -132,23 +132,23 @@ export function mountChild(
|
|
|
132
132
|
if (vnode.type === (PortalSymbol as unknown as string)) {
|
|
133
133
|
const { target, children } = vnode.props as unknown as PortalProps
|
|
134
134
|
if (__DEV__ && !target) {
|
|
135
|
-
console.warn(
|
|
135
|
+
console.warn('[Pyreon] <Portal> received a falsy `target`. Provide a valid DOM element.')
|
|
136
136
|
return noop
|
|
137
137
|
}
|
|
138
138
|
if (__DEV__ && !(target instanceof Node)) {
|
|
139
139
|
console.warn(
|
|
140
140
|
`[Pyreon] <Portal> target must be a DOM node. Received ${typeof target}. ` +
|
|
141
|
-
|
|
141
|
+
'Use document.getElementById() or a ref to get the target element.',
|
|
142
142
|
)
|
|
143
143
|
}
|
|
144
144
|
return mountChild(children, target, null)
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
if (typeof vnode.type ===
|
|
147
|
+
if (typeof vnode.type === 'function') {
|
|
148
148
|
return mountComponent(vnode as VNode & { type: ComponentFn }, parent, anchor)
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
if (__DEV__ && typeof vnode.type !==
|
|
151
|
+
if (__DEV__ && typeof vnode.type !== 'string') {
|
|
152
152
|
console.warn(
|
|
153
153
|
`[Pyreon] Invalid VNode type: expected a string tag or component function, ` +
|
|
154
154
|
`received ${typeof vnode.type} (${String(vnode.type)}). ` +
|
|
@@ -164,20 +164,20 @@ export function mountChild(
|
|
|
164
164
|
|
|
165
165
|
// Void elements that cannot have children
|
|
166
166
|
const VOID_ELEMENTS = new Set([
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
167
|
+
'area',
|
|
168
|
+
'base',
|
|
169
|
+
'br',
|
|
170
|
+
'col',
|
|
171
|
+
'embed',
|
|
172
|
+
'hr',
|
|
173
|
+
'img',
|
|
174
|
+
'input',
|
|
175
|
+
'link',
|
|
176
|
+
'meta',
|
|
177
|
+
'param',
|
|
178
|
+
'source',
|
|
179
|
+
'track',
|
|
180
|
+
'wbr',
|
|
181
181
|
])
|
|
182
182
|
|
|
183
183
|
function mountElement(vnode: VNode, parent: Node, anchor: Node | null): Cleanup {
|
|
@@ -186,7 +186,7 @@ function mountElement(vnode: VNode, parent: Node, anchor: Node | null): Cleanup
|
|
|
186
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.',
|
|
190
190
|
)
|
|
191
191
|
}
|
|
192
192
|
|
|
@@ -204,7 +204,7 @@ function mountElement(vnode: VNode, parent: Node, anchor: Node | null): Cleanup
|
|
|
204
204
|
// Populate ref after the element is in the DOM
|
|
205
205
|
const ref = props.ref as RefProp<Element> | null | undefined
|
|
206
206
|
if (ref) {
|
|
207
|
-
if (typeof ref ===
|
|
207
|
+
if (typeof ref === 'function') ref(el)
|
|
208
208
|
else ref.current = el
|
|
209
209
|
}
|
|
210
210
|
|
|
@@ -225,14 +225,14 @@ function mountElement(vnode: VNode, parent: Node, anchor: Node | null): Cleanup
|
|
|
225
225
|
}
|
|
226
226
|
const refToClean = ref
|
|
227
227
|
return () => {
|
|
228
|
-
if (refToClean && typeof refToClean ===
|
|
228
|
+
if (refToClean && typeof refToClean === 'object') refToClean.current = null
|
|
229
229
|
if (propCleanup) propCleanup()
|
|
230
230
|
childCleanup()
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
233
|
|
|
234
234
|
return () => {
|
|
235
|
-
if (ref && typeof ref ===
|
|
235
|
+
if (ref && typeof ref === 'object') ref.current = null
|
|
236
236
|
if (propCleanup) propCleanup()
|
|
237
237
|
childCleanup()
|
|
238
238
|
const p = el.parentNode
|
|
@@ -250,10 +250,10 @@ function mountComponent(
|
|
|
250
250
|
const scope = effectScope()
|
|
251
251
|
setCurrentScope(scope)
|
|
252
252
|
|
|
253
|
-
let hooks: ReturnType<typeof runWithHooks>[
|
|
253
|
+
let hooks: ReturnType<typeof runWithHooks>['hooks']
|
|
254
254
|
let output: VNodeChild
|
|
255
255
|
|
|
256
|
-
const componentName = (vnode.type.name ||
|
|
256
|
+
const componentName = (vnode.type.name || 'Anonymous') as string
|
|
257
257
|
const compId = `${componentName}-${Math.random().toString(36).slice(2, 9)}`
|
|
258
258
|
const parentId = _mountingStack[_mountingStack.length - 1] ?? null
|
|
259
259
|
_mountingStack.push(compId)
|
|
@@ -278,7 +278,7 @@ function mountComponent(
|
|
|
278
278
|
scope.stop()
|
|
279
279
|
reportError({
|
|
280
280
|
component: componentName,
|
|
281
|
-
phase:
|
|
281
|
+
phase: 'setup',
|
|
282
282
|
error: err,
|
|
283
283
|
timestamp: Date.now(),
|
|
284
284
|
props: vnode.props as Record<string, unknown>,
|
|
@@ -292,14 +292,14 @@ function mountComponent(
|
|
|
292
292
|
setCurrentScope(null)
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
if (__DEV__ && output != null && typeof output ===
|
|
295
|
+
if (__DEV__ && output != null && typeof output === 'object') {
|
|
296
296
|
if (output instanceof Promise) {
|
|
297
297
|
console.warn(
|
|
298
298
|
`[Pyreon] Component <${componentName}> returned a Promise. ` +
|
|
299
|
-
|
|
300
|
-
|
|
299
|
+
'Components must be synchronous — use lazy() + Suspense for async loading, ' +
|
|
300
|
+
'or fetch data in onMount and store it in a signal.',
|
|
301
301
|
)
|
|
302
|
-
} else if (!(
|
|
302
|
+
} else if (!('type' in output)) {
|
|
303
303
|
console.warn(
|
|
304
304
|
`[Pyreon] Component <${componentName}> returned an invalid value. Components must return a VNode, string, null, or function.`,
|
|
305
305
|
)
|
|
@@ -320,7 +320,7 @@ function mountComponent(
|
|
|
320
320
|
if (!handled) {
|
|
321
321
|
reportError({
|
|
322
322
|
component: componentName,
|
|
323
|
-
phase:
|
|
323
|
+
phase: 'render',
|
|
324
324
|
error: err,
|
|
325
325
|
timestamp: Date.now(),
|
|
326
326
|
props: vnode.props as Record<string, unknown>,
|
|
@@ -346,7 +346,7 @@ function mountComponent(
|
|
|
346
346
|
if (cleanup) mountCleanups.push(cleanup)
|
|
347
347
|
} catch (err) {
|
|
348
348
|
console.error(`[Pyreon] Error in onMount hook of <${componentName}>:`, err)
|
|
349
|
-
reportError({ component: componentName, phase:
|
|
349
|
+
reportError({ component: componentName, phase: 'mount', error: err, timestamp: Date.now() })
|
|
350
350
|
}
|
|
351
351
|
}
|
|
352
352
|
|
|
@@ -361,7 +361,7 @@ function mountComponent(
|
|
|
361
361
|
console.error(`[Pyreon] Error in onUnmount hook of <${componentName}>:`, err)
|
|
362
362
|
reportError({
|
|
363
363
|
component: componentName,
|
|
364
|
-
phase:
|
|
364
|
+
phase: 'unmount',
|
|
365
365
|
error: err,
|
|
366
366
|
timestamp: Date.now(),
|
|
367
367
|
})
|
|
@@ -380,7 +380,7 @@ function mountChildren(children: VNodeChild[], parent: Node, anchor: Node | null
|
|
|
380
380
|
if (children.length === 1) {
|
|
381
381
|
const c = children[0] as VNodeChild
|
|
382
382
|
if (c !== undefined) {
|
|
383
|
-
if (anchor === null && (typeof c ===
|
|
383
|
+
if (anchor === null && (typeof c === 'string' || typeof c === 'number')) {
|
|
384
384
|
;(parent as HTMLElement).textContent = String(c)
|
|
385
385
|
return noop
|
|
386
386
|
}
|
|
@@ -419,7 +419,7 @@ function isKeyedArray(value: unknown): value is VNode[] {
|
|
|
419
419
|
return value.every(
|
|
420
420
|
(v) =>
|
|
421
421
|
v !== null &&
|
|
422
|
-
typeof v ===
|
|
422
|
+
typeof v === 'object' &&
|
|
423
423
|
!Array.isArray(v) &&
|
|
424
424
|
(v as VNode).key !== null &&
|
|
425
425
|
(v as VNode).key !== undefined,
|
package/src/nodes.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import type { VNode, VNodeChild } from
|
|
1
|
+
import type { VNode, VNodeChild } from '@pyreon/core'
|
|
2
|
+
import { captureContextStack, restoreContextStack } from '@pyreon/core'
|
|
2
3
|
|
|
3
4
|
type MountFn = (child: VNodeChild, parent: Node, anchor: Node | null) => Cleanup
|
|
4
5
|
|
|
5
|
-
import { effect, runUntracked } from
|
|
6
|
+
import { effect, runUntracked } from '@pyreon/reactivity'
|
|
6
7
|
|
|
7
|
-
const __DEV__ = typeof process !==
|
|
8
|
+
const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
|
|
8
9
|
|
|
9
10
|
type Cleanup = () => void
|
|
10
11
|
|
|
@@ -43,9 +44,15 @@ export function mountReactive(
|
|
|
43
44
|
anchor: Node | null,
|
|
44
45
|
mount: (child: VNodeChild, p: Node, a: Node | null) => Cleanup,
|
|
45
46
|
): Cleanup {
|
|
46
|
-
const marker = document.createComment(
|
|
47
|
+
const marker = document.createComment('pyreon')
|
|
47
48
|
parent.insertBefore(marker, anchor)
|
|
48
49
|
|
|
50
|
+
// Capture the context stack at creation time — ancestor provide() calls
|
|
51
|
+
// have already run, so this snapshot contains all parent contexts.
|
|
52
|
+
// When the effect re-mounts children later (e.g. Show toggling on),
|
|
53
|
+
// we restore this snapshot so children see ancestor providers.
|
|
54
|
+
const contextSnapshot = captureContextStack()
|
|
55
|
+
|
|
49
56
|
let currentCleanup: Cleanup = () => {
|
|
50
57
|
/* noop */
|
|
51
58
|
}
|
|
@@ -61,13 +68,18 @@ export function mountReactive(
|
|
|
61
68
|
/* noop */
|
|
62
69
|
}
|
|
63
70
|
const value = accessor()
|
|
64
|
-
if (__DEV__ && typeof value ===
|
|
71
|
+
if (__DEV__ && typeof value === 'function') {
|
|
65
72
|
console.warn(
|
|
66
|
-
|
|
73
|
+
'[Pyreon] Reactive accessor returned a function instead of a value. Did you forget to call the signal?',
|
|
67
74
|
)
|
|
68
75
|
}
|
|
69
76
|
if (value != null && value !== false) {
|
|
70
|
-
|
|
77
|
+
// Restore ancestor context so children mounted here can read
|
|
78
|
+
// provider values via useContext() — without this, <Show> children
|
|
79
|
+
// wouldn't inherit context from components above the Show boundary.
|
|
80
|
+
const cleanup = restoreContextStack(contextSnapshot, () =>
|
|
81
|
+
mount(value, parent, marker),
|
|
82
|
+
)
|
|
71
83
|
// Guard: a re-entrant signal update (e.g. ErrorBoundary catching a child
|
|
72
84
|
// throw) may have already re-run this effect and updated currentCleanup.
|
|
73
85
|
// In that case, discard our stale cleanup rather than overwriting the one
|
|
@@ -209,8 +221,8 @@ export function mountKeyedList(
|
|
|
209
221
|
listAnchor: Node | null,
|
|
210
222
|
mountVNode: (vnode: VNode, p: Node, a: Node | null) => Cleanup,
|
|
211
223
|
): Cleanup {
|
|
212
|
-
const startMarker = document.createComment(
|
|
213
|
-
const tailMarker = document.createComment(
|
|
224
|
+
const startMarker = document.createComment('')
|
|
225
|
+
const tailMarker = document.createComment('')
|
|
214
226
|
parent.insertBefore(startMarker, listAnchor)
|
|
215
227
|
parent.insertBefore(tailMarker, listAnchor)
|
|
216
228
|
|
|
@@ -255,7 +267,7 @@ export function mountKeyedList(
|
|
|
255
267
|
const key = vnode.key
|
|
256
268
|
if (key === null || key === undefined) continue
|
|
257
269
|
if (cache.has(key)) continue
|
|
258
|
-
const anchor = document.createComment(
|
|
270
|
+
const anchor = document.createComment('')
|
|
259
271
|
_keyedAnchors.add(anchor)
|
|
260
272
|
parent.insertBefore(anchor, tailMarker)
|
|
261
273
|
const cleanup = mountVNode(vnode, parent, tailMarker)
|
|
@@ -427,13 +439,13 @@ function forLisReorder(
|
|
|
427
439
|
export function mountFor<T>(
|
|
428
440
|
source: () => T[],
|
|
429
441
|
getKey: (item: T) => string | number,
|
|
430
|
-
renderItem: (item: T) => import(
|
|
442
|
+
renderItem: (item: T) => import('@pyreon/core').VNode | import('@pyreon/core').NativeItem,
|
|
431
443
|
parent: Node,
|
|
432
444
|
anchor: Node | null,
|
|
433
445
|
mountChild: MountFn,
|
|
434
446
|
): Cleanup {
|
|
435
|
-
const startMarker = document.createComment(
|
|
436
|
-
const tailMarker = document.createComment(
|
|
447
|
+
const startMarker = document.createComment('')
|
|
448
|
+
const tailMarker = document.createComment('')
|
|
437
449
|
parent.insertBefore(startMarker, anchor)
|
|
438
450
|
parent.insertBefore(tailMarker, anchor)
|
|
439
451
|
|
|
@@ -453,8 +465,8 @@ export function mountFor<T>(
|
|
|
453
465
|
if (!__DEV__ || !seen) return
|
|
454
466
|
if (key == null) {
|
|
455
467
|
console.warn(
|
|
456
|
-
|
|
457
|
-
|
|
468
|
+
'[Pyreon] <For> `by` function returned null/undefined. ' +
|
|
469
|
+
'Keys must be strings or numbers. Check your `by` prop.',
|
|
458
470
|
)
|
|
459
471
|
}
|
|
460
472
|
if (seen.has(key)) {
|
|
@@ -472,18 +484,18 @@ export function mountFor<T>(
|
|
|
472
484
|
before: Node | null,
|
|
473
485
|
) => {
|
|
474
486
|
const result = renderItem(item)
|
|
475
|
-
if ((result as import(
|
|
476
|
-
const native = result as import(
|
|
487
|
+
if ((result as import('@pyreon/core').NativeItem).__isNative) {
|
|
488
|
+
const native = result as import('@pyreon/core').NativeItem
|
|
477
489
|
container.insertBefore(native.el, before)
|
|
478
490
|
cache.set(key, { anchor: native.el, cleanup: native.cleanup, pos })
|
|
479
491
|
if (native.cleanup) cleanupCount++
|
|
480
492
|
return
|
|
481
493
|
}
|
|
482
494
|
const priorLast = before ? before.previousSibling : container.lastChild
|
|
483
|
-
const cl = mountChild(result as import(
|
|
495
|
+
const cl = mountChild(result as import('@pyreon/core').VNode, container, before)
|
|
484
496
|
const firstMounted = priorLast ? priorLast.nextSibling : container.firstChild
|
|
485
497
|
if (!firstMounted || firstMounted === before) {
|
|
486
|
-
const ph = document.createComment(
|
|
498
|
+
const ph = document.createComment('')
|
|
487
499
|
container.insertBefore(ph, before)
|
|
488
500
|
cache.set(key, { anchor: ph, cleanup: cl, pos })
|
|
489
501
|
} else {
|
package/src/props.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import type { ClassValue, Props } from
|
|
2
|
-
import { cx, normalizeStyleValue, toKebabCase } from
|
|
1
|
+
import type { ClassValue, Props } from '@pyreon/core'
|
|
2
|
+
import { cx, normalizeStyleValue, toKebabCase } from '@pyreon/core'
|
|
3
3
|
|
|
4
|
-
import { batch, renderEffect } from
|
|
5
|
-
import { DELEGATED_EVENTS, delegatedPropName } from
|
|
4
|
+
import { batch, renderEffect } from '@pyreon/reactivity'
|
|
5
|
+
import { DELEGATED_EVENTS, delegatedPropName } from './delegate'
|
|
6
6
|
|
|
7
7
|
type Cleanup = () => void
|
|
8
8
|
|
|
9
|
-
const __DEV__ = typeof process !==
|
|
9
|
+
const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
|
|
10
10
|
|
|
11
11
|
// ─── Configurable sanitizer ──────────────────────────────────────────────────
|
|
12
12
|
|
|
@@ -36,75 +36,75 @@ export function setSanitizer(fn: SanitizeFn | null): void {
|
|
|
36
36
|
|
|
37
37
|
// Safe HTML tags allowed by the fallback sanitizer (block + inline, no scripts/embeds/forms)
|
|
38
38
|
const SAFE_TAGS = new Set([
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
39
|
+
'a',
|
|
40
|
+
'abbr',
|
|
41
|
+
'address',
|
|
42
|
+
'article',
|
|
43
|
+
'aside',
|
|
44
|
+
'b',
|
|
45
|
+
'bdi',
|
|
46
|
+
'bdo',
|
|
47
|
+
'blockquote',
|
|
48
|
+
'br',
|
|
49
|
+
'caption',
|
|
50
|
+
'cite',
|
|
51
|
+
'code',
|
|
52
|
+
'col',
|
|
53
|
+
'colgroup',
|
|
54
|
+
'dd',
|
|
55
|
+
'del',
|
|
56
|
+
'details',
|
|
57
|
+
'dfn',
|
|
58
|
+
'div',
|
|
59
|
+
'dl',
|
|
60
|
+
'dt',
|
|
61
|
+
'em',
|
|
62
|
+
'figcaption',
|
|
63
|
+
'figure',
|
|
64
|
+
'footer',
|
|
65
|
+
'h1',
|
|
66
|
+
'h2',
|
|
67
|
+
'h3',
|
|
68
|
+
'h4',
|
|
69
|
+
'h5',
|
|
70
|
+
'h6',
|
|
71
|
+
'header',
|
|
72
|
+
'hr',
|
|
73
|
+
'i',
|
|
74
|
+
'ins',
|
|
75
|
+
'kbd',
|
|
76
|
+
'li',
|
|
77
|
+
'main',
|
|
78
|
+
'mark',
|
|
79
|
+
'nav',
|
|
80
|
+
'ol',
|
|
81
|
+
'p',
|
|
82
|
+
'pre',
|
|
83
|
+
'q',
|
|
84
|
+
'rp',
|
|
85
|
+
'rt',
|
|
86
|
+
'ruby',
|
|
87
|
+
's',
|
|
88
|
+
'samp',
|
|
89
|
+
'section',
|
|
90
|
+
'small',
|
|
91
|
+
'span',
|
|
92
|
+
'strong',
|
|
93
|
+
'sub',
|
|
94
|
+
'summary',
|
|
95
|
+
'sup',
|
|
96
|
+
'table',
|
|
97
|
+
'tbody',
|
|
98
|
+
'td',
|
|
99
|
+
'tfoot',
|
|
100
|
+
'th',
|
|
101
|
+
'thead',
|
|
102
|
+
'time',
|
|
103
|
+
'tr',
|
|
104
|
+
'u',
|
|
105
|
+
'ul',
|
|
106
|
+
'var',
|
|
107
|
+
'wbr',
|
|
108
108
|
])
|
|
109
109
|
|
|
110
110
|
// Attributes that can carry executable code
|
|
@@ -116,7 +116,7 @@ const UNSAFE_ATTR_RE = /^on/i
|
|
|
116
116
|
* and blocks javascript:/data: URLs in href/src/action attributes.
|
|
117
117
|
*/
|
|
118
118
|
function fallbackSanitize(html: string): string {
|
|
119
|
-
const doc = new DOMParser().parseFromString(html,
|
|
119
|
+
const doc = new DOMParser().parseFromString(html, 'text/html')
|
|
120
120
|
sanitizeNode(doc.body)
|
|
121
121
|
return doc.body.innerHTML
|
|
122
122
|
}
|
|
@@ -173,7 +173,7 @@ export function applyProps(el: Element, props: Props): Cleanup | null {
|
|
|
173
173
|
let first: Cleanup | null = null
|
|
174
174
|
let cleanups: Cleanup[] | null = null
|
|
175
175
|
for (const key in props) {
|
|
176
|
-
if (key ===
|
|
176
|
+
if (key === 'key' || key === 'ref' || key === 'children') continue
|
|
177
177
|
const c = applyProp(el, key, props[key])
|
|
178
178
|
if (c) {
|
|
179
179
|
if (!first) {
|
|
@@ -203,7 +203,7 @@ 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 (typeof value !==
|
|
206
|
+
if (typeof value !== 'function') {
|
|
207
207
|
if (__DEV__) {
|
|
208
208
|
console.warn(
|
|
209
209
|
`[Pyreon] Event handler "${key}" received a non-function value (${typeof value}). ` +
|
|
@@ -233,8 +233,8 @@ export function applyProp(el: Element, key: string, value: unknown): Cleanup | n
|
|
|
233
233
|
if (EVENT_RE.test(key)) return applyEventProp(el, key, value)
|
|
234
234
|
|
|
235
235
|
// innerHTML — sanitized via Sanitizer API or fallback allowlist sanitizer
|
|
236
|
-
if (key ===
|
|
237
|
-
if (typeof (el as HTMLElement & { setHTML?: (h: string) => void }).setHTML ===
|
|
236
|
+
if (key === 'innerHTML') {
|
|
237
|
+
if (typeof (el as HTMLElement & { setHTML?: (h: string) => void }).setHTML === 'function') {
|
|
238
238
|
;(el as HTMLElement & { setHTML: (h: string) => void }).setHTML(value as string)
|
|
239
239
|
} else {
|
|
240
240
|
;(el as HTMLElement).innerHTML = sanitizeHtml(value as string)
|
|
@@ -242,10 +242,10 @@ export function applyProp(el: Element, key: string, value: unknown): Cleanup | n
|
|
|
242
242
|
return null
|
|
243
243
|
}
|
|
244
244
|
// dangerouslySetInnerHTML — intentionally raw, developer owns sanitization (same as React)
|
|
245
|
-
if (key ===
|
|
245
|
+
if (key === 'dangerouslySetInnerHTML') {
|
|
246
246
|
if (__DEV__) {
|
|
247
247
|
console.warn(
|
|
248
|
-
|
|
248
|
+
'[Pyreon] dangerouslySetInnerHTML bypasses sanitization. Ensure the HTML is trusted.',
|
|
249
249
|
)
|
|
250
250
|
}
|
|
251
251
|
;(el as HTMLElement).innerHTML = (value as { __html: string }).__html
|
|
@@ -255,7 +255,7 @@ export function applyProp(el: Element, key: string, value: unknown): Cleanup | n
|
|
|
255
255
|
// Reactive prop — function that returns the actual value
|
|
256
256
|
// Uses renderEffect (lighter than effect — no scope registration, no WeakMap)
|
|
257
257
|
// since lifecycle is managed by mountElement's cleanup array.
|
|
258
|
-
if (typeof value ===
|
|
258
|
+
if (typeof value === 'function') {
|
|
259
259
|
const dispose = renderEffect(() => setStaticProp(el, key, (value as () => unknown)()))
|
|
260
260
|
return dispose
|
|
261
261
|
}
|
|
@@ -265,42 +265,42 @@ export function applyProp(el: Element, key: string, value: unknown): Cleanup | n
|
|
|
265
265
|
}
|
|
266
266
|
|
|
267
267
|
// Attributes that carry URLs and must be guarded against javascript:/data: injection.
|
|
268
|
-
const URL_ATTRS = new Set([
|
|
268
|
+
const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'poster', 'cite', 'data'])
|
|
269
269
|
const UNSAFE_URL_RE = /^\s*(?:javascript|data):/i
|
|
270
270
|
|
|
271
271
|
/** Apply a style prop (string or object). */
|
|
272
272
|
function applyStyleProp(el: HTMLElement, value: unknown): void {
|
|
273
|
-
if (typeof value ===
|
|
273
|
+
if (typeof value === 'string') {
|
|
274
274
|
el.style.cssText = value
|
|
275
|
-
} else if (value != null && typeof value ===
|
|
275
|
+
} else if (value != null && typeof value === 'object') {
|
|
276
276
|
const obj = value as Record<string, unknown>
|
|
277
277
|
for (const k in obj) {
|
|
278
278
|
const css = normalizeStyleValue(k, obj[k])
|
|
279
|
-
el.style.setProperty(k.startsWith(
|
|
279
|
+
el.style.setProperty(k.startsWith('--') ? k : toKebabCase(k), css)
|
|
280
280
|
}
|
|
281
281
|
}
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
function applyClassProp(el: Element, value: unknown): void {
|
|
285
|
-
const resolved = typeof value ===
|
|
286
|
-
el.setAttribute(
|
|
285
|
+
const resolved = typeof value === 'string' ? value : cx(value as ClassValue)
|
|
286
|
+
el.setAttribute('class', resolved || '')
|
|
287
287
|
}
|
|
288
288
|
|
|
289
289
|
function setStaticProp(el: Element, key: string, value: unknown): void {
|
|
290
290
|
// Block javascript:/data: URI injection in URL-bearing attributes.
|
|
291
|
-
if (URL_ATTRS.has(key) && typeof value ===
|
|
291
|
+
if (URL_ATTRS.has(key) && typeof value === 'string' && UNSAFE_URL_RE.test(value)) {
|
|
292
292
|
if (__DEV__) {
|
|
293
293
|
console.warn(`[Pyreon] Blocked unsafe URL in "${key}" attribute: ${value}`)
|
|
294
294
|
}
|
|
295
295
|
return
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
if (key ===
|
|
298
|
+
if (key === 'class' || key === 'className') {
|
|
299
299
|
applyClassProp(el, value)
|
|
300
300
|
return
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
-
if (key ===
|
|
303
|
+
if (key === 'style') {
|
|
304
304
|
applyStyleProp(el as HTMLElement, value)
|
|
305
305
|
return
|
|
306
306
|
}
|
|
@@ -310,8 +310,8 @@ function setStaticProp(el: Element, key: string, value: unknown): void {
|
|
|
310
310
|
return
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
-
if (typeof value ===
|
|
314
|
-
if (value) el.setAttribute(key,
|
|
313
|
+
if (typeof value === 'boolean') {
|
|
314
|
+
if (value) el.setAttribute(key, '')
|
|
315
315
|
else el.removeAttribute(key)
|
|
316
316
|
return
|
|
317
317
|
}
|