@pyreon/runtime-dom 0.1.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,39 @@
1
+ /**
2
+ * Hydration mismatch warnings.
3
+ *
4
+ * Enabled automatically in development (NODE_ENV !== "production").
5
+ * Can be toggled manually for testing or verbose production debugging.
6
+ *
7
+ * @example
8
+ * import { enableHydrationWarnings } from "@pyreon/runtime-dom"
9
+ * enableHydrationWarnings()
10
+ */
11
+
12
+ let _enabled = typeof process !== "undefined" && process.env.NODE_ENV !== "production"
13
+
14
+ export function enableHydrationWarnings(): void {
15
+ _enabled = true
16
+ }
17
+
18
+ export function disableHydrationWarnings(): void {
19
+ _enabled = false
20
+ }
21
+
22
+ /**
23
+ * Emit a hydration mismatch warning.
24
+ * @param type - Kind of mismatch
25
+ * @param expected - What the VNode expected
26
+ * @param actual - What the DOM had
27
+ * @param path - Human-readable path in the tree, e.g. "root > div > span"
28
+ */
29
+ export function warnHydrationMismatch(
30
+ _type: "tag" | "text" | "missing",
31
+ _expected: unknown,
32
+ _actual: unknown,
33
+ _path: string,
34
+ ): void {
35
+ if (!_enabled) return
36
+ console.warn(
37
+ `[Pyreon] Hydration mismatch (${_type}): expected ${String(_expected)}, got ${String(_actual)} at ${_path}`,
38
+ )
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ // @pyreon/runtime-dom — surgical signal-to-DOM renderer (no virtual DOM)
2
+
3
+ export type { DevtoolsComponentEntry, PyreonDevtools } from "./devtools"
4
+ export { hydrateRoot } from "./hydrate"
5
+ export { disableHydrationWarnings, enableHydrationWarnings } from "./hydration-debug"
6
+ export type { KeepAliveProps } from "./keep-alive"
7
+ export { KeepAlive } from "./keep-alive"
8
+ export { mountChild } from "./mount"
9
+ export type { Directive, SanitizeFn } from "./props"
10
+ export { applyProp, applyProps, sanitizeHtml, setSanitizer } from "./props"
11
+ export { _tpl, createTemplate } from "./template"
12
+ export type { TransitionProps } from "./transition"
13
+ export { Transition } from "./transition"
14
+ export type { TransitionGroupProps } from "./transition-group"
15
+ export { TransitionGroup } from "./transition-group"
16
+
17
+ import type { VNodeChild } from "@pyreon/core"
18
+ import { installDevTools } from "./devtools"
19
+ import { mountChild } from "./mount"
20
+
21
+ const __DEV__ = typeof process !== "undefined" && process.env.NODE_ENV !== "production"
22
+
23
+ /**
24
+ * Mount a VNode tree into a container element.
25
+ * Clears the container first, then mounts the given child.
26
+ * Returns an `unmount` function that removes everything and disposes effects.
27
+ *
28
+ * @example
29
+ * const unmount = mount(h("div", null, "Hello Pyreon"), document.getElementById("app")!)
30
+ */
31
+ export function mount(root: VNodeChild, container: Element): () => void {
32
+ if (__DEV__ && container == null) {
33
+ throw new Error(
34
+ '[pyreon] mount() called with a null/undefined container. Make sure the element exists in the DOM, e.g. document.getElementById("app")',
35
+ )
36
+ }
37
+ installDevTools()
38
+ container.innerHTML = ""
39
+ return mountChild(root, container, null)
40
+ }
41
+
42
+ /** Alias for `mount` */
43
+ export const render = mount
@@ -0,0 +1,71 @@
1
+ import type { Props, VNodeChild } from "@pyreon/core"
2
+ import { createRef, h, onMount } from "@pyreon/core"
3
+ import { effect } from "@pyreon/reactivity"
4
+ import { mountChild } from "./mount"
5
+
6
+ export interface KeepAliveProps extends Props {
7
+ /**
8
+ * Accessor that returns true when this slot's children should be visible.
9
+ * When false, children are CSS-hidden but remain mounted — effects and
10
+ * signals stay alive.
11
+ * Defaults to true (always visible / always mounted).
12
+ */
13
+ active?: () => boolean
14
+ children?: VNodeChild
15
+ }
16
+
17
+ /**
18
+ * KeepAlive — mounts its children once and keeps them alive even when hidden.
19
+ *
20
+ * Unlike conditional rendering (which destroys and recreates component state),
21
+ * KeepAlive CSS-hides the children while preserving all reactive state,
22
+ * scroll position, form values, and in-flight async operations.
23
+ *
24
+ * Children are mounted imperatively on first activation and are never unmounted
25
+ * while the KeepAlive itself is mounted.
26
+ *
27
+ * Multi-slot pattern (one KeepAlive per route):
28
+ * @example
29
+ * h(Fragment, null, [
30
+ * h(KeepAlive, { active: () => route() === "/a" }, h(RouteA, null)),
31
+ * h(KeepAlive, { active: () => route() === "/b" }, h(RouteB, null)),
32
+ * ])
33
+ *
34
+ * With JSX:
35
+ * @example
36
+ * <>
37
+ * <KeepAlive active={() => route() === "/a"}><RouteA /></KeepAlive>
38
+ * <KeepAlive active={() => route() === "/b"}><RouteB /></KeepAlive>
39
+ * </>
40
+ */
41
+ export function KeepAlive(props: KeepAliveProps): VNodeChild {
42
+ const containerRef = createRef<HTMLElement>()
43
+ let childCleanup: (() => void) | null = null
44
+ let childMounted = false
45
+
46
+ onMount(() => {
47
+ const container = containerRef.current as HTMLElement
48
+
49
+ const e = effect(() => {
50
+ const isActive = props.active?.() ?? true
51
+
52
+ if (!childMounted) {
53
+ // Mount children into the container div exactly once
54
+ childCleanup = mountChild(props.children ?? null, container, null)
55
+ childMounted = true
56
+ }
57
+
58
+ // Show/hide without unmounting — state is fully preserved
59
+ container.style.display = isActive ? "" : "none"
60
+ })
61
+
62
+ return () => {
63
+ e.dispose()
64
+ childCleanup?.()
65
+ }
66
+ })
67
+
68
+ // `display: contents` makes the wrapper transparent to layout
69
+ // (children appear as if directly in the parent flow)
70
+ return h("div", { ref: containerRef, style: "display: contents" })
71
+ }
package/src/mount.ts ADDED
@@ -0,0 +1,367 @@
1
+ import type {
2
+ ComponentFn,
3
+ ForProps,
4
+ NativeItem,
5
+ PortalProps,
6
+ Ref,
7
+ VNode,
8
+ VNodeChild,
9
+ } from "@pyreon/core"
10
+ import {
11
+ dispatchToErrorBoundary,
12
+ EMPTY_PROPS,
13
+ ForSymbol,
14
+ Fragment,
15
+ PortalSymbol,
16
+ propagateError,
17
+ reportError,
18
+ runWithHooks,
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
+
25
+ const __DEV__ = typeof process !== "undefined" && process.env.NODE_ENV !== "production"
26
+
27
+ type Cleanup = () => void
28
+ const noop: Cleanup = () => {
29
+ /* noop */
30
+ }
31
+
32
+ // When > 0, we're mounting children inside an element — child cleanups can skip
33
+ // DOM removal (parent element removal handles it). This avoids allocating a
34
+ // removeChild closure for every nested element that has no reactive work.
35
+ let _elementDepth = 0
36
+
37
+ // Stack tracking which component is currently being mounted (depth-first order).
38
+ // Used to infer parent/child relationships for DevTools.
39
+ const _mountingStack: string[] = []
40
+
41
+ /**
42
+ * Mount a single child into `parent`, inserting before `anchor` (null = append).
43
+ * Returns a cleanup that removes the node(s) and disposes all reactive effects.
44
+ *
45
+ * This function is the hot path — all child types are handled inline to avoid
46
+ * function call overhead in tight render loops (1000+ calls per list render).
47
+ */
48
+ export function mountChild(
49
+ child: VNodeChild | VNodeChild[] | (() => VNodeChild | VNodeChild[]),
50
+ parent: Node,
51
+ anchor: Node | null = null,
52
+ ): Cleanup {
53
+ // Reactive accessor — function that reads signals
54
+ if (typeof child === "function") {
55
+ const sample = runUntracked(() => (child as () => VNodeChild | VNodeChild[])())
56
+ if (isKeyedArray(sample)) {
57
+ const prevDepth = _elementDepth
58
+ _elementDepth = 0
59
+ const cleanup = mountKeyedList(child as () => VNode[], parent, anchor, (v, p, a) =>
60
+ mountChild(v, p, a),
61
+ )
62
+ _elementDepth = prevDepth
63
+ return cleanup
64
+ }
65
+ // Text fast path: reactive string/number/boolean — update text.data in-place
66
+ if (typeof sample === "string" || typeof sample === "number" || typeof sample === "boolean") {
67
+ const text = document.createTextNode(sample == null || sample === false ? "" : String(sample))
68
+ parent.insertBefore(text, anchor)
69
+ const dispose = renderEffect(() => {
70
+ const v = (child as () => unknown)()
71
+ text.data = v == null || v === false ? "" : String(v as string | number)
72
+ })
73
+ if (_elementDepth > 0) return dispose
74
+ return () => {
75
+ dispose()
76
+ const p = text.parentNode
77
+ if (p && (p as Element).isConnected !== false) p.removeChild(text)
78
+ }
79
+ }
80
+ const prevDepth = _elementDepth
81
+ _elementDepth = 0
82
+ const cleanup = mountReactive(child as () => VNodeChild, parent, anchor, mountChild)
83
+ _elementDepth = prevDepth
84
+ return cleanup
85
+ }
86
+
87
+ // Array of children (e.g. from .map())
88
+ if (Array.isArray(child)) return mountChildren(child, parent, anchor)
89
+
90
+ // Nothing to render
91
+ if (child == null || child === false) return noop
92
+
93
+ // Primitive — text node (static, no reactive effects to tear down).
94
+ if (typeof child !== "object") {
95
+ parent.insertBefore(document.createTextNode(String(child)), anchor)
96
+ return noop
97
+ }
98
+
99
+ // NativeItem — pre-built DOM element from _tpl() or createTemplate().
100
+ if ((child as unknown as NativeItem).__isNative) {
101
+ const native = child as unknown as NativeItem
102
+ parent.insertBefore(native.el, anchor)
103
+ if (!native.cleanup) {
104
+ if (_elementDepth > 0) return noop
105
+ return () => {
106
+ const p = native.el.parentNode
107
+ if (p && (p as Element).isConnected !== false) p.removeChild(native.el)
108
+ }
109
+ }
110
+ if (_elementDepth > 0) return native.cleanup
111
+ return () => {
112
+ native.cleanup?.()
113
+ const p = native.el.parentNode
114
+ if (p && (p as Element).isConnected !== false) p.removeChild(native.el)
115
+ }
116
+ }
117
+
118
+ // VNode — element, component, fragment, For, Portal
119
+ const vnode = child as VNode
120
+
121
+ if (vnode.type === Fragment) return mountChildren(vnode.children, parent, anchor)
122
+
123
+ if (vnode.type === (ForSymbol as unknown as string)) {
124
+ const { each, by, children } = vnode.props as unknown as ForProps<unknown>
125
+ const prevDepth = _elementDepth
126
+ _elementDepth = 0
127
+ const cleanup = mountFor(each, by, children, parent, anchor, mountChild)
128
+ _elementDepth = prevDepth
129
+ return cleanup
130
+ }
131
+
132
+ if (vnode.type === (PortalSymbol as unknown as string)) {
133
+ const { target, children } = vnode.props as unknown as PortalProps
134
+ if (__DEV__ && !target) return noop
135
+ return mountChild(children, target, null)
136
+ }
137
+
138
+ if (typeof vnode.type === "function") {
139
+ return mountComponent(vnode as VNode & { type: ComponentFn }, parent, anchor)
140
+ }
141
+
142
+ return mountElement(vnode, parent, anchor)
143
+ }
144
+
145
+ // ─── Element ─────────────────────────────────────────────────────────────────
146
+
147
+ function mountElement(vnode: VNode, parent: Node, anchor: Node | null): Cleanup {
148
+ const el = document.createElement(vnode.type as string)
149
+
150
+ // Skip applyProps entirely when props is the shared empty sentinel (identity check — no allocation)
151
+ const props = vnode.props
152
+ const propCleanup: Cleanup | null = props !== EMPTY_PROPS ? applyProps(el, props) : null
153
+
154
+ // Mount children inside element context — nested elements can skip DOM removal closures
155
+ _elementDepth++
156
+ const childCleanup = mountChildren(vnode.children, el, null)
157
+ _elementDepth--
158
+
159
+ parent.insertBefore(el, anchor)
160
+
161
+ // Populate ref after the element is in the DOM
162
+ const ref = props.ref as Ref<Element> | null | undefined
163
+ if (ref && typeof ref === "object") ref.current = el
164
+
165
+ if (!propCleanup && childCleanup === noop && !ref) {
166
+ if (_elementDepth > 0) return noop
167
+ return () => {
168
+ const p = el.parentNode
169
+ if (p && (p as Element).isConnected !== false) p.removeChild(el)
170
+ }
171
+ }
172
+
173
+ if (_elementDepth > 0) {
174
+ if (!ref && !propCleanup) return childCleanup
175
+ if (!ref && propCleanup)
176
+ return () => {
177
+ propCleanup()
178
+ childCleanup()
179
+ }
180
+ const refToClean = ref
181
+ return () => {
182
+ if (refToClean && typeof refToClean === "object") refToClean.current = null
183
+ if (propCleanup) propCleanup()
184
+ childCleanup()
185
+ }
186
+ }
187
+
188
+ return () => {
189
+ if (ref && typeof ref === "object") ref.current = null
190
+ if (propCleanup) propCleanup()
191
+ childCleanup()
192
+ const p = el.parentNode
193
+ if (p && (p as Element).isConnected !== false) p.removeChild(el)
194
+ }
195
+ }
196
+
197
+ // ─── Component ───────────────────────────────────────────────────────────────
198
+
199
+ function mountComponent(
200
+ vnode: VNode & { type: ComponentFn },
201
+ parent: Node,
202
+ anchor: Node | null,
203
+ ): Cleanup {
204
+ const scope = effectScope()
205
+ setCurrentScope(scope)
206
+
207
+ let hooks: ReturnType<typeof runWithHooks>["hooks"]
208
+ let output: VNode | null
209
+
210
+ const componentName = (vnode.type.name || "Anonymous") as string
211
+ const compId = `${componentName}-${Math.random().toString(36).slice(2, 9)}`
212
+ const parentId = _mountingStack[_mountingStack.length - 1] ?? null
213
+ _mountingStack.push(compId)
214
+
215
+ // Merge vnode.children into props.children if not already set
216
+ const mergedProps =
217
+ vnode.children.length > 0 && (vnode.props as Record<string, unknown>).children === undefined
218
+ ? {
219
+ ...vnode.props,
220
+ children: vnode.children.length === 1 ? vnode.children[0] : vnode.children,
221
+ }
222
+ : vnode.props
223
+
224
+ try {
225
+ const result = runWithHooks(vnode.type, mergedProps)
226
+ hooks = result.hooks
227
+ output = result.vnode
228
+ } catch (err) {
229
+ _mountingStack.pop()
230
+ setCurrentScope(null)
231
+ scope.stop()
232
+ reportError({
233
+ component: componentName,
234
+ phase: "setup",
235
+ error: err,
236
+ timestamp: Date.now(),
237
+ props: vnode.props as Record<string, unknown>,
238
+ })
239
+ dispatchToErrorBoundary(err)
240
+ return noop
241
+ } finally {
242
+ setCurrentScope(null)
243
+ }
244
+
245
+ if (__DEV__ && output != null && typeof output === "object" && !("type" in output)) {
246
+ console.warn(
247
+ `[Pyreon] Component <${componentName}> returned an invalid value. Components must return a VNode, string, null, or function.`,
248
+ )
249
+ }
250
+
251
+ for (const fn of hooks.update) {
252
+ scope.addUpdateHook(fn)
253
+ }
254
+
255
+ let subtreeCleanup: Cleanup = noop
256
+ try {
257
+ subtreeCleanup = output != null ? mountChild(output, parent, anchor) : noop
258
+ } catch (err) {
259
+ _mountingStack.pop()
260
+ scope.stop()
261
+ const handled = propagateError(err, hooks) || dispatchToErrorBoundary(err)
262
+ if (!handled)
263
+ reportError({
264
+ component: componentName,
265
+ phase: "render",
266
+ error: err,
267
+ timestamp: Date.now(),
268
+ props: vnode.props as Record<string, unknown>,
269
+ })
270
+ return noop
271
+ }
272
+
273
+ _mountingStack.pop()
274
+
275
+ const firstEl = parent instanceof Element ? parent.firstElementChild : null
276
+ registerComponent(compId, componentName, firstEl, parentId)
277
+
278
+ // Fire onMount hooks inline — effects created inside are tracked by the scope
279
+ const mountCleanups: Cleanup[] = []
280
+ for (const fn of hooks.mount) {
281
+ try {
282
+ let cleanup: (() => void) | undefined
283
+ scope.runInScope(() => {
284
+ cleanup = fn()
285
+ })
286
+ if (cleanup) mountCleanups.push(cleanup)
287
+ } catch (err) {
288
+ console.error(`[Pyreon] Error in onMount hook of <${componentName}>:`, err)
289
+ reportError({ component: componentName, phase: "mount", error: err, timestamp: Date.now() })
290
+ }
291
+ }
292
+
293
+ return () => {
294
+ unregisterComponent(compId)
295
+ scope.stop()
296
+ subtreeCleanup()
297
+ for (const fn of hooks.unmount) {
298
+ try {
299
+ fn()
300
+ } catch (err) {
301
+ console.error(`[Pyreon] Error in onUnmount hook of <${componentName}>:`, err)
302
+ reportError({
303
+ component: componentName,
304
+ phase: "unmount",
305
+ error: err,
306
+ timestamp: Date.now(),
307
+ })
308
+ }
309
+ }
310
+ for (const fn of mountCleanups) fn()
311
+ }
312
+ }
313
+
314
+ // ─── Children ────────────────────────────────────────────────────────────────
315
+
316
+ function mountChildren(children: VNodeChild[], parent: Node, anchor: Node | null): Cleanup {
317
+ if (children.length === 0) return noop
318
+
319
+ // 1-child fast path
320
+ if (children.length === 1) {
321
+ const c = children[0] as VNodeChild
322
+ if (c !== undefined) {
323
+ if (anchor === null && (typeof c === "string" || typeof c === "number")) {
324
+ ;(parent as HTMLElement).textContent = String(c)
325
+ return noop
326
+ }
327
+ return mountChild(c, parent, anchor)
328
+ }
329
+ }
330
+
331
+ // 2-child fast path — avoids .map() allocation (covers <tr><td/><td/></tr>)
332
+ if (children.length === 2) {
333
+ const c0 = children[0] as VNodeChild
334
+ const c1 = children[1] as VNodeChild
335
+ if (c0 !== undefined && c1 !== undefined) {
336
+ const d0 = mountChild(c0, parent, anchor)
337
+ const d1 = mountChild(c1, parent, anchor)
338
+ if (d0 === noop && d1 === noop) return noop
339
+ if (d0 === noop) return d1
340
+ if (d1 === noop) return d0
341
+ return () => {
342
+ d0()
343
+ d1()
344
+ }
345
+ }
346
+ }
347
+
348
+ const cleanups = children.map((c) => mountChild(c, parent, anchor))
349
+ return () => {
350
+ for (const fn of cleanups) fn()
351
+ }
352
+ }
353
+
354
+ // ─── Keyed array detection ────────────────────────────────────────────────────
355
+
356
+ /** Returns true if value is a non-empty array of VNodes that all carry keys. */
357
+ function isKeyedArray(value: unknown): value is VNode[] {
358
+ if (!Array.isArray(value) || value.length === 0) return false
359
+ return value.every(
360
+ (v) =>
361
+ v !== null &&
362
+ typeof v === "object" &&
363
+ !Array.isArray(v) &&
364
+ (v as VNode).key !== null &&
365
+ (v as VNode).key !== undefined,
366
+ )
367
+ }