@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
package/src/props.ts
DELETED
|
@@ -1,474 +0,0 @@
|
|
|
1
|
-
import type { ClassValue, Props } from '@pyreon/core'
|
|
2
|
-
import { cx, normalizeStyleValue, toKebabCase } from '@pyreon/core'
|
|
3
|
-
|
|
4
|
-
import { batch, renderEffect } from '@pyreon/reactivity'
|
|
5
|
-
import { DELEGATED_EVENTS, delegatedPropName } from './delegate'
|
|
6
|
-
|
|
7
|
-
type Cleanup = () => void
|
|
8
|
-
|
|
9
|
-
// Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
|
|
10
|
-
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
11
|
-
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
12
|
-
|
|
13
|
-
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
14
|
-
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
15
|
-
|
|
16
|
-
// ─── Configurable sanitizer ──────────────────────────────────────────────────
|
|
17
|
-
|
|
18
|
-
export type SanitizeFn = (html: string) => string
|
|
19
|
-
|
|
20
|
-
let _customSanitizer: SanitizeFn | null = null
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Set a custom HTML sanitizer used by `innerHTML` and `sanitizeHtml()`.
|
|
24
|
-
* Overrides both the Sanitizer API and the built-in fallback.
|
|
25
|
-
*
|
|
26
|
-
* @example
|
|
27
|
-
* // With DOMPurify:
|
|
28
|
-
* import DOMPurify from "dompurify"
|
|
29
|
-
* setSanitizer((html) => DOMPurify.sanitize(html))
|
|
30
|
-
*
|
|
31
|
-
* // With sanitize-html:
|
|
32
|
-
* import sanitize from "sanitize-html"
|
|
33
|
-
* setSanitizer((html) => sanitize(html))
|
|
34
|
-
*
|
|
35
|
-
* // Reset to built-in:
|
|
36
|
-
* setSanitizer(null)
|
|
37
|
-
*/
|
|
38
|
-
export function setSanitizer(fn: SanitizeFn | null): void {
|
|
39
|
-
_customSanitizer = fn
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Safe HTML tags allowed by the fallback sanitizer (block + inline, no scripts/embeds/forms)
|
|
43
|
-
const SAFE_TAGS = new Set([
|
|
44
|
-
'a',
|
|
45
|
-
'abbr',
|
|
46
|
-
'address',
|
|
47
|
-
'article',
|
|
48
|
-
'aside',
|
|
49
|
-
'b',
|
|
50
|
-
'bdi',
|
|
51
|
-
'bdo',
|
|
52
|
-
'blockquote',
|
|
53
|
-
'br',
|
|
54
|
-
'caption',
|
|
55
|
-
'cite',
|
|
56
|
-
'code',
|
|
57
|
-
'col',
|
|
58
|
-
'colgroup',
|
|
59
|
-
'dd',
|
|
60
|
-
'del',
|
|
61
|
-
'details',
|
|
62
|
-
'dfn',
|
|
63
|
-
'div',
|
|
64
|
-
'dl',
|
|
65
|
-
'dt',
|
|
66
|
-
'em',
|
|
67
|
-
'figcaption',
|
|
68
|
-
'figure',
|
|
69
|
-
'footer',
|
|
70
|
-
'h1',
|
|
71
|
-
'h2',
|
|
72
|
-
'h3',
|
|
73
|
-
'h4',
|
|
74
|
-
'h5',
|
|
75
|
-
'h6',
|
|
76
|
-
'header',
|
|
77
|
-
'hr',
|
|
78
|
-
'i',
|
|
79
|
-
'ins',
|
|
80
|
-
'kbd',
|
|
81
|
-
'li',
|
|
82
|
-
'main',
|
|
83
|
-
'mark',
|
|
84
|
-
'nav',
|
|
85
|
-
'ol',
|
|
86
|
-
'p',
|
|
87
|
-
'pre',
|
|
88
|
-
'q',
|
|
89
|
-
'rp',
|
|
90
|
-
'rt',
|
|
91
|
-
'ruby',
|
|
92
|
-
's',
|
|
93
|
-
'samp',
|
|
94
|
-
'section',
|
|
95
|
-
'small',
|
|
96
|
-
'span',
|
|
97
|
-
'strong',
|
|
98
|
-
'sub',
|
|
99
|
-
'summary',
|
|
100
|
-
'sup',
|
|
101
|
-
'table',
|
|
102
|
-
'tbody',
|
|
103
|
-
'td',
|
|
104
|
-
'tfoot',
|
|
105
|
-
'th',
|
|
106
|
-
'thead',
|
|
107
|
-
'time',
|
|
108
|
-
'tr',
|
|
109
|
-
'u',
|
|
110
|
-
'ul',
|
|
111
|
-
'var',
|
|
112
|
-
'wbr',
|
|
113
|
-
])
|
|
114
|
-
|
|
115
|
-
// Attributes that can carry executable code
|
|
116
|
-
const UNSAFE_ATTR_RE = /^on/i
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Fallback tag-stripping sanitizer for environments without the Sanitizer API.
|
|
120
|
-
* Removes all tags not in SAFE_TAGS, strips event handler attributes,
|
|
121
|
-
* and blocks javascript:/data: URLs in href/src/action attributes.
|
|
122
|
-
*/
|
|
123
|
-
function fallbackSanitize(html: string): string {
|
|
124
|
-
const doc = new DOMParser().parseFromString(html, 'text/html')
|
|
125
|
-
sanitizeNode(doc.body)
|
|
126
|
-
return doc.body.innerHTML
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Strip unsafe attributes from a single element. */
|
|
130
|
-
function stripUnsafeAttrs(el: Element): void {
|
|
131
|
-
const attrs = Array.from(el.attributes)
|
|
132
|
-
for (const attr of attrs) {
|
|
133
|
-
if (UNSAFE_ATTR_RE.test(attr.name)) {
|
|
134
|
-
el.removeAttribute(attr.name)
|
|
135
|
-
} else if (URL_ATTRS.has(attr.name) && UNSAFE_URL_RE.test(attr.value)) {
|
|
136
|
-
el.removeAttribute(attr.name)
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function sanitizeNode(node: Node): void {
|
|
142
|
-
const children = Array.from(node.childNodes)
|
|
143
|
-
for (const child of children) {
|
|
144
|
-
if (child.nodeType !== 1) continue
|
|
145
|
-
const el = child as Element
|
|
146
|
-
const tag = el.tagName.toLowerCase()
|
|
147
|
-
if (!SAFE_TAGS.has(tag)) {
|
|
148
|
-
const text = document.createTextNode(el.textContent as string)
|
|
149
|
-
node.replaceChild(text, el)
|
|
150
|
-
continue
|
|
151
|
-
}
|
|
152
|
-
stripUnsafeAttrs(el)
|
|
153
|
-
sanitizeNode(el)
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Sanitize an HTML string using the browser Sanitizer API (Chrome 105+).
|
|
159
|
-
* Falls back to a tag-allowlist sanitizer that strips unsafe elements and attributes.
|
|
160
|
-
*/
|
|
161
|
-
export function sanitizeHtml(html: string): string {
|
|
162
|
-
// User-provided sanitizer takes priority (e.g. DOMPurify)
|
|
163
|
-
if (_customSanitizer) return _customSanitizer(html)
|
|
164
|
-
// DOM-based allowlist sanitizer — DOMParser is available in all browser targets.
|
|
165
|
-
// sanitizeHtml is only called for innerHTML (DOM-only), so SSR fallback is not needed.
|
|
166
|
-
return fallbackSanitize(html)
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Matches onClick, onInput, onMouseEnter, etc.
|
|
170
|
-
const EVENT_RE = /^on[A-Z]/
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Apply all props to a DOM element.
|
|
174
|
-
* Returns a single chained cleanup (or null if no props need teardown).
|
|
175
|
-
* Uses for-in instead of Object.keys() to avoid allocating a keys array.
|
|
176
|
-
*/
|
|
177
|
-
export function applyProps(el: Element, props: Props): Cleanup | null {
|
|
178
|
-
let first: Cleanup | null = null
|
|
179
|
-
let cleanups: Cleanup[] | null = null
|
|
180
|
-
for (const key in props) {
|
|
181
|
-
if (key === 'key' || key === 'ref' || key === 'children') continue
|
|
182
|
-
// Getter-shaped descriptors are produced by `makeReactiveProps` from
|
|
183
|
-
// compiler-emitted `_rp(() => signal())` wrappers. A plain
|
|
184
|
-
// `props[key]` read fires the getter once at mount time and stores
|
|
185
|
-
// the resolved value — breaking signal-driven reactivity. Detecting
|
|
186
|
-
// the descriptor and wrapping the read in `renderEffect` here is
|
|
187
|
-
// equivalent to applyProp's existing function-value branch (line 322),
|
|
188
|
-
// routed through the descriptor instead of the value. Other prop
|
|
189
|
-
// pipelines (`splitProps`, `mergeProps`, rocketstyle's
|
|
190
|
-
// descriptor-preserving merges) keep the getter intact end-to-end;
|
|
191
|
-
// this is the final consumer that closes the loop.
|
|
192
|
-
const descriptor = Object.getOwnPropertyDescriptor(props, key)
|
|
193
|
-
let c: Cleanup | null
|
|
194
|
-
if (descriptor?.get) {
|
|
195
|
-
c = renderEffect(() => applyStaticProp(el, key, (props as Record<string, unknown>)[key]))
|
|
196
|
-
} else {
|
|
197
|
-
c = applyProp(el, key, props[key])
|
|
198
|
-
}
|
|
199
|
-
if (c) {
|
|
200
|
-
if (!first) {
|
|
201
|
-
first = c
|
|
202
|
-
} else if (!cleanups) {
|
|
203
|
-
cleanups = [first, c]
|
|
204
|
-
} else {
|
|
205
|
-
cleanups.push(c)
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
if (cleanups)
|
|
210
|
-
return () => {
|
|
211
|
-
for (const c of cleanups) c()
|
|
212
|
-
}
|
|
213
|
-
return first
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Apply a single prop.
|
|
218
|
-
*
|
|
219
|
-
* - `onXxx` → addEventListener
|
|
220
|
-
* - `() => value` (non-event function) → reactive via effect
|
|
221
|
-
* - anything else → static attribute / DOM property
|
|
222
|
-
*/
|
|
223
|
-
/**
|
|
224
|
-
* Bind an event handler (onClick → "click") with batching + delegation support.
|
|
225
|
-
*/
|
|
226
|
-
function applyEventProp(el: Element, key: string, value: unknown): Cleanup | null {
|
|
227
|
-
if (__DEV__) _countSink.__pyreon_count__?.('runtime.applyEvent')
|
|
228
|
-
if (typeof value !== 'function') {
|
|
229
|
-
// `undefined` and `null` are legitimate — conditional handler pattern:
|
|
230
|
-
// <button onClick={condition ? handler : undefined}>
|
|
231
|
-
// The runtime silently bails on nullish values. Only warn for
|
|
232
|
-
// actually-wrong types (strings, numbers, objects) that indicate
|
|
233
|
-
// a real bug in the caller (e.g. `onClick={someSignal()}` where
|
|
234
|
-
// the signal returns a value instead of a handler function).
|
|
235
|
-
if (__DEV__ && value != null) {
|
|
236
|
-
console.warn(
|
|
237
|
-
`[Pyreon] Event handler "${key}" received a non-function value (${typeof value}). ` +
|
|
238
|
-
`Expected a function. Did you mean ${key}={() => ...}?`,
|
|
239
|
-
)
|
|
240
|
-
}
|
|
241
|
-
return null
|
|
242
|
-
}
|
|
243
|
-
// `onPointerDown` -> `pointerdown`. Multi-word DOM event names are
|
|
244
|
-
// all-lowercase (`pointerdown`, `dblclick`, `mouseover`), so we
|
|
245
|
-
// lowercase the WHOLE name — not just the first letter, as a previous
|
|
246
|
-
// version did. That bug silently dropped delegation for every
|
|
247
|
-
// multi-word event (pointerdown/up/move, mousedown/up/move, dblclick,
|
|
248
|
-
// touchstart/end/move, etc.) — the handler was attached via
|
|
249
|
-
// `addEventListener('pointerDown', ...)` which never fires because
|
|
250
|
-
// real events use the lowercase name.
|
|
251
|
-
const eventName = (key[2]?.toLowerCase() + key.slice(3)).toLowerCase()
|
|
252
|
-
const handler = value as EventListener
|
|
253
|
-
|
|
254
|
-
if (DELEGATED_EVENTS.has(eventName)) {
|
|
255
|
-
const prop = delegatedPropName(eventName)
|
|
256
|
-
;(el as unknown as Record<string, unknown>)[prop] = (e: Event) => batch(() => handler(e))
|
|
257
|
-
return () => {
|
|
258
|
-
;(el as unknown as Record<string, unknown>)[prop] = undefined
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const batched: EventListener = (e) => batch(() => handler(e))
|
|
263
|
-
el.addEventListener(eventName, batched)
|
|
264
|
-
return () => el.removeEventListener(eventName, batched)
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Bind ONE event handler through the CANONICAL event path
|
|
269
|
-
* (`applyEventProp` — the same delegation, batching, and exact
|
|
270
|
-
* `onXxx`→event-name normalization every compiler-emitted handler
|
|
271
|
-
* uses). PR 2 of the partial-collapse build (open-work #1): a
|
|
272
|
-
* collapsed-with-handler site (`_rsCollapseH`) re-attaches the residual
|
|
273
|
-
* handlers `detectPartialCollapsibleShape` (compiler PR 1) peeled off.
|
|
274
|
-
* Contract-consistent BY CONSTRUCTION — it IS `applyEventProp`, not a
|
|
275
|
-
* re-implementation — so a partially-collapsed `<Button onClick=…>`
|
|
276
|
-
* behaves byte-identically to the 5-layer mount it replaced (same
|
|
277
|
-
* delegated-event prop slot, same `batch()` wrapping, same cleanup).
|
|
278
|
-
*/
|
|
279
|
-
export function _bindEvent(el: Element, key: string, handler: unknown): Cleanup | null {
|
|
280
|
-
return applyEventProp(el, key, handler)
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Sink for a single prop's CALLED value (always a primitive / object /
|
|
285
|
-
* `null` — never a function). Called both directly for static values and
|
|
286
|
-
* from the reactive `renderEffect` for accessor-bound values.
|
|
287
|
-
*
|
|
288
|
-
* NOTE on architecture: extracting the special-cased sinks
|
|
289
|
-
* (`innerHTML` / `dangerouslySetInnerHTML`) into this single dispatch
|
|
290
|
-
* function ensures every prop kind goes through the same reactive
|
|
291
|
-
* wrapping at `applyProp`'s entry. Previously each special case had its
|
|
292
|
-
* own early-return branch that needed to remember to handle function
|
|
293
|
-
* values; missing the dance once meant the closure was stringified and
|
|
294
|
-
* set as literal text. The structural fix (one reactive-wrap, then
|
|
295
|
-
* dispatch) eliminates the entire bug class.
|
|
296
|
-
*/
|
|
297
|
-
function applyStaticProp(el: Element, key: string, value: unknown): void {
|
|
298
|
-
if (__DEV__ && typeof value === 'function') {
|
|
299
|
-
// Defensive: function values must be unwrapped via `renderEffect`
|
|
300
|
-
// before reaching here. If we see one, a NEW special-case branch
|
|
301
|
-
// somewhere upstream skipped the reactive-wrapping dance — exactly
|
|
302
|
-
// the bug class the structural refactor was meant to eliminate.
|
|
303
|
-
console.warn(
|
|
304
|
-
`[Pyreon] applyStaticProp received a function for "${key}". ` +
|
|
305
|
-
`This likely means a new special-cased prop sink in applyProp() ` +
|
|
306
|
-
`bypassed the reactive-wrap path. The closure would be stringified ` +
|
|
307
|
-
`and set as a literal value. Verify the dispatch in applyProp().`,
|
|
308
|
-
)
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// innerHTML — sanitized via Sanitizer API or fallback allowlist sanitizer.
|
|
312
|
-
if (key === 'innerHTML') {
|
|
313
|
-
const html = String(value ?? '')
|
|
314
|
-
if (typeof (el as HTMLElement & { setHTML?: (h: string) => void }).setHTML === 'function') {
|
|
315
|
-
;(el as HTMLElement & { setHTML: (h: string) => void }).setHTML(html)
|
|
316
|
-
} else {
|
|
317
|
-
;(el as HTMLElement).innerHTML = sanitizeHtml(html)
|
|
318
|
-
}
|
|
319
|
-
return
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// dangerouslySetInnerHTML — intentionally raw, developer owns sanitization
|
|
323
|
-
// (same as React). The name itself is the warning — React doesn't log,
|
|
324
|
-
// neither should we.
|
|
325
|
-
if (key === 'dangerouslySetInnerHTML') {
|
|
326
|
-
const v = value as { __html: string } | null | undefined
|
|
327
|
-
;(el as HTMLElement).innerHTML = v?.__html ?? ''
|
|
328
|
-
return
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
setStaticProp(el, key, value)
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// `runtime.applyProp` fires for EVERY prop key, including events. `runtime.applyEvent`
|
|
335
|
-
// fires only for `on*` props — strict subset. Useful diagnostic ratios:
|
|
336
|
-
// applyEvent / applyProp = event-handler density per element
|
|
337
|
-
// applyProp - applyEvent = static / reactive attr density
|
|
338
|
-
// Don't subtract them and treat as disjoint.
|
|
339
|
-
export function applyProp(el: Element, key: string, value: unknown): Cleanup | null {
|
|
340
|
-
if (__DEV__) _countSink.__pyreon_count__?.('runtime.applyProp')
|
|
341
|
-
// Event listener: onClick → "click"
|
|
342
|
-
if (EVENT_RE.test(key)) return applyEventProp(el, key, value)
|
|
343
|
-
|
|
344
|
-
// Reactive prop — function value is an accessor closure. The JSX compiler
|
|
345
|
-
// emits `prop={someExpr(signal())}` as a `() => someExpr(signal())` thunk
|
|
346
|
-
// so the prop tracks the signal automatically. We wrap in `renderEffect`
|
|
347
|
-
// ONCE here, before any prop-kind dispatch, so EVERY sink gets the same
|
|
348
|
-
// reactive treatment. Previously special-cased sinks (innerHTML etc.) had
|
|
349
|
-
// early-return branches that bypassed this wrap and stringified the
|
|
350
|
-
// closure — the bug fixed by this restructure.
|
|
351
|
-
//
|
|
352
|
-
// Uses renderEffect (lighter than effect — no scope registration, no
|
|
353
|
-
// WeakMap) since lifecycle is managed by mountElement's cleanup array.
|
|
354
|
-
if (typeof value === 'function') {
|
|
355
|
-
return renderEffect(() => applyStaticProp(el, key, (value as () => unknown)()))
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
applyStaticProp(el, key, value)
|
|
359
|
-
return null
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Attributes that carry URLs and must be guarded against javascript:/data: injection.
|
|
363
|
-
const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'poster', 'cite', 'data'])
|
|
364
|
-
const UNSAFE_URL_RE = /^\s*(?:javascript|data):/i
|
|
365
|
-
|
|
366
|
-
// Track the CSS property names an element's last-applied style object set,
|
|
367
|
-
// so a reactive style going from `{ color, fontSize }` to `{ color }` removes
|
|
368
|
-
// the stale `fontSize`. React/Vue/Solid all do this diff; previously Pyreon
|
|
369
|
-
// only applied new keys, leaking the removed ones onto the DOM.
|
|
370
|
-
const _prevStyleKeys: WeakMap<HTMLElement, Set<string>> = new WeakMap()
|
|
371
|
-
|
|
372
|
-
/** Apply a style prop (string or object). */
|
|
373
|
-
function applyStyleProp(el: HTMLElement, value: unknown): void {
|
|
374
|
-
if (typeof value === 'string') {
|
|
375
|
-
// cssText replaces everything — drop any tracked object-mode keys.
|
|
376
|
-
el.style.cssText = value
|
|
377
|
-
_prevStyleKeys.delete(el)
|
|
378
|
-
return
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
const prev = _prevStyleKeys.get(el)
|
|
382
|
-
|
|
383
|
-
if (value == null) {
|
|
384
|
-
// Explicit null/undefined: clear whatever object-mode keys we set.
|
|
385
|
-
if (prev) {
|
|
386
|
-
for (const propName of prev) el.style.removeProperty(propName)
|
|
387
|
-
_prevStyleKeys.delete(el)
|
|
388
|
-
}
|
|
389
|
-
return
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
if (typeof value === 'object') {
|
|
393
|
-
const obj = value as Record<string, unknown>
|
|
394
|
-
const next = new Set<string>()
|
|
395
|
-
for (const k in obj) {
|
|
396
|
-
const propName = k.startsWith('--') ? k : toKebabCase(k)
|
|
397
|
-
next.add(propName)
|
|
398
|
-
const css = normalizeStyleValue(k, obj[k])
|
|
399
|
-
el.style.setProperty(propName, css)
|
|
400
|
-
}
|
|
401
|
-
if (prev) {
|
|
402
|
-
for (const propName of prev) {
|
|
403
|
-
if (!next.has(propName)) el.style.removeProperty(propName)
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
if (next.size === 0) _prevStyleKeys.delete(el)
|
|
407
|
-
else _prevStyleKeys.set(el, next)
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
function applyClassProp(el: Element, value: unknown): void {
|
|
412
|
-
const resolved = typeof value === 'string' ? value : cx(value as ClassValue)
|
|
413
|
-
el.setAttribute('class', resolved || '')
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function setStaticProp(el: Element, key: string, value: unknown): void {
|
|
417
|
-
// Block javascript:/data: URI injection in URL-bearing attributes.
|
|
418
|
-
if (URL_ATTRS.has(key) && typeof value === 'string' && UNSAFE_URL_RE.test(value)) {
|
|
419
|
-
if (__DEV__) {
|
|
420
|
-
console.warn(`[Pyreon] Blocked unsafe URL in "${key}" attribute: ${value}`)
|
|
421
|
-
}
|
|
422
|
-
return
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
if (key === 'class' || key === 'className') {
|
|
426
|
-
applyClassProp(el, value)
|
|
427
|
-
return
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
if (key === 'style') {
|
|
431
|
-
applyStyleProp(el as HTMLElement, value)
|
|
432
|
-
return
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
if (value == null) {
|
|
436
|
-
el.removeAttribute(key)
|
|
437
|
-
return
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
if (typeof value === 'boolean') {
|
|
441
|
-
if (value) el.setAttribute(key, '')
|
|
442
|
-
else el.removeAttribute(key)
|
|
443
|
-
return
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// SVG and MathML elements: ALWAYS use setAttribute. Many of their
|
|
447
|
-
// properties are read-only `SVGAnimated*` getters (e.g.
|
|
448
|
-
// `SVGMarkerElement.refX`, `SVGMarkerElement.markerWidth`,
|
|
449
|
-
// `SVGRectElement.x`, etc.). Trying `el[key] = value` on those
|
|
450
|
-
// crashes with "Cannot set property X of [object Object] which has
|
|
451
|
-
// only a getter". The standard React/Vue/Solid behavior is to
|
|
452
|
-
// skip the property assignment optimization for non-HTML elements
|
|
453
|
-
// and always go through setAttribute.
|
|
454
|
-
if (el.namespaceURI && el.namespaceURI !== 'http://www.w3.org/1999/xhtml') {
|
|
455
|
-
el.setAttribute(key, String(value))
|
|
456
|
-
return
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
if (key in el) {
|
|
460
|
-
;(el as unknown as Record<string, unknown>)[key] = value
|
|
461
|
-
return
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// Custom elements: set as property (element may not be upgraded yet,
|
|
465
|
-
// so `key in el` missed it). Properties set before upgrade are picked
|
|
466
|
-
// up when the element's constructor runs.
|
|
467
|
-
const tag = el.tagName
|
|
468
|
-
if (tag.includes('-')) {
|
|
469
|
-
;(el as unknown as Record<string, unknown>)[key] = value
|
|
470
|
-
return
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
el.setAttribute(key, String(value))
|
|
474
|
-
}
|