@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,304 @@
1
+ /**
2
+ * Pyreon DevTools — exposes a `__PYREON_DEVTOOLS__` global hook for browser devtools extensions
3
+ * and in-app debugging utilities.
4
+ *
5
+ * Installed automatically on first `mount()` call in the browser.
6
+ * No-op on the server (typeof window === "undefined").
7
+ *
8
+ * Usage:
9
+ * window.__PYREON_DEVTOOLS__.getComponentTree() // root component entries
10
+ * window.__PYREON_DEVTOOLS__.getAllComponents() // flat list of all live components
11
+ * window.__PYREON_DEVTOOLS__.highlight("comp-id") // outline a component's DOM node
12
+ * window.__PYREON_DEVTOOLS__.onComponentMount(cb) // subscribe to mount events
13
+ * window.__PYREON_DEVTOOLS__.onComponentUnmount(cb)// subscribe to unmount events
14
+ * window.__PYREON_DEVTOOLS__.enableOverlay() // Ctrl+Shift+P: hover to inspect components
15
+ */
16
+
17
+ export interface DevtoolsComponentEntry {
18
+ id: string
19
+ name: string
20
+ /** First DOM element produced by this component, if any */
21
+ el: Element | null
22
+ parentId: string | null
23
+ childIds: string[]
24
+ }
25
+
26
+ export interface PyreonDevtools {
27
+ readonly version: string
28
+ getComponentTree(): DevtoolsComponentEntry[]
29
+ getAllComponents(): DevtoolsComponentEntry[]
30
+ highlight(id: string): void
31
+ onComponentMount(cb: (entry: DevtoolsComponentEntry) => void): () => void
32
+ onComponentUnmount(cb: (id: string) => void): () => void
33
+ /** Toggle the component inspector overlay (also: Ctrl+Shift+P) */
34
+ enableOverlay(): void
35
+ disableOverlay(): void
36
+ }
37
+
38
+ // ─── Internal registry ────────────────────────────────────────────────────────
39
+
40
+ const _components = new Map<string, DevtoolsComponentEntry>()
41
+ const _mountListeners: ((entry: DevtoolsComponentEntry) => void)[] = []
42
+ const _unmountListeners: ((id: string) => void)[] = []
43
+
44
+ export function registerComponent(
45
+ id: string,
46
+ name: string,
47
+ el: Element | null,
48
+ parentId: string | null,
49
+ ): void {
50
+ const entry: DevtoolsComponentEntry = { id, name, el, parentId, childIds: [] }
51
+ _components.set(id, entry)
52
+ if (parentId) {
53
+ const parent = _components.get(parentId)
54
+ if (parent) parent.childIds.push(id)
55
+ }
56
+ for (const cb of _mountListeners) cb(entry)
57
+ }
58
+
59
+ export function unregisterComponent(id: string): void {
60
+ const entry = _components.get(id)
61
+ if (!entry) return
62
+ if (entry.parentId) {
63
+ const parent = _components.get(entry.parentId)
64
+ if (parent) parent.childIds = parent.childIds.filter((c) => c !== id)
65
+ }
66
+ _components.delete(id)
67
+ for (const cb of _unmountListeners) cb(id)
68
+ }
69
+
70
+ // ─── Component Inspector Overlay ─────────────────────────────────────────────
71
+
72
+ let _overlayActive = false
73
+ let _overlayEl: HTMLDivElement | null = null
74
+ let _tooltipEl: HTMLDivElement | null = null
75
+ let _currentHighlight: Element | null = null
76
+
77
+ function findComponentForElement(el: Element): DevtoolsComponentEntry | null {
78
+ // Walk up from the hovered element to find the nearest registered component
79
+ let node: Element | null = el
80
+ while (node) {
81
+ for (const entry of _components.values()) {
82
+ if (entry.el === node) return entry
83
+ }
84
+ node = node.parentElement
85
+ }
86
+ return null
87
+ }
88
+
89
+ function createOverlayElements(): void {
90
+ if (_overlayEl) return
91
+
92
+ _overlayEl = document.createElement("div")
93
+ _overlayEl.id = "__pyreon-overlay"
94
+ _overlayEl.style.cssText =
95
+ "position:fixed;pointer-events:none;border:2px solid #00b4d8;border-radius:3px;z-index:999999;display:none;transition:all 0.08s ease-out;"
96
+
97
+ _tooltipEl = document.createElement("div")
98
+ _tooltipEl.style.cssText =
99
+ "position:fixed;pointer-events:none;background:#1a1a2e;color:#e0e0e0;font:12px/1.4 ui-monospace,monospace;padding:6px 10px;border-radius:4px;z-index:999999;display:none;box-shadow:0 2px 8px rgba(0,0,0,0.3);max-width:400px;white-space:pre-wrap;"
100
+
101
+ document.body.appendChild(_overlayEl)
102
+ document.body.appendChild(_tooltipEl)
103
+ }
104
+
105
+ function positionOverlay(rect: DOMRect): void {
106
+ if (!_overlayEl) return
107
+ _overlayEl.style.display = "block"
108
+ _overlayEl.style.top = `${rect.top}px`
109
+ _overlayEl.style.left = `${rect.left}px`
110
+ _overlayEl.style.width = `${rect.width}px`
111
+ _overlayEl.style.height = `${rect.height}px`
112
+ }
113
+
114
+ function positionTooltip(entry: DevtoolsComponentEntry, rect: DOMRect): void {
115
+ if (!_tooltipEl) return
116
+ const childCount = entry.childIds.length
117
+ let info = `<${entry.name}>`
118
+ if (childCount > 0) info += `\n ${childCount} child component${childCount === 1 ? "" : "s"}`
119
+ _tooltipEl.textContent = info
120
+ _tooltipEl.style.display = "block"
121
+ _tooltipEl.style.top = `${rect.top - 30}px`
122
+ _tooltipEl.style.left = `${rect.left}px`
123
+ if (rect.top < 35) {
124
+ _tooltipEl.style.top = `${rect.bottom + 4}px`
125
+ }
126
+ }
127
+
128
+ function hideOverlayElements(): void {
129
+ if (_overlayEl) _overlayEl.style.display = "none"
130
+ if (_tooltipEl) _tooltipEl.style.display = "none"
131
+ _currentHighlight = null
132
+ }
133
+
134
+ /** @internal — exported for testing only */
135
+ export function onOverlayMouseMove(e: MouseEvent): void {
136
+ const target = document.elementFromPoint(e.clientX, e.clientY)
137
+ if (!target || target === _overlayEl || target === _tooltipEl) return
138
+
139
+ const entry = findComponentForElement(target)
140
+ if (!entry?.el) {
141
+ hideOverlayElements()
142
+ return
143
+ }
144
+
145
+ if (entry.el === _currentHighlight) return
146
+ _currentHighlight = entry.el
147
+
148
+ const rect = entry.el.getBoundingClientRect()
149
+ positionOverlay(rect)
150
+ positionTooltip(entry, rect)
151
+ }
152
+
153
+ /** @internal — exported for testing only */
154
+ export function onOverlayClick(e: MouseEvent): void {
155
+ e.preventDefault()
156
+ e.stopPropagation()
157
+ const target = document.elementFromPoint(e.clientX, e.clientY)
158
+ if (!target) return
159
+ const entry = findComponentForElement(target)
160
+ if (entry) {
161
+ console.group(`[Pyreon] <${entry.name}>`)
162
+ console.log("element:", entry.el)
163
+ console.log("children:", entry.childIds.length)
164
+ if (entry.parentId) {
165
+ const parent = _components.get(entry.parentId)
166
+ if (parent) {
167
+ console.log("parent:", `<${parent.name}>`)
168
+ }
169
+ }
170
+ console.groupEnd()
171
+ }
172
+ disableOverlay()
173
+ }
174
+
175
+ function onOverlayKeydown(e: KeyboardEvent): void {
176
+ if (e.key === "Escape") {
177
+ disableOverlay()
178
+ }
179
+ }
180
+
181
+ function enableOverlay(): void {
182
+ if (_overlayActive) return
183
+ _overlayActive = true
184
+ createOverlayElements()
185
+ document.addEventListener("mousemove", onOverlayMouseMove, true)
186
+ document.addEventListener("click", onOverlayClick, true)
187
+ document.addEventListener("keydown", onOverlayKeydown, true)
188
+ document.body.style.cursor = "crosshair"
189
+ }
190
+
191
+ function disableOverlay(): void {
192
+ if (!_overlayActive) return
193
+ _overlayActive = false
194
+ document.removeEventListener("mousemove", onOverlayMouseMove, true)
195
+ document.removeEventListener("click", onOverlayClick, true)
196
+ document.removeEventListener("keydown", onOverlayKeydown, true)
197
+ document.body.style.cursor = ""
198
+ if (_overlayEl) _overlayEl.style.display = "none"
199
+ if (_tooltipEl) _tooltipEl.style.display = "none"
200
+ _currentHighlight = null
201
+ }
202
+
203
+ // ─── Installation ─────────────────────────────────────────────────────────────
204
+
205
+ let _installed = false
206
+ // Resolved once at module load — avoids per-call typeof branch in coverage
207
+ const _hasWindow = typeof window !== "undefined"
208
+
209
+ export function installDevTools(): void {
210
+ if (!_hasWindow || _installed) return
211
+ _installed = true
212
+
213
+ const devtools: PyreonDevtools = {
214
+ version: "0.1.0",
215
+
216
+ getComponentTree() {
217
+ return Array.from(_components.values()).filter((e) => e.parentId === null)
218
+ },
219
+
220
+ getAllComponents() {
221
+ return Array.from(_components.values())
222
+ },
223
+
224
+ highlight(id: string) {
225
+ const entry = _components.get(id)
226
+ if (!entry?.el) return
227
+ const el = entry.el as HTMLElement
228
+ const prev = el.style.outline
229
+ el.style.outline = "2px solid #00b4d8"
230
+ setTimeout(() => {
231
+ el.style.outline = prev
232
+ }, 1500)
233
+ },
234
+
235
+ onComponentMount(cb: (entry: DevtoolsComponentEntry) => void): () => void {
236
+ _mountListeners.push(cb)
237
+ return () => {
238
+ const i = _mountListeners.indexOf(cb)
239
+ if (i >= 0) _mountListeners.splice(i, 1)
240
+ }
241
+ },
242
+
243
+ onComponentUnmount(cb: (id: string) => void): () => void {
244
+ _unmountListeners.push(cb)
245
+ return () => {
246
+ const i = _unmountListeners.indexOf(cb)
247
+ if (i >= 0) _unmountListeners.splice(i, 1)
248
+ }
249
+ },
250
+
251
+ enableOverlay,
252
+ disableOverlay,
253
+ }
254
+
255
+ // Attach to window — compatible with browser devtools extensions
256
+ ;(window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ = devtools
257
+
258
+ // Ctrl+Shift+P toggles the component inspector overlay
259
+ window.addEventListener("keydown", (e) => {
260
+ if (e.ctrlKey && e.shiftKey && e.key === "P") {
261
+ e.preventDefault()
262
+ if (_overlayActive) disableOverlay()
263
+ else enableOverlay()
264
+ }
265
+ })
266
+
267
+ // ── $p console helper ────────────────────────────────────────────────────
268
+ // Type `$p` in the browser console for quick access to Pyreon debug tools.
269
+ const win = window as unknown as Record<string, unknown>
270
+ win.$p = {
271
+ /** List all mounted components */
272
+ components: () => devtools.getAllComponents(),
273
+ /** Component tree (roots only) */
274
+ tree: () => devtools.getComponentTree(),
275
+ /** Highlight a component by id */
276
+ highlight: (id: string) => devtools.highlight(id),
277
+ /** Toggle component inspector overlay */
278
+ inspect: () => {
279
+ if (_overlayActive) disableOverlay()
280
+ else enableOverlay()
281
+ },
282
+ /** Print component count */
283
+ stats: () => {
284
+ const all = devtools.getAllComponents()
285
+ const roots = devtools.getComponentTree()
286
+ console.log(
287
+ `[Pyreon] ${all.length} component${all.length === 1 ? "" : "s"}, ${roots.length} root${roots.length === 1 ? "" : "s"}`,
288
+ )
289
+ return { total: all.length, roots: roots.length }
290
+ },
291
+ /** Quick help */
292
+ help: () => {
293
+ console.log(
294
+ "[Pyreon] $p commands:\n" +
295
+ " $p.components() — list all mounted components\n" +
296
+ " $p.tree() — component tree (roots only)\n" +
297
+ " $p.highlight(id)— outline a component\n" +
298
+ " $p.inspect() — toggle component inspector\n" +
299
+ " $p.stats() — print component count\n" +
300
+ " $p.help() — this message",
301
+ )
302
+ },
303
+ }
304
+ }
package/src/hydrate.ts ADDED
@@ -0,0 +1,385 @@
1
+ /**
2
+ * SSR Hydration — "walk-and-claim" strategy.
3
+ *
4
+ * The server renders plain HTML (no special markers needed). On the client,
5
+ * hydrateRoot walks the VNode tree in parallel with the live DOM tree and:
6
+ *
7
+ * - Static elements → matched by tag position, props attached (events + reactive effects)
8
+ * - Static text → existing text node reused
9
+ * - Reactive text → existing text node found, reactive effect attached to .data
10
+ * - Reactive blocks → comment marker inserted, mountReactive takes over
11
+ * - Components → component fn called, output VNode matched against DOM subtree
12
+ * - For lists → full remount (can't map keys to DOM without SSR markers)
13
+ * - Fragment → transparent, children matched directly
14
+ * - Portal → always remounts into target
15
+ *
16
+ * Falls back to mountChild() whenever DOM structure doesn't match the VNode.
17
+ */
18
+
19
+ import type { ComponentFn, Ref, VNode, VNodeChild } from "@pyreon/core"
20
+ import {
21
+ dispatchToErrorBoundary,
22
+ ForSymbol,
23
+ Fragment,
24
+ PortalSymbol,
25
+ reportError,
26
+ runWithHooks,
27
+ } from "@pyreon/core"
28
+ import { effect, effectScope, runUntracked, setCurrentScope } from "@pyreon/reactivity"
29
+ import { warnHydrationMismatch } from "./hydration-debug"
30
+ import { mountChild } from "./mount"
31
+ import { mountReactive } from "./nodes"
32
+ import { applyProps } from "./props"
33
+
34
+ type Cleanup = () => void
35
+ const noop: Cleanup = () => {
36
+ /* noop */
37
+ }
38
+
39
+ // ─── DOM cursor helpers ───────────────────────────────────────────────────────
40
+
41
+ /** Skip comment and whitespace-only text nodes, return first "real" node */
42
+ function firstReal(initialNode: ChildNode | null): ChildNode | null {
43
+ let node = initialNode
44
+ while (node) {
45
+ if (node.nodeType === Node.COMMENT_NODE) {
46
+ node = node.nextSibling
47
+ continue
48
+ }
49
+ if (node.nodeType === Node.TEXT_NODE && (node as Text).data.trim() === "") {
50
+ node = node.nextSibling
51
+ continue
52
+ }
53
+ return node
54
+ }
55
+ return null
56
+ }
57
+
58
+ /** Advance past a node, skipping whitespace-only text and comments */
59
+ function nextReal(node: ChildNode): ChildNode | null {
60
+ return firstReal(node.nextSibling)
61
+ }
62
+
63
+ // ─── Core recursive walker ────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * Hydrate a single VNodeChild against the DOM subtree starting at `domNode`.
67
+ * Returns [cleanup, nextDomSibling].
68
+ */
69
+ /** Insert a comment marker before domNode (or append if domNode is null). */
70
+ function insertMarker(parent: Node, domNode: ChildNode | null, text: string): Comment {
71
+ const marker = document.createComment(text)
72
+ if (domNode) {
73
+ parent.insertBefore(marker, domNode)
74
+ } else {
75
+ parent.appendChild(marker)
76
+ }
77
+ return marker
78
+ }
79
+
80
+ /** Hydrate a reactive accessor (function child). */
81
+ function hydrateReactiveChild(
82
+ child: () => VNodeChild,
83
+ domNode: ChildNode | null,
84
+ parent: Node,
85
+ anchor: Node | null,
86
+ path: string,
87
+ ): [Cleanup, ChildNode | null] {
88
+ const initial = runUntracked(child)
89
+
90
+ if (initial == null || initial === false) {
91
+ const marker = insertMarker(parent, domNode, "pyreon")
92
+ const cleanup = mountReactive(child, parent, marker, mountChild)
93
+ return [cleanup, domNode]
94
+ }
95
+
96
+ if (typeof initial === "string" || typeof initial === "number" || typeof initial === "boolean") {
97
+ return hydrateReactiveText(
98
+ child as () => string | number | boolean | null | undefined,
99
+ domNode,
100
+ parent,
101
+ anchor,
102
+ path,
103
+ )
104
+ }
105
+
106
+ const marker = insertMarker(parent, domNode, "pyreon")
107
+ const cleanup = mountReactive(child, parent, marker, mountChild)
108
+ const next = domNode ? nextReal(domNode) : null
109
+ return [cleanup, next]
110
+ }
111
+
112
+ /** Hydrate a reactive text binding against an existing text node. */
113
+ function hydrateReactiveText(
114
+ child: () => string | number | boolean | null | undefined,
115
+ domNode: ChildNode | null,
116
+ parent: Node,
117
+ anchor: Node | null,
118
+ path: string,
119
+ ): [Cleanup, ChildNode | null] {
120
+ if (domNode?.nodeType === Node.TEXT_NODE) {
121
+ const textNode = domNode as Text
122
+ const e = effect(() => {
123
+ const v = child()
124
+ textNode.data = v == null ? "" : String(v)
125
+ })
126
+ return [() => e.dispose(), nextReal(domNode)]
127
+ }
128
+ warnHydrationMismatch("text", "TextNode", domNode?.nodeType ?? "null", `${path} > reactive`)
129
+ const cleanup = mountChild(child, parent, anchor)
130
+ return [cleanup, domNode]
131
+ }
132
+
133
+ /** Hydrate a VNode (fragment, For, Portal, component, element). */
134
+ function hydrateVNode(
135
+ vnode: VNode,
136
+ domNode: ChildNode | null,
137
+ parent: Node,
138
+ anchor: Node | null,
139
+ path: string,
140
+ ): [Cleanup, ChildNode | null] {
141
+ if (vnode.type === Fragment) {
142
+ return hydrateChildren(vnode.children, domNode, parent, anchor, path)
143
+ }
144
+
145
+ if (vnode.type === ForSymbol) {
146
+ const marker = insertMarker(parent, domNode, "pyreon-for")
147
+ const cleanup = mountChild(vnode, parent, marker)
148
+ return [cleanup, null]
149
+ }
150
+
151
+ if (vnode.type === PortalSymbol) {
152
+ const cleanup = mountChild(vnode, parent, anchor)
153
+ return [cleanup, domNode]
154
+ }
155
+
156
+ if (typeof vnode.type === "function") {
157
+ return hydrateComponent(vnode, domNode, parent, anchor, path)
158
+ }
159
+
160
+ if (typeof vnode.type === "string") {
161
+ return hydrateElement(vnode, domNode, parent, anchor, path)
162
+ }
163
+
164
+ return [noop, domNode]
165
+ }
166
+
167
+ function hydrateChild(
168
+ child: VNodeChild | VNodeChild[],
169
+ domNode: ChildNode | null,
170
+ parent: Node,
171
+ anchor: Node | null,
172
+ path = "root",
173
+ ): [Cleanup, ChildNode | null] {
174
+ if (Array.isArray(child)) {
175
+ const cleanups: Cleanup[] = []
176
+ let cursor = domNode
177
+ for (const c of child) {
178
+ const [cleanup, next] = hydrateChild(c, cursor, parent, anchor, path)
179
+ cleanups.push(cleanup)
180
+ cursor = next
181
+ }
182
+ return [
183
+ () => {
184
+ for (const c of cleanups) c()
185
+ },
186
+ cursor,
187
+ ]
188
+ }
189
+
190
+ if (child == null || child === false) return [noop, domNode]
191
+
192
+ if (typeof child === "function") {
193
+ return hydrateReactiveChild(child as () => VNodeChild, domNode, parent, anchor, path)
194
+ }
195
+
196
+ if (typeof child === "string" || typeof child === "number") {
197
+ if (domNode?.nodeType === Node.TEXT_NODE) {
198
+ return [() => (domNode as Text).remove(), nextReal(domNode)]
199
+ }
200
+ warnHydrationMismatch("text", "TextNode", domNode?.nodeType ?? "null", `${path} > text`)
201
+ const cleanup = mountChild(child, parent, anchor)
202
+ return [cleanup, domNode]
203
+ }
204
+
205
+ return hydrateVNode(child as VNode, domNode, parent, anchor, path)
206
+ }
207
+
208
+ // ─── Element hydration ────────────────────────────────────────────────────────
209
+
210
+ function hydrateElement(
211
+ vnode: VNode,
212
+ domNode: ChildNode | null,
213
+ parent: Node,
214
+ anchor: Node | null,
215
+ path = "root",
216
+ ): [Cleanup, ChildNode | null] {
217
+ const elPath = `${path} > ${vnode.type as string}`
218
+
219
+ // Check if existing DOM node matches
220
+ if (
221
+ domNode?.nodeType === Node.ELEMENT_NODE &&
222
+ (domNode as Element).tagName.toLowerCase() === vnode.type
223
+ ) {
224
+ const el = domNode as Element
225
+ const cleanups: Cleanup[] = []
226
+
227
+ // Attach props (events + reactive effects) — don't set static attrs (SSR already did)
228
+ const propCleanup = applyProps(el, vnode.props)
229
+ if (propCleanup) cleanups.push(propCleanup)
230
+
231
+ // Hydrate children
232
+ const firstChild = firstReal(el.firstChild as ChildNode | null)
233
+ const [childCleanup] = hydrateChildren(vnode.children, firstChild, el, null, elPath)
234
+ cleanups.push(childCleanup)
235
+
236
+ // Set ref
237
+ const ref = vnode.props.ref as Ref<Element> | undefined
238
+ if (ref && typeof ref === "object") ref.current = el
239
+
240
+ const cleanup = () => {
241
+ if (ref && typeof ref === "object") ref.current = null
242
+ for (const c of cleanups) c()
243
+ el.remove()
244
+ }
245
+
246
+ return [cleanup, nextReal(domNode)]
247
+ }
248
+
249
+ // Mismatch — fall back to fresh mount
250
+ const actual =
251
+ domNode?.nodeType === Node.ELEMENT_NODE
252
+ ? (domNode as Element).tagName.toLowerCase()
253
+ : (domNode?.nodeType ?? "null")
254
+ warnHydrationMismatch("tag", vnode.type, actual, elPath)
255
+ const cleanup = mountChild(vnode, parent, anchor)
256
+ return [cleanup, domNode]
257
+ }
258
+
259
+ // ─── Children hydration ───────────────────────────────────────────────────────
260
+
261
+ function hydrateChildren(
262
+ children: VNodeChild[],
263
+ domNode: ChildNode | null,
264
+ parent: Node,
265
+ anchor: Node | null,
266
+ path = "root",
267
+ ): [Cleanup, ChildNode | null] {
268
+ const cleanups: Cleanup[] = []
269
+ let cursor = domNode
270
+ for (const child of children) {
271
+ const [cleanup, next] = hydrateChild(child, cursor, parent, anchor, path)
272
+ cleanups.push(cleanup)
273
+ cursor = next
274
+ }
275
+ return [
276
+ () => {
277
+ for (const c of cleanups) c()
278
+ },
279
+ cursor,
280
+ ]
281
+ }
282
+
283
+ // ─── Component hydration ──────────────────────────────────────────────────────
284
+
285
+ function hydrateComponent(
286
+ vnode: VNode,
287
+ domNode: ChildNode | null,
288
+ parent: Node,
289
+ anchor: Node | null,
290
+ path = "root",
291
+ ): [Cleanup, ChildNode | null] {
292
+ const scope = effectScope()
293
+ setCurrentScope(scope)
294
+
295
+ let subtreeCleanup: Cleanup = noop
296
+ const mountCleanups: Cleanup[] = []
297
+ let nextDom: ChildNode | null = domNode
298
+
299
+ // Function.name is always a string per spec; || handles empty string, avoids uncoverable ?? branch
300
+ const componentName = ((vnode.type as ComponentFn).name || "Anonymous") as string
301
+ const mergedProps =
302
+ vnode.children.length > 0 && (vnode.props as Record<string, unknown>).children === undefined
303
+ ? {
304
+ ...vnode.props,
305
+ children: vnode.children.length === 1 ? vnode.children[0] : vnode.children,
306
+ }
307
+ : vnode.props
308
+
309
+ let result: ReturnType<typeof runWithHooks>
310
+ try {
311
+ result = runWithHooks(vnode.type as ComponentFn, mergedProps)
312
+ } catch (err) {
313
+ setCurrentScope(null)
314
+ scope.stop()
315
+
316
+ console.error(`[Pyreon] Error hydrating component <${componentName}>:`, err)
317
+ reportError({
318
+ component: componentName,
319
+ phase: "setup",
320
+ error: err,
321
+ timestamp: Date.now(),
322
+ props: vnode.props as Record<string, unknown>,
323
+ })
324
+ dispatchToErrorBoundary(err)
325
+ return [noop, domNode]
326
+ }
327
+ setCurrentScope(null)
328
+
329
+ const { vnode: output, hooks } = result
330
+
331
+ // Register onUpdate hooks with the scope
332
+ for (const fn of hooks.update) {
333
+ scope.addUpdateHook(fn)
334
+ }
335
+
336
+ if (output != null) {
337
+ const [childCleanup, next] = hydrateChild(output, domNode, parent, anchor, path)
338
+ subtreeCleanup = childCleanup
339
+ nextDom = next
340
+ }
341
+
342
+ // Fire onMount hooks; effects created inside are tracked by the scope via runInScope
343
+ for (const fn of hooks.mount) {
344
+ try {
345
+ let c: (() => void) | undefined
346
+ scope.runInScope(() => {
347
+ c = fn()
348
+ })
349
+ if (c) mountCleanups.push(c)
350
+ } catch (err) {
351
+ reportError({ component: componentName, phase: "mount", error: err, timestamp: Date.now() })
352
+ }
353
+ }
354
+
355
+ const cleanup: Cleanup = () => {
356
+ scope.stop()
357
+ subtreeCleanup()
358
+ for (const fn of hooks.unmount) fn()
359
+ for (const fn of mountCleanups) fn()
360
+ }
361
+
362
+ return [cleanup, nextDom]
363
+ }
364
+
365
+ // ─── Public API ───────────────────────────────────────────────────────────────
366
+
367
+ /**
368
+ * Hydrate a server-rendered container with a Pyreon VNode tree.
369
+ *
370
+ * Reuses existing DOM elements for static structure, attaches event listeners
371
+ * and reactive effects without re-rendering. Falls back to fresh mount for
372
+ * dynamic content (reactive conditionals, For lists).
373
+ *
374
+ * @example
375
+ * // Server:
376
+ * const html = await renderToString(h(App, null))
377
+ *
378
+ * // Client:
379
+ * const unmount = hydrateRoot(document.getElementById("app")!, h(App, null))
380
+ */
381
+ export function hydrateRoot(container: Element, vnode: VNodeChild): () => void {
382
+ const firstChild = firstReal(container.firstChild as ChildNode | null)
383
+ const [cleanup] = hydrateChild(vnode, firstChild, container, null)
384
+ return cleanup
385
+ }