@pyreon/runtime-dom 0.2.1 → 0.3.0

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.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Event delegation — single listener per event type on the mount container.
3
+ *
4
+ * Instead of calling addEventListener on every element, the compiler emits
5
+ * `el.__click = handler` (expando property). A single delegated listener on the
6
+ * container walks event.target up the DOM tree, checking for expandos.
7
+ *
8
+ * Benefits:
9
+ * - Saves ~2000 addEventListener calls for 1000 rows with 2 handlers each
10
+ * - Reduces memory per row (no per-element listener closure)
11
+ * - Faster initial mount (~0.4-0.8ms savings on 1000-row benchmarks)
12
+ */
13
+
14
+ import { batch } from "@pyreon/reactivity"
15
+
16
+ /**
17
+ * Events that are delegated (common bubbling events).
18
+ * Non-bubbling events (focus, blur, mouseenter, mouseleave, load, error, scroll)
19
+ * are NOT delegated — they must use addEventListener.
20
+ */
21
+ export const DELEGATED_EVENTS = new Set([
22
+ "click",
23
+ "dblclick",
24
+ "contextmenu",
25
+ "focusin",
26
+ "focusout",
27
+ "input",
28
+ "change",
29
+ "keydown",
30
+ "keyup",
31
+ "mousedown",
32
+ "mouseup",
33
+ "mousemove",
34
+ "mouseover",
35
+ "mouseout",
36
+ "pointerdown",
37
+ "pointerup",
38
+ "pointermove",
39
+ "pointerover",
40
+ "pointerout",
41
+ "touchstart",
42
+ "touchend",
43
+ "touchmove",
44
+ "submit",
45
+ ])
46
+
47
+ /**
48
+ * Property name used on DOM elements to store delegated event handlers.
49
+ * Format: `__ev_{eventName}` e.g. `__ev_click`, `__ev_input`
50
+ */
51
+ export function delegatedPropName(eventName: string): string {
52
+ return `__ev_${eventName}`
53
+ }
54
+
55
+ // Track which containers already have delegation installed
56
+ const _delegated = new WeakSet<Element>()
57
+
58
+ /**
59
+ * Install delegation listeners on a container element.
60
+ * Called once from mount(). Idempotent — safe to call multiple times.
61
+ */
62
+ export function setupDelegation(container: Element): void {
63
+ if (_delegated.has(container)) return
64
+ _delegated.add(container)
65
+
66
+ for (const eventName of DELEGATED_EVENTS) {
67
+ const prop = delegatedPropName(eventName)
68
+ container.addEventListener(eventName, (e: Event) => {
69
+ let el = e.target as (HTMLElement & Record<string, unknown>) | null
70
+ while (el && el !== container) {
71
+ const handler = el[prop] as EventListener | undefined
72
+ if (handler) {
73
+ batch(() => handler(e))
74
+ // Don't break — allow ancestor handlers too (consistent with addEventListener)
75
+ // But if stopPropagation was called, stop walking
76
+ if (e.cancelBubble) break
77
+ }
78
+ el = el.parentElement as (HTMLElement & Record<string, unknown>) | null
79
+ }
80
+ })
81
+ }
82
+ }
package/src/hydrate.ts CHANGED
@@ -25,7 +25,8 @@ import {
25
25
  reportError,
26
26
  runWithHooks,
27
27
  } from "@pyreon/core"
28
- import { effect, effectScope, runUntracked, setCurrentScope } from "@pyreon/reactivity"
28
+ import { effectScope, renderEffect, runUntracked, setCurrentScope } from "@pyreon/reactivity"
29
+ import { setupDelegation } from "./delegate"
29
30
  import { warnHydrationMismatch } from "./hydration-debug"
30
31
  import { mountChild } from "./mount"
31
32
  import { mountReactive } from "./nodes"
@@ -46,7 +47,7 @@ function firstReal(initialNode: ChildNode | null): ChildNode | null {
46
47
  node = node.nextSibling
47
48
  continue
48
49
  }
