@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.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +1909 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +1845 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +355 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +48 -0
- package/src/devtools.ts +304 -0
- package/src/hydrate.ts +385 -0
- package/src/hydration-debug.ts +39 -0
- package/src/index.ts +43 -0
- package/src/keep-alive.ts +71 -0
- package/src/mount.ts +367 -0
- package/src/nodes.ts +741 -0
- package/src/props.ts +328 -0
- package/src/template.ts +81 -0
- package/src/tests/coverage-gaps.test.ts +2488 -0
- package/src/tests/coverage.test.ts +1123 -0
- package/src/tests/mount.test.ts +3098 -0
- package/src/tests/setup.ts +3 -0
- package/src/transition-group.ts +264 -0
- package/src/transition.ts +184 -0
package/src/devtools.ts
ADDED
|
@@ -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
|
+
}
|