@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
|
@@ -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
|
+
}
|