49
- if (node.nodeType === Node.TEXT_NODE && (node as Text).data.trim() === "") {
50
+ if (node.nodeType === Node.TEXT_NODE && isWhitespaceOnly((node as Text).data)) {
50
51
  node = node.nextSibling
51
52
  continue
52
53
  }
@@ -55,6 +56,16 @@ function firstReal(initialNode: ChildNode | null): ChildNode | null {
55
56
  return null
56
57
  }
57
58
 
59
+ /** Check if a string is whitespace-only without allocating a trimmed copy. */
60
+ function isWhitespaceOnly(s: string): boolean {
61
+ for (let i = 0; i < s.length; i++) {
62
+ const c = s.charCodeAt(i)
63
+ // space, tab, newline, carriage return, form feed
64
+ if (c !== 32 && c !== 9 && c !== 10 && c !== 13 && c !== 12) return false
65
+ }
66
+ return true
67
+ }
68
+
58
69
  /** Advance past a node, skipping whitespace-only text and comments */
59
70
  function nextReal(node: ChildNode): ChildNode | null {
60
71
  return firstReal(node.nextSibling)
@@ -119,11 +130,11 @@ function hydrateReactiveText(
119
130
  ): [Cleanup, ChildNode | null] {
120
131
  if (domNode?.nodeType === Node.TEXT_NODE) {
121
132
  const textNode = domNode as Text
122
- const e = effect(() => {
133
+ const dispose = renderEffect(() => {
123
134
  const v = child()
124
135
  textNode.data = v == null ? "" : String(v)
125
136
  })
126
- return [() => e.dispose(), nextReal(domNode)]
137
+ return [dispose, nextReal(domNode)]
127
138
  }
128
139
  warnHydrationMismatch("text", "TextNode", domNode?.nodeType ?? "null", `${path} > reactive`)
129
140
  const cleanup = mountChild(child, parent, anchor)
@@ -265,6 +276,13 @@ function hydrateChildren(
265
276
  anchor: Node | null,
266
277
  path = "root",
267
278
  ): [Cleanup, ChildNode | null] {
279
+ if (children.length === 0) return [noop, domNode]
280
+
281
+ // Single-child fast path — avoids cleanups array allocation
282
+ if (children.length === 1) {
283
+ return hydrateChild(children[0] as VNodeChild, domNode, parent, anchor, path)
284
+ }
285
+
268
286
  const cleanups: Cleanup[] = []
269
287
  let cursor = domNode
270
288
  for (const child of children) {
@@ -379,6 +397,7 @@ function hydrateComponent(
379
397
  * const unmount = hydrateRoot(document.getElementById("app")!, h(App, null))
380
398
  */
381
399
  export function hydrateRoot(container: Element, vnode: VNodeChild): () => void {
400
+ setupDelegation(container)
382
401
  const firstChild = firstReal(container.firstChild as ChildNode | null)
383
402
  const [cleanup] = hydrateChild(vnode, firstChild, container, null)
384
403
  return cleanup
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // @pyreon/runtime-dom — surgical signal-to-DOM renderer (no virtual DOM)
2
2
 
3
+ export { DELEGATED_EVENTS, delegatedPropName, setupDelegation } from "./delegate"
3
4
  export type { DevtoolsComponentEntry, PyreonDevtools } from "./devtools"
4
5
  export { hydrateRoot } from "./hydrate"
5
6
  export { disableHydrationWarnings, enableHydrationWarnings } from "./hydration-debug"
@@ -8,13 +9,14 @@ export { KeepAlive } from "./keep-alive"
8
9
  export { mountChild } from "./mount"
9
10
  export type { Directive, SanitizeFn } from "./props"
10
11
  export { applyProp, applyProps, sanitizeHtml, setSanitizer } from "./props"
11
- export { _tpl, createTemplate } from "./template"
12
+ export { _bindDirect, _bindText, _tpl, createTemplate } from "./template"
12
13
  export type { TransitionProps } from "./transition"
13
14
  export { Transition } from "./transition"
14
15
  export type { TransitionGroupProps } from "./transition-group"
15
16
  export { TransitionGroup } from "./transition-group"
16
17
 
17
18
  import type { VNodeChild } from "@pyreon/core"
19
+ import { setupDelegation } from "./delegate"
18
20
  import { installDevTools } from "./devtools"
19
21
  import { mountChild } from "./mount"
20
22
 
@@ -35,6 +37,7 @@ export function mount(root: VNodeChild, container: Element): () => void {
35
37
  )
36
38
  }
37
39
  installDevTools()
40
+ setupDelegation(container)
38
41
  container.innerHTML = ""
39
42
  return mountChild(root, container, null)
40
43
  }
package/src/props.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Props } from "@pyreon/core"
2
2
  import { batch, renderEffect } from "@pyreon/reactivity"
3
+ import { DELEGATED_EVENTS, delegatedPropName } from "./delegate"
3
4
 
4
5
  type Cleanup = () => void
5
6
 
@@ -214,10 +215,21 @@ export function applyProps(el: Element, props: Props): Cleanup | null {
214
215
  */
215
216
  export function applyProp(el: Element, key: string, value: unknown): Cleanup | null {
216
217
  // Event listener: onClick → "click"
217
- // Wrapped in batch() so multiple signal writes from one handler coalesce into one DOM update.
218
+ // Delegated events use expando properties (picked up by the container's delegated listener).
219
+ // Non-delegated events use addEventListener directly.
220
+ // Both paths wrap in batch() so multiple signal writes coalesce into one DOM update.
218
221
  if (EVENT_RE.test(key)) {
219
222
  const eventName = key[2]?.toLowerCase() + key.slice(3)
220
223
  const handler = value as EventListener
224
+
225
+ if (DELEGATED_EVENTS.has(eventName)) {
226
+ const prop = delegatedPropName(eventName)
227
+ ;(el as unknown as Record<string, unknown>)[prop] = (e: Event) => batch(() => handler(e))
228
+ return () => {
229
+ ;(el as unknown as Record<string, unknown>)[prop] = undefined
230
+ }
231
+ }
232
+
221
233
  const batched: EventListener = (e) => batch(() => handler(e))
222
234
  el.addEventListener(eventName, batched)
223
235
  return () => el.removeEventListener(eventName, batched)
package/src/template.ts CHANGED
@@ -40,6 +40,62 @@ export function createTemplate<T>(
40
40
  }
41
41
  }
42
42
 
43
+ // ─── Direct text binding (bypasses effect system) ────────────────────────────
44
+
45
+ /**
46
+ * Compiler-emitted direct text binding for single-signal text nodes.
47
+ *
48
+ * When the compiler detects `{signal()}` as the only reactive expression
49
+ * in a text binding, it emits `_bindText(signal, textNode)` instead of
50
+ * `_bind(() => { textNode.data = signal() })`.
51
+ *
52
+ * This bypasses the effect system entirely:
53
+ * - No deps array allocation
54
+ * - No withTracking / setDepsCollector overhead
55
+ * - No `run` closure
56
+ * - Signal.subscribe is used directly (O(1) subscribe + unsubscribe)
57
+ *
58
+ * @param source - A signal (anything with `._v` and `.direct`)
59
+ * @param node - The Text node to update
60
+ */
61
+ export function _bindText(
62
+ source: { _v: unknown; direct: (fn: () => void) => () => void },
63
+ node: Text,
64
+ ): () => void {
65
+ const update = () => {
66
+ const v = source._v
67
+ node.data = v == null || v === false ? "" : String(v as string | number)
68
+ }
69
+ update()
70
+ return source.direct(update)
71
+ }
72
+
73
+ // ─── Direct signal binding (bypasses effect system) ──────────────────────────
74
+
75
+ /**
76
+ * Compiler-emitted direct binding for single-signal reactive expressions.
77
+ *
78
+ * Like _bindText but for arbitrary DOM updates (attributes, className, style).
79
+ * When the compiler detects that a reactive expression depends on exactly one
80
+ * signal call, it emits `_bindDirect(signal, updater)` instead of
81
+ * `_bind(() => { updater() })`.
82
+ *
83
+ * Uses signal.direct() for zero-overhead registration:
84
+ * - Flat array instead of Set (no hashing)
85
+ * - Index-based disposal (no Set.delete)
86
+ * - No deps array, no withTracking, no run closure
87
+ *
88
+ * @param source - A signal (anything with `._v` and `.direct`)
89
+ * @param updater - Function that reads `source._v` and applies the DOM update
90
+ */
91
+ export function _bindDirect(
92
+ source: { _v: unknown; direct: (fn: () => void) => () => void },
93
+ updater: (value: unknown) => void,
94
+ ): () => void {
95
+ updater(source._v)
96
+ return source.direct(() => updater(source._v))
97
+ }
98
+
43
99
  // ─── Compiler-facing template API ─────────────────────────────────────────────
44
100
 
45
101
  // Cache parsed <template> elements by HTML string — parse once, clone many.
@@ -1010,8 +1010,8 @@ describe("mount — props (extended)", () => {
1010
1010
  el,
1011
1011
  )
1012
1012
  const div = el.querySelector("div") as HTMLElement
1013
- div.dispatchEvent(new MouseEvent("mousedown"))
1014
- div.dispatchEvent(new MouseEvent("mouseup"))
1013
+ div.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }))
1014
+ div.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }))
1015
1015
  expect(mouseDown).toBe(true)
1016
1016
  expect(mouseUp).toBe(true)
1017
1017
  })