@pyreon/runtime-dom 0.2.0 → 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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +143 -6
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +118 -6
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index2.d.ts +71 -1
- package/lib/types/index2.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/delegate.ts +82 -0
- package/src/hydrate.ts +23 -4
- package/src/index.ts +4 -1
- package/src/props.ts +13 -1
- package/src/template.ts +56 -0
- package/src/tests/mount.test.ts +2 -2
package/src/delegate.ts
ADDED
|
@@ -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 {
|
|
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
|
|
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
|
|
133
|
+
const dispose = renderEffect(() => {
|
|
123
134
|
const v = child()
|
|
124
135
|
textNode.data = v == null ? "" : String(v)
|
|
125
136
|
})
|
|
126
|
-
return [
|
|
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
|
-
//
|
|
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.
|
package/src/tests/mount.test.ts
CHANGED
|
@@ -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
|
})
|