@pyreon/core 0.11.3 → 0.11.5

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,181 @@
1
+ import { dispatchToErrorBoundary, popErrorBoundary, runWithHooks } from "../component"
2
+ import { ErrorBoundary } from "../error-boundary"
3
+ import { h } from "../h"
4
+ import type { VNodeChild } from "../types"
5
+
6
+ describe("ErrorBoundary", () => {
7
+ // Clean up error boundary stack after each test
8
+ afterEach(() => {
9
+ // Pop all boundaries that tests may have left
10
+ while (dispatchToErrorBoundary("cleanup")) {
11
+ popErrorBoundary()
12
+ }
13
+ })
14
+
15
+ test("is a function", () => {
16
+ expect(typeof ErrorBoundary).toBe("function")
17
+ })
18
+
19
+ test("returns a reactive getter", () => {
20
+ let result: VNodeChild = null
21
+ runWithHooks(() => {
22
+ result = ErrorBoundary({
23
+ fallback: (err) => `Error: ${err}`,
24
+ children: "child",
25
+ })
26
+ return null
27
+ }, {})
28
+ expect(typeof result).toBe("function")
29
+ })
30
+
31
+ test("renders children when no error", () => {
32
+ let result: VNodeChild = null
33
+ runWithHooks(() => {
34
+ result = ErrorBoundary({
35
+ fallback: (err) => `Error: ${err}`,
36
+ children: "child content",
37
+ })
38
+ return null
39
+ }, {})
40
+ const getter = result as unknown as () => VNodeChild
41
+ expect(getter()).toBe("child content")
42
+ })
43
+
44
+ test("renders function children by calling them", () => {
45
+ let result: VNodeChild = null
46
+ runWithHooks(() => {
47
+ result = ErrorBoundary({
48
+ fallback: (err) => `Error: ${err}`,
49
+ children: () => "dynamic child",
50
+ })
51
+ return null
52
+ }, {})
53
+ const getter = result as unknown as () => VNodeChild
54
+ expect(getter()).toBe("dynamic child")
55
+ })
56
+
57
+ test("renders VNode children", () => {
58
+ let result: VNodeChild = null
59
+ const child = h("div", null, "content")
60
+ runWithHooks(() => {
61
+ result = ErrorBoundary({
62
+ fallback: (err) => `Error: ${err}`,
63
+ children: child,
64
+ })
65
+ return null
66
+ }, {})
67
+ const getter = result as unknown as () => VNodeChild
68
+ expect(getter()).toBe(child)
69
+ })
70
+
71
+ test("registers unmount cleanup hook", () => {
72
+ const { hooks } = runWithHooks(() => {
73
+ ErrorBoundary({
74
+ fallback: (err) => `Error: ${err}`,
75
+ children: "child",
76
+ })
77
+ return null
78
+ }, {})
79
+ expect(hooks.unmount.length).toBeGreaterThanOrEqual(1)
80
+ })
81
+
82
+ test("warns when fallback is not a function", () => {
83
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
84
+ runWithHooks(() => {
85
+ ErrorBoundary({
86
+ fallback: "not-a-function" as unknown as (err: unknown, reset: () => void) => VNodeChild,
87
+ children: "child",
88
+ })
89
+ return null
90
+ }, {})
91
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("<ErrorBoundary>"))
92
+ warnSpy.mockRestore()
93
+ })
94
+
95
+ test("dispatched error triggers fallback rendering", () => {
96
+ let result: VNodeChild = null
97
+ runWithHooks(() => {
98
+ result = ErrorBoundary({
99
+ fallback: (err) => `Caught: ${err}`,
100
+ children: "normal",
101
+ })
102
+ return null
103
+ }, {})
104
+ const getter = result as unknown as () => VNodeChild
105
+ expect(getter()).toBe("normal")
106
+
107
+ dispatchToErrorBoundary(new Error("boom"))
108
+ expect(getter()).toBe("Caught: Error: boom")
109
+ })
110
+
111
+ test("fallback receives reset function that clears error", () => {
112
+ let result: VNodeChild = null
113
+ let capturedReset: (() => void) | undefined
114
+ runWithHooks(() => {
115
+ result = ErrorBoundary({
116
+ fallback: (_err, reset) => {
117
+ capturedReset = reset
118
+ return "error-ui"
119
+ },
120
+ children: "child",
121
+ })
122
+ return null
123
+ }, {})
124
+ const getter = result as unknown as () => VNodeChild
125
+ expect(getter()).toBe("child")
126
+
127
+ dispatchToErrorBoundary("test-error")
128
+ expect(getter()).toBe("error-ui")
129
+ expect(capturedReset).toBeDefined()
130
+
131
+ capturedReset?.()
132
+ expect(getter()).toBe("child")
133
+ })
134
+
135
+ test("second error while already in error state is not handled", () => {
136
+ let result: VNodeChild = null
137
+ runWithHooks(() => {
138
+ result = ErrorBoundary({
139
+ fallback: (err) => `Error: ${err}`,
140
+ children: "child",
141
+ })
142
+ return null
143
+ }, {})
144
+ const getter = result as unknown as () => VNodeChild
145
+
146
+ // First error handled
147
+ expect(dispatchToErrorBoundary("first")).toBe(true)
148
+ expect(getter()).toBe("Error: first")
149
+
150
+ // Second error not handled (already in error state)
151
+ expect(dispatchToErrorBoundary("second")).toBe(false)
152
+ // Still showing first error
153
+ expect(getter()).toBe("Error: first")
154
+ })
155
+
156
+ test("after reset, new error can be caught again", () => {
157
+ let result: VNodeChild = null
158
+ let capturedReset: (() => void) | undefined
159
+ runWithHooks(() => {
160
+ result = ErrorBoundary({
161
+ fallback: (err, reset) => {
162
+ capturedReset = reset
163
+ return `Error: ${err}`
164
+ },
165
+ children: "child",
166
+ })
167
+ return null
168
+ }, {})
169
+ const getter = result as unknown as () => VNodeChild
170
+
171
+ dispatchToErrorBoundary("first-error")
172
+ expect(getter()).toBe("Error: first-error")
173
+
174
+ capturedReset?.()
175
+ expect(getter()).toBe("child")
176
+
177
+ // Can catch new error after reset
178
+ expect(dispatchToErrorBoundary("second-error")).toBe(true)
179
+ expect(getter()).toBe("Error: second-error")
180
+ })
181
+ })
@@ -0,0 +1,94 @@
1
+ import { For, ForSymbol } from "../for"
2
+ import { h } from "../h"
3
+ import type { VNode } from "../types"
4
+
5
+ describe("For", () => {
6
+ test("returns VNode with ForSymbol type", () => {
7
+ const node = For({
8
+ each: () => [1, 2, 3],
9
+ by: (item) => item,
10
+ children: (item) => h("li", null, String(item)),
11
+ })
12
+ expect(node.type).toBe(ForSymbol)
13
+ })
14
+
15
+ test("VNode has empty children array", () => {
16
+ const node = For({
17
+ each: () => [],
18
+ by: (item: number) => item,
19
+ children: (item) => h("span", null, String(item)),
20
+ })
21
+ expect(node.children).toEqual([])
22
+ })
23
+
24
+ test("VNode has null key", () => {
25
+ const node = For({
26
+ each: () => [1],
27
+ by: (item) => item,
28
+ children: (item) => h("li", null, String(item)),
29
+ })
30
+ expect(node.key).toBeNull()
31
+ })
32
+
33
+ test("props contain each, by, children functions", () => {
34
+ const eachFn = () => ["a", "b"]
35
+ const byFn = (item: string) => item
36
+ const childFn = (item: string) => h("span", null, item)
37
+ const node = For({ each: eachFn, by: byFn, children: childFn })
38
+
39
+ const props = node.props as unknown as {
40
+ each: typeof eachFn
41
+ by: typeof byFn
42
+ children: typeof childFn
43
+ }
44
+ expect(props.each).toBe(eachFn)
45
+ expect(props.by).toBe(byFn)
46
+ expect(props.children).toBe(childFn)
47
+ })
48
+
49
+ test("ForSymbol is a unique symbol", () => {
50
+ expect(typeof ForSymbol).toBe("symbol")
51
+ expect(ForSymbol.toString()).toContain("pyreon.For")
52
+ })
53
+
54
+ test("works with object items", () => {
55
+ interface Item {
56
+ id: number
57
+ name: string
58
+ }
59
+ const items: Item[] = [
60
+ { id: 1, name: "one" },
61
+ { id: 2, name: "two" },
62
+ ]
63
+ const node = For<Item>({
64
+ each: () => items,
65
+ by: (item) => item.id,
66
+ children: (item) => h("li", null, item.name),
67
+ })
68
+ expect(node.type).toBe(ForSymbol)
69
+ const props = node.props as unknown as { each: () => Item[] }
70
+ expect(props.each()).toBe(items)
71
+ })
72
+
73
+ test("works with string keys", () => {
74
+ const node = For({
75
+ each: () => [{ slug: "hello" }, { slug: "world" }],
76
+ by: (item) => item.slug,
77
+ children: (item) => h("div", null, item.slug),
78
+ })
79
+ expect(node.type).toBe(ForSymbol)
80
+ })
81
+
82
+ test("children function produces VNodes", () => {
83
+ const childFn = (n: number) => h("li", { key: n }, String(n))
84
+ const node = For({
85
+ each: () => [1, 2, 3],
86
+ by: (n) => n,
87
+ children: childFn,
88
+ })
89
+ const props = node.props as unknown as { children: typeof childFn }
90
+ const result = props.children(1)
91
+ expect((result as VNode).type).toBe("li")
92
+ expect((result as VNode).key).toBe(1)
93
+ })
94
+ })
@@ -0,0 +1,200 @@
1
+ import { EMPTY_PROPS, Fragment, h } from "../h"
2
+ import type { ComponentFn, VNode, VNodeChild } from "../types"
3
+
4
+ describe("h() — VNode creation", () => {
5
+ describe("basic element creation", () => {
6
+ test("creates VNode with string tag", () => {
7
+ const node = h("div", null)
8
+ expect(node.type).toBe("div")
9
+ expect(node.props).toBe(EMPTY_PROPS)
10
+ expect(node.children).toEqual([])
11
+ expect(node.key).toBeNull()
12
+ })
13
+
14
+ test("creates VNode with props", () => {
15
+ const node = h("div", { id: "main", class: "container" })
16
+ expect(node.props.id).toBe("main")
17
+ expect(node.props.class).toBe("container")
18
+ })
19
+
20
+ test("null props becomes EMPTY_PROPS sentinel", () => {
21
+ const node1 = h("div", null)
22
+ const node2 = h("span", null)
23
+ // Both should use the same EMPTY_PROPS object (identity check)
24
+ expect(node1.props).toBe(node2.props)
25
+ expect(node1.props).toBe(EMPTY_PROPS)
26
+ })
27
+ })
28
+
29
+ describe("key extraction", () => {
30
+ test("extracts string key from props", () => {
31
+ const node = h("li", { key: "item-1" })
32
+ expect(node.key).toBe("item-1")
33
+ })
34
+
35
+ test("extracts numeric key from props", () => {
36
+ const node = h("li", { key: 42 })
37
+ expect(node.key).toBe(42)
38
+ })
39
+
40
+ test("key is null when not provided", () => {
41
+ const node = h("div", { class: "x" })
42
+ expect(node.key).toBeNull()
43
+ })
44
+
45
+ test("key is null for null props", () => {
46
+ const node = h("div", null)
47
+ expect(node.key).toBeNull()
48
+ })
49
+
50
+ test("key 0 is preserved (falsy but valid)", () => {
51
+ const node = h("li", { key: 0 })
52
+ expect(node.key).toBe(0)
53
+ })
54
+ })
55
+
56
+ describe("children handling", () => {
57
+ test("string children", () => {
58
+ const node = h("p", null, "hello")
59
+ expect(node.children).toEqual(["hello"])
60
+ })
61
+
62
+ test("multiple string children", () => {
63
+ const node = h("p", null, "hello", " ", "world")
64
+ expect(node.children).toEqual(["hello", " ", "world"])
65
+ })
66
+
67
+ test("number children", () => {
68
+ const node = h("span", null, 42)
69
+ expect(node.children).toEqual([42])
70
+ })
71
+
72
+ test("VNode children", () => {
73
+ const child = h("span", null, "inner")
74
+ const parent = h("div", null, child)
75
+ expect(parent.children).toHaveLength(1)
76
+ expect((parent.children[0] as VNode).type).toBe("span")
77
+ })
78
+
79
+ test("mixed children types", () => {
80
+ const child = h("em", null)
81
+ const getter = () => "reactive"
82
+ const node = h("div", null, "text", 42, child, null, undefined, true, false, getter)
83
+ expect(node.children).toHaveLength(8)
84
+ expect(node.children[0]).toBe("text")
85
+ expect(node.children[1]).toBe(42)
86
+ expect((node.children[2] as VNode).type).toBe("em")
87
+ expect(node.children[3]).toBeNull()
88
+ expect(node.children[4]).toBeUndefined()
89
+ expect(node.children[5]).toBe(true)
90
+ expect(node.children[6]).toBe(false)
91
+ expect(typeof node.children[7]).toBe("function")
92
+ })
93
+
94
+ test("function children (reactive getters) are preserved", () => {
95
+ const getter = () => "dynamic"
96
+ const node = h("div", null, getter)
97
+ expect(node.children).toHaveLength(1)
98
+ expect(typeof node.children[0]).toBe("function")
99
+ expect((node.children[0] as () => string)()).toBe("dynamic")
100
+ })
101
+
102
+ test("no children produces empty array", () => {
103
+ const node = h("br", null)
104
+ expect(node.children).toEqual([])
105
+ })
106
+ })
107
+
108
+ describe("children flattening", () => {
109
+ test("flattens single-level array children", () => {
110
+ const node = h("ul", null, [h("li", null, "a"), h("li", null, "b")])
111
+ expect(node.children).toHaveLength(2)
112
+ expect((node.children[0] as VNode).type).toBe("li")
113
+ expect((node.children[1] as VNode).type).toBe("li")
114
+ })
115
+
116
+ test("flattens deeply nested arrays", () => {
117
+ const node = h("div", null, [[["deep"]]] as unknown as VNodeChild)
118
+ expect(node.children).toEqual(["deep"])
119
+ })
120
+
121
+ test("flattens mixed nested/flat children", () => {
122
+ const node = h("div", null, "flat", ["nested-a", "nested-b"] as unknown as VNodeChild)
123
+ expect(node.children).toEqual(["flat", "nested-a", "nested-b"])
124
+ })
125
+
126
+ test("fast path: no allocation when children have no nested arrays", () => {
127
+ // normalizeChildren returns as-is when no element is an array
128
+ const node = h("div", null, "a", "b", "c")
129
+ expect(node.children).toEqual(["a", "b", "c"])
130
+ expect(node.children).toHaveLength(3)
131
+ })
132
+
133
+ test("flattens multiple levels of nesting", () => {
134
+ const node = h("div", null, [["a", ["b", ["c"]]]] as unknown as VNodeChild)
135
+ expect(node.children).toEqual(["a", "b", "c"])
136
+ })
137
+ })
138
+
139
+ describe("component function type", () => {
140
+ test("accepts component function as type", () => {
141
+ const Comp: ComponentFn<{ name: string }> = (props) => h("span", null, props.name)
142
+ const node = h(Comp, { name: "test" })
143
+ expect(node.type).toBe(Comp)
144
+ expect(node.props.name).toBe("test")
145
+ })
146
+
147
+ test("component with no props", () => {
148
+ const Comp: ComponentFn = () => h("div", null)
149
+ const node = h(Comp, null)
150
+ expect(node.type).toBe(Comp)
151
+ expect(node.props).toBe(EMPTY_PROPS)
152
+ })
153
+
154
+ test("component with children rest args", () => {
155
+ const Comp: ComponentFn = () => null
156
+ const node = h(Comp, { id: "x" }, "child1", "child2")
157
+ expect(node.children).toEqual(["child1", "child2"])
158
+ })
159
+ })
160
+
161
+ describe("symbol type (Fragment)", () => {
162
+ test("Fragment as type", () => {
163
+ const node = h(Fragment, null, "a", "b")
164
+ expect(node.type).toBe(Fragment)
165
+ expect(node.children).toEqual(["a", "b"])
166
+ })
167
+
168
+ test("Fragment with VNode children", () => {
169
+ const node = h(Fragment, null, h("span", null, "x"), h("em", null, "y"))
170
+ expect(node.children).toHaveLength(2)
171
+ })
172
+
173
+ test("nested Fragments", () => {
174
+ const inner = h(Fragment, null, "a", "b")
175
+ const outer = h(Fragment, null, inner, "c")
176
+ expect(outer.children).toHaveLength(2)
177
+ expect((outer.children[0] as VNode).type).toBe(Fragment)
178
+ })
179
+ })
180
+ })
181
+
182
+ describe("EMPTY_PROPS", () => {
183
+ test("is a plain object", () => {
184
+ expect(typeof EMPTY_PROPS).toBe("object")
185
+ expect(EMPTY_PROPS).not.toBeNull()
186
+ })
187
+
188
+ test("is the same reference for all null-prop VNodes", () => {
189
+ const a = h("div", null)
190
+ const b = h("span", null)
191
+ expect(a.props).toBe(b.props)
192
+ })
193
+ })
194
+
195
+ describe("Fragment", () => {
196
+ test("is a unique symbol", () => {
197
+ expect(typeof Fragment).toBe("symbol")
198
+ expect(Fragment.toString()).toContain("Pyreon.Fragment")
199
+ })
200
+ })
@@ -0,0 +1,100 @@
1
+ import { h } from "../h"
2
+ import { lazy } from "../lazy"
3
+ import type { ComponentFn, Props, VNode } from "../types"
4
+
5
+ describe("lazy", () => {
6
+ test("returns a LazyComponent with __loading flag", () => {
7
+ const Comp = lazy<Props>(() => new Promise(() => {})) // never resolves
8
+ expect(typeof Comp).toBe("function")
9
+ expect(typeof Comp.__loading).toBe("function")
10
+ expect(Comp.__loading()).toBe(true)
11
+ })
12
+
13
+ test("__loading returns true while loading", () => {
14
+ const Comp = lazy<Props>(() => new Promise(() => {}))
15
+ expect(Comp.__loading()).toBe(true)
16
+ })
17
+
18
+ test("returns null while loading (component not yet available)", () => {
19
+ const Comp = lazy<Props>(() => new Promise(() => {}))
20
+ const result = Comp({})
21
+ expect(result).toBeNull()
22
+ })
23
+
24
+ test("resolves to the loaded component", async () => {
25
+ const Inner: ComponentFn<{ name: string }> = (props) => h("span", null, props.name)
26
+ const Comp = lazy(() => Promise.resolve({ default: Inner }))
27
+
28
+ await new Promise((r) => setTimeout(r, 0))
29
+
30
+ expect(Comp.__loading()).toBe(false)
31
+ const result = Comp({ name: "test" })
32
+ expect(result).not.toBeNull()
33
+ expect((result as VNode).type).toBe(Inner)
34
+ expect((result as VNode).props).toEqual({ name: "test" })
35
+ })
36
+
37
+ test("throws on import error", async () => {
38
+ const Comp = lazy<Props>(() => Promise.reject(new Error("load failed")))
39
+
40
+ await new Promise((r) => setTimeout(r, 0))
41
+
42
+ expect(Comp.__loading()).toBe(false)
43
+ expect(() => Comp({})).toThrow("load failed")
44
+ })
45
+
46
+ test("wraps non-Error rejection in Error", async () => {
47
+ const Comp = lazy<Props>(() => Promise.reject("string-error"))
48
+
49
+ await new Promise((r) => setTimeout(r, 0))
50
+
51
+ expect(() => Comp({})).toThrow("string-error")
52
+ })
53
+
54
+ test("wraps numeric rejection in Error", async () => {
55
+ const Comp = lazy<Props>(() => Promise.reject(404))
56
+
57
+ await new Promise((r) => setTimeout(r, 0))
58
+
59
+ expect(() => Comp({})).toThrow("404")
60
+ })
61
+
62
+ test("__loading is false after successful load", async () => {
63
+ const Inner: ComponentFn = () => null
64
+ const Comp = lazy(() => Promise.resolve({ default: Inner }))
65
+
66
+ expect(Comp.__loading()).toBe(true)
67
+ await new Promise((r) => setTimeout(r, 0))
68
+ expect(Comp.__loading()).toBe(false)
69
+ })
70
+
71
+ test("__loading is false after failed load", async () => {
72
+ const Comp = lazy<Props>(() => Promise.reject(new Error("fail")))
73
+
74
+ expect(Comp.__loading()).toBe(true)
75
+ await new Promise((r) => setTimeout(r, 0))
76
+ expect(Comp.__loading()).toBe(false)
77
+ })
78
+
79
+ test("multiple calls after load return consistent results", async () => {
80
+ const Inner: ComponentFn = () => h("div", null, "content")
81
+ const Comp = lazy(() => Promise.resolve({ default: Inner }))
82
+
83
+ await new Promise((r) => setTimeout(r, 0))
84
+
85
+ const result1 = Comp({})
86
+ const result2 = Comp({})
87
+ expect((result1 as VNode).type).toBe(Inner)
88
+ expect((result2 as VNode).type).toBe(Inner)
89
+ })
90
+
91
+ test("passes props through to loaded component via h()", async () => {
92
+ const Inner: ComponentFn<{ count: number }> = (props) => h("span", null, String(props.count))
93
+ const Comp = lazy(() => Promise.resolve({ default: Inner }))
94
+
95
+ await new Promise((r) => setTimeout(r, 0))
96
+
97
+ const result = Comp({ count: 42 })
98
+ expect((result as VNode).props).toEqual({ count: 42 })
99
+ })
100
+ })