@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.
Files changed (53) hide show
  1. package/package.json +5 -9
  2. package/src/delegate.ts +0 -98
  3. package/src/devtools.ts +0 -339
  4. package/src/env.d.ts +0 -6
  5. package/src/hydrate.ts +0 -450
  6. package/src/hydration-debug.ts +0 -129
  7. package/src/index.ts +0 -83
  8. package/src/keep-alive-entry.ts +0 -3
  9. package/src/keep-alive.ts +0 -83
  10. package/src/manifest.ts +0 -236
  11. package/src/mount.ts +0 -597
  12. package/src/nodes.ts +0 -896
  13. package/src/props.ts +0 -474
  14. package/src/template.ts +0 -523
  15. package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
  16. package/src/tests/callback-ref-unmount.test.ts +0 -52
  17. package/src/tests/compiler-integration.test.tsx +0 -508
  18. package/src/tests/coverage-gaps.test.ts +0 -3183
  19. package/src/tests/coverage.test.ts +0 -1140
  20. package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
  21. package/src/tests/dev-gate-pattern.test.ts +0 -46
  22. package/src/tests/dev-gate-treeshake.test.ts +0 -256
  23. package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
  24. package/src/tests/fanout-repro.test.tsx +0 -219
  25. package/src/tests/hydration-integration.test.tsx +0 -540
  26. package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
  27. package/src/tests/lifecycle-integration.test.tsx +0 -342
  28. package/src/tests/lis-prepend.browser.test.ts +0 -99
  29. package/src/tests/manifest-snapshot.test.ts +0 -85
  30. package/src/tests/mount.test.ts +0 -3529
  31. package/src/tests/native-markers.test.ts +0 -19
  32. package/src/tests/props.test.ts +0 -581
  33. package/src/tests/reactive-props.test.ts +0 -270
  34. package/src/tests/real-world-integration.test.tsx +0 -714
  35. package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
  36. package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
  37. package/src/tests/rs-collapse-h.browser.test.ts +0 -152
  38. package/src/tests/rs-collapse-h.test.ts +0 -237
  39. package/src/tests/rs-collapse.browser.test.ts +0 -128
  40. package/src/tests/runtime-dom.browser.test.ts +0 -409
  41. package/src/tests/setup.ts +0 -3
  42. package/src/tests/show-context.test.ts +0 -270
  43. package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
  44. package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
  45. package/src/tests/style-key-removal.browser.test.ts +0 -54
  46. package/src/tests/style-key-removal.test.ts +0 -88
  47. package/src/tests/template.test.ts +0 -383
  48. package/src/tests/transition-timeout-leak.test.ts +0 -126
  49. package/src/tests/transition.test.ts +0 -568
  50. package/src/tests/verified-correct-probes.test.ts +0 -56
  51. package/src/transition-entry.ts +0 -7
  52. package/src/transition-group.ts +0 -350
  53. 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
- }