@pyreon/core 0.11.2 → 0.11.4
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/package.json +2 -2
- package/src/tests/component.test.ts +281 -0
- package/src/tests/context.test.ts +263 -0
- package/src/tests/dynamic.test.ts +55 -0
- package/src/tests/error-boundary.test.ts +181 -0
- package/src/tests/for.test.ts +94 -0
- package/src/tests/h.test.ts +200 -0
- package/src/tests/lazy.test.ts +100 -0
- package/src/tests/lifecycle.test.ts +208 -0
- package/src/tests/map-array.test.ts +313 -0
- package/src/tests/portal.test.ts +48 -0
- package/src/tests/props-extended.test.ts +157 -0
- package/src/tests/ref.test.ts +70 -0
- package/src/tests/show.test.ts +238 -0
- package/src/tests/style.test.ts +157 -0
- package/src/tests/suspense.test.ts +139 -0
- package/src/tests/telemetry.test.ts +142 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/core",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.4",
|
|
4
4
|
"description": "Core component model and lifecycle for Pyreon",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"prepublishOnly": "bun run build"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@pyreon/reactivity": "^0.11.
|
|
52
|
+
"@pyreon/reactivity": "^0.11.4"
|
|
53
53
|
},
|
|
54
54
|
"publishConfig": {
|
|
55
55
|
"access": "public"
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineComponent,
|
|
3
|
+
dispatchToErrorBoundary,
|
|
4
|
+
popErrorBoundary,
|
|
5
|
+
propagateError,
|
|
6
|
+
pushErrorBoundary,
|
|
7
|
+
runWithHooks,
|
|
8
|
+
} from "../component"
|
|
9
|
+
import { h } from "../h"
|
|
10
|
+
import { onErrorCaptured, onMount, onUnmount, onUpdate } from "../lifecycle"
|
|
11
|
+
import type { ComponentFn, LifecycleHooks, VNode } from "../types"
|
|
12
|
+
|
|
13
|
+
describe("defineComponent", () => {
|
|
14
|
+
test("returns the exact same function (identity)", () => {
|
|
15
|
+
const fn: ComponentFn = () => h("div", null)
|
|
16
|
+
expect(defineComponent(fn)).toBe(fn)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("preserves typed props", () => {
|
|
20
|
+
const Comp = defineComponent<{ count: number }>((props) => {
|
|
21
|
+
return h("span", null, String(props.count))
|
|
22
|
+
})
|
|
23
|
+
const node = Comp({ count: 10 })
|
|
24
|
+
expect((node as VNode).type).toBe("span")
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe("runWithHooks", () => {
|
|
29
|
+
test("captures all lifecycle hook types", () => {
|
|
30
|
+
const mountFn = () => undefined
|
|
31
|
+
const unmountFn = () => {}
|
|
32
|
+
const updateFn = () => {}
|
|
33
|
+
const errorFn = () => true
|
|
34
|
+
|
|
35
|
+
const Comp: ComponentFn = () => {
|
|
36
|
+
onMount(mountFn)
|
|
37
|
+
onUnmount(unmountFn)
|
|
38
|
+
onUpdate(updateFn)
|
|
39
|
+
onErrorCaptured(errorFn)
|
|
40
|
+
return h("div", null)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { vnode, hooks } = runWithHooks(Comp, {})
|
|
44
|
+
expect(vnode).not.toBeNull()
|
|
45
|
+
expect(hooks.mount).toContain(mountFn)
|
|
46
|
+
expect(hooks.unmount).toContain(unmountFn)
|
|
47
|
+
expect(hooks.update).toContain(updateFn)
|
|
48
|
+
expect(hooks.error).toContain(errorFn)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("returns null vnode for component returning null", () => {
|
|
52
|
+
const { vnode } = runWithHooks(() => null, {})
|
|
53
|
+
expect(vnode).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("returns string vnode for component returning string", () => {
|
|
57
|
+
const { vnode } = runWithHooks(() => "hello", {})
|
|
58
|
+
expect(vnode).toBe("hello")
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("clears hooks context after execution", () => {
|
|
62
|
+
const Comp: ComponentFn = () => h("div", null)
|
|
63
|
+
runWithHooks(Comp, {})
|
|
64
|
+
// After runWithHooks, lifecycle hooks should be no-ops
|
|
65
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
66
|
+
onMount(() => {})
|
|
67
|
+
expect(warnSpy).toHaveBeenCalled()
|
|
68
|
+
warnSpy.mockRestore()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("clears hooks context even when component throws", () => {
|
|
72
|
+
const Comp: ComponentFn = () => {
|
|
73
|
+
throw new Error("boom")
|
|
74
|
+
}
|
|
75
|
+
expect(() => runWithHooks(Comp, {})).toThrow("boom")
|
|
76
|
+
// Should still be cleared
|
|
77
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
78
|
+
onMount(() => {})
|
|
79
|
+
expect(warnSpy).toHaveBeenCalled()
|
|
80
|
+
warnSpy.mockRestore()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("passes props to component function", () => {
|
|
84
|
+
let received: unknown = null
|
|
85
|
+
runWithHooks(
|
|
86
|
+
((props: { msg: string }) => {
|
|
87
|
+
received = props
|
|
88
|
+
return null
|
|
89
|
+
}) as ComponentFn,
|
|
90
|
+
{ msg: "hello" },
|
|
91
|
+
)
|
|
92
|
+
expect(received).toEqual({ msg: "hello" })
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test("captures multiple hooks of same type", () => {
|
|
96
|
+
const Comp: ComponentFn = () => {
|
|
97
|
+
onMount(() => undefined)
|
|
98
|
+
onMount(() => undefined)
|
|
99
|
+
onUnmount(() => {})
|
|
100
|
+
onUnmount(() => {})
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
const { hooks } = runWithHooks(Comp, {})
|
|
104
|
+
expect(hooks.mount).toHaveLength(2)
|
|
105
|
+
expect(hooks.unmount).toHaveLength(2)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("empty hooks when component registers none", () => {
|
|
109
|
+
const { hooks } = runWithHooks(() => h("div", null), {})
|
|
110
|
+
expect(hooks.mount).toHaveLength(0)
|
|
111
|
+
expect(hooks.unmount).toHaveLength(0)
|
|
112
|
+
expect(hooks.update).toHaveLength(0)
|
|
113
|
+
expect(hooks.error).toHaveLength(0)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe("propagateError", () => {
|
|
118
|
+
test("returns true when handler returns true", () => {
|
|
119
|
+
const hooks: LifecycleHooks = {
|
|
120
|
+
mount: [],
|
|
121
|
+
unmount: [],
|
|
122
|
+
update: [],
|
|
123
|
+
error: [() => true],
|
|
124
|
+
}
|
|
125
|
+
expect(propagateError(new Error("test"), hooks)).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test("returns false when no handlers", () => {
|
|
129
|
+
const hooks: LifecycleHooks = {
|
|
130
|
+
mount: [],
|
|
131
|
+
unmount: [],
|
|
132
|
+
update: [],
|
|
133
|
+
error: [],
|
|
134
|
+
}
|
|
135
|
+
expect(propagateError(new Error("test"), hooks)).toBe(false)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test("returns false when handler returns undefined", () => {
|
|
139
|
+
const hooks: LifecycleHooks = {
|
|
140
|
+
mount: [],
|
|
141
|
+
unmount: [],
|
|
142
|
+
update: [],
|
|
143
|
+
error: [() => undefined],
|
|
144
|
+
}
|
|
145
|
+
expect(propagateError(new Error("test"), hooks)).toBe(false)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test("stops at first handler returning true", () => {
|
|
149
|
+
let secondCalled = false
|
|
150
|
+
const hooks: LifecycleHooks = {
|
|
151
|
+
mount: [],
|
|
152
|
+
unmount: [],
|
|
153
|
+
update: [],
|
|
154
|
+
error: [
|
|
155
|
+
() => true,
|
|
156
|
+
() => {
|
|
157
|
+
secondCalled = true
|
|
158
|
+
return true
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
}
|
|
162
|
+
expect(propagateError("err", hooks)).toBe(true)
|
|
163
|
+
expect(secondCalled).toBe(false)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test("continues to next handler when first returns undefined", () => {
|
|
167
|
+
const calls: number[] = []
|
|
168
|
+
const hooks: LifecycleHooks = {
|
|
169
|
+
mount: [],
|
|
170
|
+
unmount: [],
|
|
171
|
+
update: [],
|
|
172
|
+
error: [
|
|
173
|
+
() => {
|
|
174
|
+
calls.push(1)
|
|
175
|
+
return undefined
|
|
176
|
+
},
|
|
177
|
+
() => {
|
|
178
|
+
calls.push(2)
|
|
179
|
+
return true
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
}
|
|
183
|
+
expect(propagateError("err", hooks)).toBe(true)
|
|
184
|
+
expect(calls).toEqual([1, 2])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test("passes the error to each handler", () => {
|
|
188
|
+
const errors: unknown[] = []
|
|
189
|
+
const hooks: LifecycleHooks = {
|
|
190
|
+
mount: [],
|
|
191
|
+
unmount: [],
|
|
192
|
+
update: [],
|
|
193
|
+
error: [
|
|
194
|
+
(err) => {
|
|
195
|
+
errors.push(err)
|
|
196
|
+
return undefined
|
|
197
|
+
},
|
|
198
|
+
(err) => {
|
|
199
|
+
errors.push(err)
|
|
200
|
+
return true
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
}
|
|
204
|
+
const testErr = new Error("propagated")
|
|
205
|
+
propagateError(testErr, hooks)
|
|
206
|
+
expect(errors).toEqual([testErr, testErr])
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe("pushErrorBoundary / popErrorBoundary / dispatchToErrorBoundary", () => {
|
|
211
|
+
afterEach(() => {
|
|
212
|
+
// Clean up any leftover boundaries — pop until empty
|
|
213
|
+
// dispatchToErrorBoundary returns false when stack is empty
|
|
214
|
+
while (dispatchToErrorBoundary("cleanup-probe")) {
|
|
215
|
+
popErrorBoundary()
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test("dispatches to the most recently pushed boundary", () => {
|
|
220
|
+
let caught: unknown = null
|
|
221
|
+
pushErrorBoundary((err) => {
|
|
222
|
+
caught = err
|
|
223
|
+
return true
|
|
224
|
+
})
|
|
225
|
+
expect(dispatchToErrorBoundary("test-error")).toBe(true)
|
|
226
|
+
expect(caught).toBe("test-error")
|
|
227
|
+
popErrorBoundary()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test("returns false when no boundary is registered", () => {
|
|
231
|
+
expect(dispatchToErrorBoundary("no-boundary")).toBe(false)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test("nested boundaries — innermost catches first", () => {
|
|
235
|
+
const caught: string[] = []
|
|
236
|
+
pushErrorBoundary((err) => {
|
|
237
|
+
caught.push(`outer: ${err}`)
|
|
238
|
+
return true
|
|
239
|
+
})
|
|
240
|
+
pushErrorBoundary((err) => {
|
|
241
|
+
caught.push(`inner: ${err}`)
|
|
242
|
+
return true
|
|
243
|
+
})
|
|
244
|
+
dispatchToErrorBoundary("test")
|
|
245
|
+
expect(caught).toEqual(["inner: test"])
|
|
246
|
+
popErrorBoundary()
|
|
247
|
+
|
|
248
|
+
// After popping inner, outer should catch
|
|
249
|
+
dispatchToErrorBoundary("test2")
|
|
250
|
+
expect(caught).toEqual(["inner: test", "outer: test2"])
|
|
251
|
+
popErrorBoundary()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test("boundary handler returning false does not propagate to outer", () => {
|
|
255
|
+
// dispatchToErrorBoundary only calls the innermost handler
|
|
256
|
+
let outerCalled = false
|
|
257
|
+
pushErrorBoundary(() => {
|
|
258
|
+
outerCalled = true
|
|
259
|
+
return true
|
|
260
|
+
})
|
|
261
|
+
pushErrorBoundary(() => false)
|
|
262
|
+
const result = dispatchToErrorBoundary("test")
|
|
263
|
+
expect(result).toBe(false)
|
|
264
|
+
expect(outerCalled).toBe(false) // outer not called — only innermost is checked
|
|
265
|
+
popErrorBoundary()
|
|
266
|
+
popErrorBoundary()
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test("push and pop maintain stack correctly", () => {
|
|
270
|
+
const results: boolean[] = []
|
|
271
|
+
pushErrorBoundary(() => true)
|
|
272
|
+
pushErrorBoundary(() => true)
|
|
273
|
+
pushErrorBoundary(() => true)
|
|
274
|
+
popErrorBoundary()
|
|
275
|
+
popErrorBoundary()
|
|
276
|
+
results.push(dispatchToErrorBoundary("x"))
|
|
277
|
+
popErrorBoundary()
|
|
278
|
+
results.push(dispatchToErrorBoundary("y"))
|
|
279
|
+
expect(results).toEqual([true, false])
|
|
280
|
+
})
|
|
281
|
+
})
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { runWithHooks } from "../component"
|
|
2
|
+
import {
|
|
3
|
+
createContext,
|
|
4
|
+
popContext,
|
|
5
|
+
provide,
|
|
6
|
+
pushContext,
|
|
7
|
+
setContextStackProvider,
|
|
8
|
+
useContext,
|
|
9
|
+
withContext,
|
|
10
|
+
} from "../context"
|
|
11
|
+
import type { ComponentFn, Props } from "../types"
|
|
12
|
+
|
|
13
|
+
describe("createContext", () => {
|
|
14
|
+
test("returns context with unique symbol id", () => {
|
|
15
|
+
const ctx = createContext("default")
|
|
16
|
+
expect(typeof ctx.id).toBe("symbol")
|
|
17
|
+
expect(ctx.defaultValue).toBe("default")
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test("each context has a unique id", () => {
|
|
21
|
+
const a = createContext(1)
|
|
22
|
+
const b = createContext(2)
|
|
23
|
+
expect(a.id).not.toBe(b.id)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("undefined default value", () => {
|
|
27
|
+
const ctx = createContext<string | undefined>(undefined)
|
|
28
|
+
expect(ctx.defaultValue).toBeUndefined()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("null default value", () => {
|
|
32
|
+
const ctx = createContext<null>(null)
|
|
33
|
+
expect(ctx.defaultValue).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("object default value", () => {
|
|
37
|
+
const obj = { theme: "dark", lang: "en" }
|
|
38
|
+
const ctx = createContext(obj)
|
|
39
|
+
expect(ctx.defaultValue).toBe(obj)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("function default value", () => {
|
|
43
|
+
const fn = () => 42
|
|
44
|
+
const ctx = createContext(fn)
|
|
45
|
+
expect(ctx.defaultValue).toBe(fn)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe("useContext", () => {
|
|
50
|
+
test("returns default when no provider exists", () => {
|
|
51
|
+
const ctx = createContext("fallback")
|
|
52
|
+
expect(useContext(ctx)).toBe("fallback")
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("returns provided value from pushContext", () => {
|
|
56
|
+
const ctx = createContext("default")
|
|
57
|
+
pushContext(new Map([[ctx.id, "provided"]]))
|
|
58
|
+
expect(useContext(ctx)).toBe("provided")
|
|
59
|
+
popContext()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("returns innermost value with nested pushContext", () => {
|
|
63
|
+
const ctx = createContext("default")
|
|
64
|
+
pushContext(new Map([[ctx.id, "outer"]]))
|
|
65
|
+
pushContext(new Map([[ctx.id, "inner"]]))
|
|
66
|
+
expect(useContext(ctx)).toBe("inner")
|
|
67
|
+
popContext()
|
|
68
|
+
expect(useContext(ctx)).toBe("outer")
|
|
69
|
+
popContext()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test("different contexts in same frame are independent", () => {
|
|
73
|
+
const ctxA = createContext("a-default")
|
|
74
|
+
const ctxB = createContext("b-default")
|
|
75
|
+
const frame = new Map<symbol, unknown>([
|
|
76
|
+
[ctxA.id, "a-value"],
|
|
77
|
+
[ctxB.id, "b-value"],
|
|
78
|
+
])
|
|
79
|
+
pushContext(frame)
|
|
80
|
+
expect(useContext(ctxA)).toBe("a-value")
|
|
81
|
+
expect(useContext(ctxB)).toBe("b-value")
|
|
82
|
+
popContext()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test("context not in frame falls through to previous frame", () => {
|
|
86
|
+
const ctxA = createContext("a-default")
|
|
87
|
+
const ctxB = createContext("b-default")
|
|
88
|
+
pushContext(new Map([[ctxA.id, "a-outer"]]))
|
|
89
|
+
pushContext(new Map([[ctxB.id, "b-inner"]]))
|
|
90
|
+
// ctxA is not in the inner frame, should fall through to outer
|
|
91
|
+
expect(useContext(ctxA)).toBe("a-outer")
|
|
92
|
+
expect(useContext(ctxB)).toBe("b-inner")
|
|
93
|
+
popContext()
|
|
94
|
+
popContext()
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe("pushContext / popContext", () => {
|
|
99
|
+
test("push and pop maintain correct stack order", () => {
|
|
100
|
+
const ctx = createContext(0)
|
|
101
|
+
pushContext(new Map([[ctx.id, 1]]))
|
|
102
|
+
pushContext(new Map([[ctx.id, 2]]))
|
|
103
|
+
pushContext(new Map([[ctx.id, 3]]))
|
|
104
|
+
expect(useContext(ctx)).toBe(3)
|
|
105
|
+
popContext()
|
|
106
|
+
expect(useContext(ctx)).toBe(2)
|
|
107
|
+
popContext()
|
|
108
|
+
expect(useContext(ctx)).toBe(1)
|
|
109
|
+
popContext()
|
|
110
|
+
expect(useContext(ctx)).toBe(0) // default
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test("popContext on empty stack warns in dev mode", () => {
|
|
114
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
115
|
+
popContext()
|
|
116
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
117
|
+
expect.stringContaining("popContext() called on an empty context stack"),
|
|
118
|
+
)
|
|
119
|
+
warnSpy.mockRestore()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe("withContext", () => {
|
|
124
|
+
test("provides value during callback execution", () => {
|
|
125
|
+
const ctx = createContext("default")
|
|
126
|
+
let captured = ""
|
|
127
|
+
withContext(ctx, "inside", () => {
|
|
128
|
+
captured = useContext(ctx)
|
|
129
|
+
})
|
|
130
|
+
expect(captured).toBe("inside")
|
|
131
|
+
// After withContext, should be back to default
|
|
132
|
+
expect(useContext(ctx)).toBe("default")
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test("restores stack on normal completion", () => {
|
|
136
|
+
const ctx = createContext("default")
|
|
137
|
+
withContext(ctx, "temp", () => {
|
|
138
|
+
expect(useContext(ctx)).toBe("temp")
|
|
139
|
+
})
|
|
140
|
+
expect(useContext(ctx)).toBe("default")
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test("restores stack even when callback throws", () => {
|
|
144
|
+
const ctx = createContext("safe")
|
|
145
|
+
try {
|
|
146
|
+
withContext(ctx, "dangerous", () => {
|
|
147
|
+
expect(useContext(ctx)).toBe("dangerous")
|
|
148
|
+
throw new Error("boom")
|
|
149
|
+
})
|
|
150
|
+
} catch {
|
|
151
|
+
// expected
|
|
152
|
+
}
|
|
153
|
+
expect(useContext(ctx)).toBe("safe")
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test("nested withContext calls", () => {
|
|
157
|
+
const ctx = createContext(0)
|
|
158
|
+
withContext(ctx, 1, () => {
|
|
159
|
+
expect(useContext(ctx)).toBe(1)
|
|
160
|
+
withContext(ctx, 2, () => {
|
|
161
|
+
expect(useContext(ctx)).toBe(2)
|
|
162
|
+
withContext(ctx, 3, () => {
|
|
163
|
+
expect(useContext(ctx)).toBe(3)
|
|
164
|
+
})
|
|
165
|
+
expect(useContext(ctx)).toBe(2)
|
|
166
|
+
})
|
|
167
|
+
expect(useContext(ctx)).toBe(1)
|
|
168
|
+
})
|
|
169
|
+
expect(useContext(ctx)).toBe(0)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test("multiple contexts in nested withContext", () => {
|
|
173
|
+
const theme = createContext("light")
|
|
174
|
+
const lang = createContext("en")
|
|
175
|
+
|
|
176
|
+
withContext(theme, "dark", () => {
|
|
177
|
+
withContext(lang, "fr", () => {
|
|
178
|
+
expect(useContext(theme)).toBe("dark")
|
|
179
|
+
expect(useContext(lang)).toBe("fr")
|
|
180
|
+
})
|
|
181
|
+
expect(useContext(lang)).toBe("en")
|
|
182
|
+
})
|
|
183
|
+
expect(useContext(theme)).toBe("light")
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe("provide", () => {
|
|
188
|
+
test("pushes context and registers unmount cleanup", () => {
|
|
189
|
+
const ctx = createContext("default")
|
|
190
|
+
const { hooks } = runWithHooks(
|
|
191
|
+
(() => {
|
|
192
|
+
provide(ctx, "provided-value")
|
|
193
|
+
expect(useContext(ctx)).toBe("provided-value")
|
|
194
|
+
return null
|
|
195
|
+
}) as ComponentFn,
|
|
196
|
+
{} as Props,
|
|
197
|
+
)
|
|
198
|
+
// Context should still be available after runWithHooks
|
|
199
|
+
expect(useContext(ctx)).toBe("provided-value")
|
|
200
|
+
// unmount hooks should include the popContext cleanup
|
|
201
|
+
expect(hooks.unmount.length).toBeGreaterThanOrEqual(1)
|
|
202
|
+
// Running unmount cleans up
|
|
203
|
+
for (const fn of hooks.unmount) fn()
|
|
204
|
+
expect(useContext(ctx)).toBe("default")
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test("multiple provides in same component", () => {
|
|
208
|
+
const ctxA = createContext("a")
|
|
209
|
+
const ctxB = createContext("b")
|
|
210
|
+
const { hooks } = runWithHooks(
|
|
211
|
+
(() => {
|
|
212
|
+
provide(ctxA, "A-value")
|
|
213
|
+
provide(ctxB, "B-value")
|
|
214
|
+
return null
|
|
215
|
+
}) as ComponentFn,
|
|
216
|
+
{} as Props,
|
|
217
|
+
)
|
|
218
|
+
expect(useContext(ctxA)).toBe("A-value")
|
|
219
|
+
expect(useContext(ctxB)).toBe("B-value")
|
|
220
|
+
// Clean up
|
|
221
|
+
for (const fn of hooks.unmount) fn()
|
|
222
|
+
expect(useContext(ctxA)).toBe("a")
|
|
223
|
+
expect(useContext(ctxB)).toBe("b")
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe("setContextStackProvider", () => {
|
|
228
|
+
test("overrides the stack provider", () => {
|
|
229
|
+
const customStack: Map<symbol, unknown>[] = []
|
|
230
|
+
const ctx = createContext("default")
|
|
231
|
+
|
|
232
|
+
setContextStackProvider(() => customStack)
|
|
233
|
+
|
|
234
|
+
customStack.push(new Map([[ctx.id, "custom"]]))
|
|
235
|
+
expect(useContext(ctx)).toBe("custom")
|
|
236
|
+
customStack.pop()
|
|
237
|
+
expect(useContext(ctx)).toBe("default")
|
|
238
|
+
|
|
239
|
+
// Restore default provider
|
|
240
|
+
const freshStack: Map<symbol, unknown>[] = []
|
|
241
|
+
setContextStackProvider(() => freshStack)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test("different providers see different stacks", () => {
|
|
245
|
+
const ctx = createContext("default")
|
|
246
|
+
const stack1: Map<symbol, unknown>[] = []
|
|
247
|
+
const stack2: Map<symbol, unknown>[] = []
|
|
248
|
+
|
|
249
|
+
setContextStackProvider(() => stack1)
|
|
250
|
+
pushContext(new Map([[ctx.id, "stack1-value"]]))
|
|
251
|
+
expect(useContext(ctx)).toBe("stack1-value")
|
|
252
|
+
|
|
253
|
+
// Switch to stack2 — should not see stack1's value
|
|
254
|
+
setContextStackProvider(() => stack2)
|
|
255
|
+
expect(useContext(ctx)).toBe("default")
|
|
256
|
+
|
|
257
|
+
// Clean up
|
|
258
|
+
setContextStackProvider(() => stack1)
|
|
259
|
+
popContext()
|
|
260
|
+
const freshStack: Map<symbol, unknown>[] = []
|
|
261
|
+
setContextStackProvider(() => freshStack)
|
|
262
|
+
})
|
|
263
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Dynamic } from "../dynamic"
|
|
2
|
+
import { h } from "../h"
|
|
3
|
+
import type { ComponentFn, VNode } from "../types"
|
|
4
|
+
|
|
5
|
+
describe("Dynamic", () => {
|
|
6
|
+
test("renders component function", () => {
|
|
7
|
+
const Greeting: ComponentFn = (props) => h("span", null, (props as { name: string }).name)
|
|
8
|
+
const result = Dynamic({ component: Greeting, name: "world" })
|
|
9
|
+
expect(result).not.toBeNull()
|
|
10
|
+
expect((result as VNode).type).toBe(Greeting)
|
|
11
|
+
expect((result as VNode).props).toEqual({ name: "world" })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test("renders string element", () => {
|
|
15
|
+
const result = Dynamic({ component: "div", class: "box", id: "main" })
|
|
16
|
+
expect(result).not.toBeNull()
|
|
17
|
+
expect((result as VNode).type).toBe("div")
|
|
18
|
+
expect((result as VNode).props).toEqual({ class: "box", id: "main" })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("strips component prop from rest props", () => {
|
|
22
|
+
const result = Dynamic({ component: "span", id: "x" })
|
|
23
|
+
expect((result as VNode).props.component).toBeUndefined()
|
|
24
|
+
expect((result as VNode).props.id).toBe("x")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("returns null for empty string component", () => {
|
|
28
|
+
const result = Dynamic({ component: "" })
|
|
29
|
+
expect(result).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test("warns when component prop is falsy", () => {
|
|
33
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
34
|
+
Dynamic({ component: "" })
|
|
35
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("<Dynamic>"))
|
|
36
|
+
warnSpy.mockRestore()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test("passes all extra props to the rendered component", () => {
|
|
40
|
+
const Comp: ComponentFn = (props) => h("div", null, JSON.stringify(props))
|
|
41
|
+
const result = Dynamic({
|
|
42
|
+
component: Comp,
|
|
43
|
+
a: 1,
|
|
44
|
+
b: "two",
|
|
45
|
+
c: true,
|
|
46
|
+
})
|
|
47
|
+
expect((result as VNode).props).toEqual({ a: 1, b: "two", c: true })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("renders with no extra props", () => {
|
|
51
|
+
const result = Dynamic({ component: "br" })
|
|
52
|
+
expect(result).not.toBeNull()
|
|
53
|
+
expect((result as VNode).type).toBe("br")
|
|
54
|
+
})
|
|
55
|
+
})
|