@pyreon/core 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 +79 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +475 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +381 -0
- package/lib/types/index.d.ts.map +1 -0
- package/package.json +53 -0
- package/src/component.ts +66 -0
- package/src/context.ts +79 -0
- package/src/dynamic.ts +12 -0
- package/src/error-boundary.ts +58 -0
- package/src/for.ts +33 -0
- package/src/h.ts +49 -0
- package/src/index.ts +42 -0
- package/src/jsx-dev-runtime.ts +2 -0
- package/src/jsx-runtime.ts +576 -0
- package/src/lazy.ts +25 -0
- package/src/lifecycle.ts +52 -0
- package/src/map-array.ts +42 -0
- package/src/portal.ts +39 -0
- package/src/ref.ts +19 -0
- package/src/show.ts +108 -0
- package/src/suspense.ts +41 -0
- package/src/telemetry.ts +55 -0
- package/src/tests/core.test.ts +1226 -0
- package/src/types.ts +61 -0
|
@@ -0,0 +1,1226 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
createRef,
|
|
4
|
+
Dynamic,
|
|
5
|
+
defineComponent,
|
|
6
|
+
dispatchToErrorBoundary,
|
|
7
|
+
ErrorBoundary,
|
|
8
|
+
For,
|
|
9
|
+
ForSymbol,
|
|
10
|
+
Fragment,
|
|
11
|
+
h,
|
|
12
|
+
lazy,
|
|
13
|
+
Match,
|
|
14
|
+
MatchSymbol,
|
|
15
|
+
mapArray,
|
|
16
|
+
onErrorCaptured,
|
|
17
|
+
onMount,
|
|
18
|
+
onUnmount,
|
|
19
|
+
onUpdate,
|
|
20
|
+
Portal,
|
|
21
|
+
PortalSymbol,
|
|
22
|
+
popContext,
|
|
23
|
+
propagateError,
|
|
24
|
+
pushContext,
|
|
25
|
+
registerErrorHandler,
|
|
26
|
+
reportError,
|
|
27
|
+
runWithHooks,
|
|
28
|
+
Show,
|
|
29
|
+
Suspense,
|
|
30
|
+
Switch,
|
|
31
|
+
useContext,
|
|
32
|
+
withContext,
|
|
33
|
+
} from "../index"
|
|
34
|
+
import { jsxDEV } from "../jsx-dev-runtime"
|
|
35
|
+
import { Fragment as JsxFragment, jsx, jsxs } from "../jsx-runtime"
|
|
36
|
+
import type { ComponentFn, Props, VNode, VNodeChild } from "../types"
|
|
37
|
+
|
|
38
|
+
// ─── h() ─────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
describe("h()", () => {
|
|
41
|
+
test("creates a VNode with string type", () => {
|
|
42
|
+
const node = h("div", null)
|
|
43
|
+
expect(node.type).toBe("div")
|
|
44
|
+
expect(node.props).toEqual({})
|
|
45
|
+
expect(node.children).toEqual([])
|
|
46
|
+
expect(node.key).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("passes props through", () => {
|
|
50
|
+
const node = h("div", { class: "foo", id: "bar" })
|
|
51
|
+
expect(node.props).toEqual({ class: "foo", id: "bar" })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("extracts key from props", () => {
|
|
55
|
+
const node = h("li", { key: "item-1" })
|
|
56
|
+
expect(node.key).toBe("item-1")
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("numeric key", () => {
|
|
60
|
+
const node = h("li", { key: 42 })
|
|
61
|
+
expect(node.key).toBe(42)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("null props becomes empty object", () => {
|
|
65
|
+
const node = h("span", null)
|
|
66
|
+
expect(node.props).toEqual({})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("children are stored in vnode.children", () => {
|
|
70
|
+
const node = h("div", null, "hello", "world")
|
|
71
|
+
expect(node.children).toEqual(["hello", "world"])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test("nested array children are flattened", () => {
|
|
75
|
+
const node = h("ul", null, [h("li", null, "a"), h("li", null, "b")])
|
|
76
|
+
expect(node.children).toHaveLength(2)
|
|
77
|
+
expect((node.children[0] as VNode).type).toBe("li")
|
|
78
|
+
expect((node.children[1] as VNode).type).toBe("li")
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("deeply nested arrays are flattened", () => {
|
|
82
|
+
const node = h("div", null, [[["deep"]]] as unknown as VNodeChild)
|
|
83
|
+
expect(node.children).toEqual(["deep"])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test("handles boolean/null/undefined children", () => {
|
|
87
|
+
const node = h("div", null, true, false, null, undefined, "text")
|
|
88
|
+
expect(node.children).toEqual([true, false, null, undefined, "text"])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test("handles component function type", () => {
|
|
92
|
+
const Comp = ((props: { name: string }) => h("span", null, props.name)) as ComponentFn<{
|
|
93
|
+
name: string
|
|
94
|
+
}>
|
|
95
|
+
const node = h(Comp, { name: "test" })
|
|
96
|
+
expect(node.type as unknown).toBe(Comp)
|
|
97
|
+
expect(node.props).toEqual({ name: "test" })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test("handles symbol type (Fragment)", () => {
|
|
101
|
+
const node = h(Fragment, null, "a", "b")
|
|
102
|
+
expect(node.type).toBe(Fragment)
|
|
103
|
+
expect(node.children).toEqual(["a", "b"])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test("function children are preserved (reactive getters)", () => {
|
|
107
|
+
const getter = () => "dynamic"
|
|
108
|
+
const node = h("div", null, getter)
|
|
109
|
+
expect(node.children).toHaveLength(1)
|
|
110
|
+
expect(typeof node.children[0]).toBe("function")
|
|
111
|
+
expect((node.children[0] as () => string)()).toBe("dynamic")
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test("VNode children are preserved", () => {
|
|
115
|
+
const child = h("span", null, "inner")
|
|
116
|
+
const parent = h("div", null, child)
|
|
117
|
+
expect(parent.children).toHaveLength(1)
|
|
118
|
+
expect((parent.children[0] as VNode).type).toBe("span")
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// ─── Fragment ────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
describe("Fragment", () => {
|
|
125
|
+
test("is a symbol", () => {
|
|
126
|
+
expect(typeof Fragment).toBe("symbol")
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test("Fragment VNode wraps children without a DOM element", () => {
|
|
130
|
+
const node = h(Fragment, null, h("span", null, "a"), h("span", null, "b"))
|
|
131
|
+
expect(node.type).toBe(Fragment)
|
|
132
|
+
expect(node.children).toHaveLength(2)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// ─── defineComponent ────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
describe("defineComponent()", () => {
|
|
139
|
+
test("returns the same function", () => {
|
|
140
|
+
const fn: ComponentFn = () => h("div", null)
|
|
141
|
+
const defined = defineComponent(fn)
|
|
142
|
+
expect(defined).toBe(fn)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test("preserves typed props", () => {
|
|
146
|
+
const Comp = defineComponent<{ count: number }>((props) => {
|
|
147
|
+
return h("span", null, String(props.count))
|
|
148
|
+
})
|
|
149
|
+
const node = Comp({ count: 5 })
|
|
150
|
+
expect(node).not.toBeNull()
|
|
151
|
+
expect((node as VNode).type).toBe("span")
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// ─── runWithHooks / lifecycle ────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
describe("runWithHooks()", () => {
|
|
158
|
+
test("captures lifecycle hooks registered during component execution", () => {
|
|
159
|
+
const mountFn = () => undefined
|
|
160
|
+
const unmountFn = () => {}
|
|
161
|
+
const updateFn = () => {}
|
|
162
|
+
const errorFn = () => true
|
|
163
|
+
|
|
164
|
+
const Comp: ComponentFn = () => {
|
|
165
|
+
onMount(mountFn)
|
|
166
|
+
onUnmount(unmountFn)
|
|
167
|
+
onUpdate(updateFn)
|
|
168
|
+
onErrorCaptured(errorFn)
|
|
169
|
+
return h("div", null)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const { vnode, hooks } = runWithHooks(Comp, {})
|
|
173
|
+
expect(vnode).not.toBeNull()
|
|
174
|
+
expect(hooks.mount).toHaveLength(1)
|
|
175
|
+
expect(hooks.mount[0]).toBe(mountFn)
|
|
176
|
+
expect(hooks.unmount).toHaveLength(1)
|
|
177
|
+
expect(hooks.unmount[0]).toBe(unmountFn)
|
|
178
|
+
expect(hooks.update).toHaveLength(1)
|
|
179
|
+
expect(hooks.update[0]).toBe(updateFn)
|
|
180
|
+
expect(hooks.error).toHaveLength(1)
|
|
181
|
+
expect(hooks.error[0]).toBe(errorFn)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test("returns null vnode when component returns null", () => {
|
|
185
|
+
const Comp: ComponentFn = () => null
|
|
186
|
+
const { vnode } = runWithHooks(Comp, {})
|
|
187
|
+
expect(vnode).toBeNull()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test("clears hooks context after execution (hooks outside component are no-ops)", () => {
|
|
191
|
+
const Comp: ComponentFn = () => h("div", null)
|
|
192
|
+
runWithHooks(Comp, {})
|
|
193
|
+
|
|
194
|
+
// Calling lifecycle hooks outside a component should not throw
|
|
195
|
+
onMount(() => undefined)
|
|
196
|
+
onUnmount(() => {})
|
|
197
|
+
onUpdate(() => {})
|
|
198
|
+
onErrorCaptured(() => true)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test("multiple hooks of the same type are all captured", () => {
|
|
202
|
+
const Comp: ComponentFn = () => {
|
|
203
|
+
onMount(() => undefined)
|
|
204
|
+
onMount(() => undefined)
|
|
205
|
+
onMount(() => undefined)
|
|
206
|
+
return h("div", null)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const { hooks } = runWithHooks(Comp, {})
|
|
210
|
+
expect(hooks.mount).toHaveLength(3)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test("passes props to component function", () => {
|
|
214
|
+
let received: unknown = null
|
|
215
|
+
const Comp: ComponentFn<{ msg: string }> = (props) => {
|
|
216
|
+
received = props
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
runWithHooks(Comp, { msg: "hello" })
|
|
220
|
+
expect(received).toEqual({ msg: "hello" })
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// ─── propagateError ──────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
describe("propagateError()", () => {
|
|
227
|
+
test("returns true when handler marks error as handled", () => {
|
|
228
|
+
const hooks = {
|
|
229
|
+
mount: [],
|
|
230
|
+
unmount: [],
|
|
231
|
+
update: [],
|
|
232
|
+
error: [(_err: unknown) => true as boolean | undefined],
|
|
233
|
+
}
|
|
234
|
+
expect(propagateError(new Error("test"), hooks)).toBe(true)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test("returns false when no handlers", () => {
|
|
238
|
+
const hooks = {
|
|
239
|
+
mount: [],
|
|
240
|
+
unmount: [],
|
|
241
|
+
update: [],
|
|
242
|
+
error: [] as ((err: unknown) => boolean | undefined)[],
|
|
243
|
+
}
|
|
244
|
+
expect(propagateError(new Error("test"), hooks)).toBe(false)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test("returns false when handler does not return true", () => {
|
|
248
|
+
const hooks = {
|
|
249
|
+
mount: [],
|
|
250
|
+
unmount: [],
|
|
251
|
+
update: [],
|
|
252
|
+
error: [(_err: unknown) => undefined as boolean | undefined],
|
|
253
|
+
}
|
|
254
|
+
expect(propagateError(new Error("test"), hooks)).toBe(false)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test("stops at first handler that returns true", () => {
|
|
258
|
+
let secondCalled = false
|
|
259
|
+
const hooks = {
|
|
260
|
+
mount: [],
|
|
261
|
+
unmount: [],
|
|
262
|
+
update: [],
|
|
263
|
+
error: [
|
|
264
|
+
(_err: unknown) => true as boolean | undefined,
|
|
265
|
+
(_err: unknown) => {
|
|
266
|
+
secondCalled = true
|
|
267
|
+
return true as boolean | undefined
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
}
|
|
271
|
+
propagateError(new Error("test"), hooks)
|
|
272
|
+
expect(secondCalled).toBe(false)
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
// ─── Context ─────────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
describe("createContext / useContext", () => {
|
|
279
|
+
test("createContext returns context with default value", () => {
|
|
280
|
+
const ctx = createContext(42)
|
|
281
|
+
expect(ctx.defaultValue).toBe(42)
|
|
282
|
+
expect(typeof ctx.id).toBe("symbol")
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test("useContext returns default when no provider", () => {
|
|
286
|
+
const ctx = createContext("default-value")
|
|
287
|
+
expect(useContext(ctx)).toBe("default-value")
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test("withContext provides value during callback", () => {
|
|
291
|
+
const ctx = createContext(0)
|
|
292
|
+
let captured = -1
|
|
293
|
+
withContext(ctx, 99, () => {
|
|
294
|
+
captured = useContext(ctx)
|
|
295
|
+
})
|
|
296
|
+
expect(captured).toBe(99)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test("withContext restores stack after callback (even on throw)", () => {
|
|
300
|
+
const ctx = createContext("original")
|
|
301
|
+
try {
|
|
302
|
+
withContext(ctx, "override", () => {
|
|
303
|
+
throw new Error("boom")
|
|
304
|
+
})
|
|
305
|
+
} catch {}
|
|
306
|
+
expect(useContext(ctx)).toBe("original")
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test("nested contexts override outer", () => {
|
|
310
|
+
const ctx = createContext(0)
|
|
311
|
+
withContext(ctx, 1, () => {
|
|
312
|
+
expect(useContext(ctx)).toBe(1)
|
|
313
|
+
withContext(ctx, 2, () => {
|
|
314
|
+
expect(useContext(ctx)).toBe(2)
|
|
315
|
+
})
|
|
316
|
+
expect(useContext(ctx)).toBe(1)
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
test("multiple contexts are independent", () => {
|
|
321
|
+
const ctxA = createContext("a")
|
|
322
|
+
const ctxB = createContext("b")
|
|
323
|
+
withContext(ctxA, "A", () => {
|
|
324
|
+
withContext(ctxB, "B", () => {
|
|
325
|
+
expect(useContext(ctxA)).toBe("A")
|
|
326
|
+
expect(useContext(ctxB)).toBe("B")
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test("pushContext / popContext work directly", () => {
|
|
332
|
+
const ctx = createContext("default")
|
|
333
|
+
const frame = new Map<symbol, unknown>([[ctx.id, "pushed"]])
|
|
334
|
+
pushContext(frame)
|
|
335
|
+
expect(useContext(ctx)).toBe("pushed")
|
|
336
|
+
popContext()
|
|
337
|
+
expect(useContext(ctx)).toBe("default")
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
// ─── createRef ───────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
describe("createRef()", () => {
|
|
344
|
+
test("returns object with current = null", () => {
|
|
345
|
+
const ref = createRef()
|
|
346
|
+
expect(ref.current).toBeNull()
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
test("current is mutable", () => {
|
|
350
|
+
const ref = createRef<number>()
|
|
351
|
+
ref.current = 42
|
|
352
|
+
expect(ref.current).toBe(42)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
test("typed ref works", () => {
|
|
356
|
+
const ref = createRef<string>()
|
|
357
|
+
ref.current = "hello"
|
|
358
|
+
expect(ref.current).toBe("hello")
|
|
359
|
+
})
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
// ─── Show ────────────────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
describe("Show", () => {
|
|
365
|
+
test("returns a reactive getter", () => {
|
|
366
|
+
const result = Show({ when: () => true, children: "visible" })
|
|
367
|
+
expect(typeof result).toBe("function")
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
test("returns children when condition is truthy", () => {
|
|
371
|
+
const getter = Show({ when: () => true, children: "visible" }) as unknown as () => VNodeChild
|
|
372
|
+
expect(getter()).toBe("visible")
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test("returns null when condition is falsy and no fallback", () => {
|
|
376
|
+
const getter = Show({ when: () => false, children: "visible" }) as unknown as () => VNodeChild
|
|
377
|
+
expect(getter()).toBeNull()
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
test("returns fallback when condition is falsy", () => {
|
|
381
|
+
const fallbackNode = h("span", null, "nope")
|
|
382
|
+
const getter = Show({
|
|
383
|
+
when: () => false,
|
|
384
|
+
fallback: fallbackNode,
|
|
385
|
+
children: "visible",
|
|
386
|
+
}) as unknown as () => VNodeChild
|
|
387
|
+
expect(getter()).toBe(fallbackNode)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
test("reacts to condition changes", () => {
|
|
391
|
+
let flag = true
|
|
392
|
+
const getter = Show({
|
|
393
|
+
when: () => flag,
|
|
394
|
+
children: "yes",
|
|
395
|
+
fallback: "no",
|
|
396
|
+
}) as unknown as () => VNodeChild
|
|
397
|
+
expect(getter()).toBe("yes")
|
|
398
|
+
flag = false
|
|
399
|
+
expect(getter()).toBe("no")
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
test("returns null for children when children not provided and condition truthy", () => {
|
|
403
|
+
const getter = Show({ when: () => true }) as unknown as () => VNodeChild
|
|
404
|
+
expect(getter()).toBeNull()
|
|
405
|
+
})
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
// ─── Switch / Match ──────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
describe("Switch / Match", () => {
|
|
411
|
+
test("renders first matching branch", () => {
|
|
412
|
+
const result = Switch({
|
|
413
|
+
children: [
|
|
414
|
+
h(Match, { when: () => false }, "first"),
|
|
415
|
+
h(Match, { when: () => true }, "second"),
|
|
416
|
+
h(Match, { when: () => true }, "third"),
|
|
417
|
+
],
|
|
418
|
+
})
|
|
419
|
+
const getter = result as unknown as () => VNodeChild
|
|
420
|
+
expect(getter()).toBe("second")
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test("renders fallback when no branch matches", () => {
|
|
424
|
+
const fb = h("p", null, "404")
|
|
425
|
+
const result = Switch({
|
|
426
|
+
fallback: fb,
|
|
427
|
+
children: [h(Match, { when: () => false }, "a"), h(Match, { when: () => false }, "b")],
|
|
428
|
+
})
|
|
429
|
+
const getter = result as unknown as () => VNodeChild
|
|
430
|
+
expect(getter()).toBe(fb)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
test("returns null when no match and no fallback", () => {
|
|
434
|
+
const result = Switch({
|
|
435
|
+
children: [h(Match, { when: () => false }, "a")],
|
|
436
|
+
})
|
|
437
|
+
const getter = result as unknown as () => VNodeChild
|
|
438
|
+
expect(getter()).toBeNull()
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
test("handles single child (not array)", () => {
|
|
442
|
+
const result = Switch({
|
|
443
|
+
children: h(Match, { when: () => true }, "only"),
|
|
444
|
+
})
|
|
445
|
+
const getter = result as unknown as () => VNodeChild
|
|
446
|
+
expect(getter()).toBe("only")
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
test("handles no children", () => {
|
|
450
|
+
const result = Switch({})
|
|
451
|
+
const getter = result as unknown as () => VNodeChild
|
|
452
|
+
expect(getter()).toBeNull()
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
test("Match function returns null (marker only)", () => {
|
|
456
|
+
const result = Match({ when: () => true, children: "content" })
|
|
457
|
+
expect(result).toBeNull()
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
test("MatchSymbol is a symbol", () => {
|
|
461
|
+
expect(typeof MatchSymbol).toBe("symbol")
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
test("reacts to condition changes", () => {
|
|
465
|
+
let a = false
|
|
466
|
+
let b = false
|
|
467
|
+
const result = Switch({
|
|
468
|
+
fallback: "none",
|
|
469
|
+
children: [h(Match, { when: () => a }, "A"), h(Match, { when: () => b }, "B")],
|
|
470
|
+
})
|
|
471
|
+
const getter = result as unknown as () => VNodeChild
|
|
472
|
+
expect(getter()).toBe("none")
|
|
473
|
+
b = true
|
|
474
|
+
expect(getter()).toBe("B")
|
|
475
|
+
a = true
|
|
476
|
+
expect(getter()).toBe("A") // first match wins
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
test("handles multiple children in a Match branch", () => {
|
|
480
|
+
const result = Switch({
|
|
481
|
+
children: [h(Match, { when: () => true }, "child1", "child2")],
|
|
482
|
+
})
|
|
483
|
+
const getter = result as unknown as () => VNodeChild
|
|
484
|
+
const value = getter()
|
|
485
|
+
expect(Array.isArray(value)).toBe(true)
|
|
486
|
+
expect(value as unknown).toEqual(["child1", "child2"])
|
|
487
|
+
})
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
// ─── For ─────────────────────────────────────────────────────────────────────
|
|
491
|
+
|
|
492
|
+
describe("For()", () => {
|
|
493
|
+
test("returns a VNode with ForSymbol type", () => {
|
|
494
|
+
const node = For({
|
|
495
|
+
each: () => [1, 2, 3],
|
|
496
|
+
by: (item) => item,
|
|
497
|
+
children: (item) => h("li", null, String(item)),
|
|
498
|
+
})
|
|
499
|
+
expect(node.type).toBe(ForSymbol)
|
|
500
|
+
expect(node.children).toEqual([])
|
|
501
|
+
expect(node.key).toBeNull()
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
test("ForSymbol is a symbol", () => {
|
|
505
|
+
expect(typeof ForSymbol).toBe("symbol")
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
test("props contain each, by, children functions", () => {
|
|
509
|
+
const eachFn = () => [1, 2]
|
|
510
|
+
const keyFn = (item: number) => item
|
|
511
|
+
const childFn = (item: number) => h("span", null, String(item))
|
|
512
|
+
const node = For({ each: eachFn, by: keyFn, children: childFn })
|
|
513
|
+
const props = node.props as unknown as {
|
|
514
|
+
each: typeof eachFn
|
|
515
|
+
by: typeof keyFn
|
|
516
|
+
children: typeof childFn
|
|
517
|
+
}
|
|
518
|
+
expect(props.each).toBe(eachFn)
|
|
519
|
+
expect(props.by).toBe(keyFn)
|
|
520
|
+
expect(props.children).toBe(childFn)
|
|
521
|
+
})
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
// ─── Portal ──────────────────────────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
describe("Portal()", () => {
|
|
527
|
+
test("returns a VNode with PortalSymbol type", () => {
|
|
528
|
+
const fakeTarget = {} as Element
|
|
529
|
+
const node = Portal({ target: fakeTarget, children: h("div", null) })
|
|
530
|
+
expect(node.type).toBe(PortalSymbol)
|
|
531
|
+
expect(node.key).toBeNull()
|
|
532
|
+
expect(node.children).toEqual([])
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
test("PortalSymbol is a symbol", () => {
|
|
536
|
+
expect(typeof PortalSymbol).toBe("symbol")
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
test("props contain target and children", () => {
|
|
540
|
+
const fakeTarget = {} as Element
|
|
541
|
+
const child = h("span", null, "content")
|
|
542
|
+
const node = Portal({ target: fakeTarget, children: child })
|
|
543
|
+
const props = node.props as unknown as { target: Element; children: VNode }
|
|
544
|
+
expect(props.target).toBe(fakeTarget)
|
|
545
|
+
expect(props.children).toBe(child)
|
|
546
|
+
})
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
// ─── Suspense ────────────────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
describe("Suspense", () => {
|
|
552
|
+
test("returns a Fragment VNode", () => {
|
|
553
|
+
const node = Suspense({
|
|
554
|
+
fallback: h("div", null, "loading..."),
|
|
555
|
+
children: h("div", null, "content"),
|
|
556
|
+
})
|
|
557
|
+
expect(node.type).toBe(Fragment)
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
test("renders children when not loading", () => {
|
|
561
|
+
const child = h("div", null, "loaded")
|
|
562
|
+
const node = Suspense({
|
|
563
|
+
fallback: h("span", null, "loading"),
|
|
564
|
+
children: child,
|
|
565
|
+
})
|
|
566
|
+
// The child of Fragment is a reactive getter
|
|
567
|
+
expect(node.children).toHaveLength(1)
|
|
568
|
+
const getter = node.children[0] as () => VNodeChild
|
|
569
|
+
expect(typeof getter).toBe("function")
|
|
570
|
+
// Should return the child since it's not a lazy component
|
|
571
|
+
expect(getter()).toBe(child)
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
test("renders fallback when child type has __loading() returning true", () => {
|
|
575
|
+
const fallback = h("span", null, "loading")
|
|
576
|
+
const lazyFn = (() => h("div", null)) as unknown as ComponentFn & { __loading: () => boolean }
|
|
577
|
+
lazyFn.__loading = () => true
|
|
578
|
+
const child = h(lazyFn, null)
|
|
579
|
+
|
|
580
|
+
const node = Suspense({ fallback, children: child })
|
|
581
|
+
const getter = node.children[0] as () => VNodeChild
|
|
582
|
+
expect(getter()).toBe(fallback)
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
test("renders children when __loading returns false", () => {
|
|
586
|
+
const fallback = h("span", null, "loading")
|
|
587
|
+
const lazyFn = (() => h("div", null)) as unknown as ComponentFn & { __loading: () => boolean }
|
|
588
|
+
lazyFn.__loading = () => false
|
|
589
|
+
const child = h(lazyFn, null)
|
|
590
|
+
|
|
591
|
+
const node = Suspense({ fallback, children: child })
|
|
592
|
+
const getter = node.children[0] as () => VNodeChild
|
|
593
|
+
expect(getter()).toBe(child)
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
test("handles function children (reactive getter)", () => {
|
|
597
|
+
const child = h("div", null, "content")
|
|
598
|
+
const node = Suspense({
|
|
599
|
+
fallback: h("span", null, "loading"),
|
|
600
|
+
children: () => child,
|
|
601
|
+
})
|
|
602
|
+
const getter = node.children[0] as () => VNodeChild
|
|
603
|
+
// The getter should unwrap the function child
|
|
604
|
+
expect(getter()).toBe(child)
|
|
605
|
+
})
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
// ─── ErrorBoundary ───────────────────────────────────────────────────────────
|
|
609
|
+
|
|
610
|
+
describe("ErrorBoundary", () => {
|
|
611
|
+
test("is a component function", () => {
|
|
612
|
+
expect(typeof ErrorBoundary).toBe("function")
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
test("returns a reactive getter", () => {
|
|
616
|
+
// Must run inside runWithHooks since ErrorBoundary calls onUnmount
|
|
617
|
+
const { vnode, hooks } = runWithHooks(() => {
|
|
618
|
+
return h(
|
|
619
|
+
"div",
|
|
620
|
+
null,
|
|
621
|
+
ErrorBoundary({
|
|
622
|
+
fallback: (err) => `Error: ${err}`,
|
|
623
|
+
children: "child content",
|
|
624
|
+
}) as VNodeChild,
|
|
625
|
+
)
|
|
626
|
+
}, {})
|
|
627
|
+
expect(vnode).not.toBeNull()
|
|
628
|
+
// Should have registered onUnmount for cleanup
|
|
629
|
+
expect(hooks.unmount.length).toBeGreaterThanOrEqual(1)
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
test("renders children when no error", () => {
|
|
633
|
+
let result: VNodeChild = null
|
|
634
|
+
runWithHooks(() => {
|
|
635
|
+
result = ErrorBoundary({
|
|
636
|
+
fallback: (err) => `Error: ${err}`,
|
|
637
|
+
children: "child content",
|
|
638
|
+
})
|
|
639
|
+
return null
|
|
640
|
+
}, {})
|
|
641
|
+
expect(typeof result).toBe("function")
|
|
642
|
+
const getter = result as unknown as () => VNodeChild
|
|
643
|
+
expect(getter()).toBe("child content")
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
test("renders function children by calling them", () => {
|
|
647
|
+
let result: VNodeChild = null
|
|
648
|
+
runWithHooks(() => {
|
|
649
|
+
result = ErrorBoundary({
|
|
650
|
+
fallback: (err) => `Error: ${err}`,
|
|
651
|
+
children: () => "dynamic child",
|
|
652
|
+
})
|
|
653
|
+
return null
|
|
654
|
+
}, {})
|
|
655
|
+
const getter = result as unknown as () => VNodeChild
|
|
656
|
+
expect(getter()).toBe("dynamic child")
|
|
657
|
+
})
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
// ─── dispatchToErrorBoundary ─────────────────────────────────────────────────
|
|
661
|
+
|
|
662
|
+
describe("dispatchToErrorBoundary()", () => {
|
|
663
|
+
test("dispatches to the most recently pushed boundary", async () => {
|
|
664
|
+
// Previous ErrorBoundary tests may have left handlers on the stack,
|
|
665
|
+
// so we test by pushing our own known handler.
|
|
666
|
+
let caughtErr: unknown = null
|
|
667
|
+
const { pushErrorBoundary: push, popErrorBoundary: pop } = await import("../component")
|
|
668
|
+
push((err: unknown) => {
|
|
669
|
+
caughtErr = err
|
|
670
|
+
return true
|
|
671
|
+
})
|
|
672
|
+
expect(dispatchToErrorBoundary(new Error("caught"))).toBe(true)
|
|
673
|
+
expect((caughtErr as Error).message).toBe("caught")
|
|
674
|
+
pop()
|
|
675
|
+
})
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
// ─── mapArray ────────────────────────────────────────────────────────────────
|
|
679
|
+
|
|
680
|
+
describe("mapArray()", () => {
|
|
681
|
+
test("maps items with caching", () => {
|
|
682
|
+
let callCount = 0
|
|
683
|
+
const items = [1, 2, 3]
|
|
684
|
+
const mapped = mapArray(
|
|
685
|
+
() => items,
|
|
686
|
+
(item) => item,
|
|
687
|
+
(item) => {
|
|
688
|
+
callCount++
|
|
689
|
+
return item * 10
|
|
690
|
+
},
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
const result1 = mapped()
|
|
694
|
+
expect(result1).toEqual([10, 20, 30])
|
|
695
|
+
expect(callCount).toBe(3)
|
|
696
|
+
|
|
697
|
+
// Second call should use cache
|
|
698
|
+
const result2 = mapped()
|
|
699
|
+
expect(result2).toEqual([10, 20, 30])
|
|
700
|
+
expect(callCount).toBe(3) // no new calls
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
test("only maps new keys on update", () => {
|
|
704
|
+
let callCount = 0
|
|
705
|
+
let items = [1, 2, 3]
|
|
706
|
+
const mapped = mapArray(
|
|
707
|
+
() => items,
|
|
708
|
+
(item) => item,
|
|
709
|
+
(item) => {
|
|
710
|
+
callCount++
|
|
711
|
+
return item * 10
|
|
712
|
+
},
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
mapped() // initial: 3 calls
|
|
716
|
+
expect(callCount).toBe(3)
|
|
717
|
+
|
|
718
|
+
items = [1, 2, 3, 4]
|
|
719
|
+
mapped() // only item 4 is new
|
|
720
|
+
expect(callCount).toBe(4)
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
test("evicts removed keys", () => {
|
|
724
|
+
let items = [1, 2, 3]
|
|
725
|
+
const mapped = mapArray(
|
|
726
|
+
() => items,
|
|
727
|
+
(item) => item,
|
|
728
|
+
(item) => item * 10,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
mapped()
|
|
732
|
+
items = [1, 3] // remove key 2
|
|
733
|
+
const result = mapped()
|
|
734
|
+
expect(result).toEqual([10, 30])
|
|
735
|
+
|
|
736
|
+
// Re-add key 2 — should re-map since it was evicted
|
|
737
|
+
let callCount = 0
|
|
738
|
+
items = [1, 2, 3]
|
|
739
|
+
const mapped2 = mapArray(
|
|
740
|
+
() => items,
|
|
741
|
+
(item) => item,
|
|
742
|
+
(item) => {
|
|
743
|
+
callCount++
|
|
744
|
+
return item * 100
|
|
745
|
+
},
|
|
746
|
+
)
|
|
747
|
+
mapped2()
|
|
748
|
+
expect(callCount).toBe(3)
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
test("handles empty source", () => {
|
|
752
|
+
const mapped = mapArray(
|
|
753
|
+
() => [],
|
|
754
|
+
(item: number) => item,
|
|
755
|
+
(item) => item * 10,
|
|
756
|
+
)
|
|
757
|
+
expect(mapped()).toEqual([])
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
test("handles reordering", () => {
|
|
761
|
+
let items = [1, 2, 3]
|
|
762
|
+
let callCount = 0
|
|
763
|
+
const mapped = mapArray(
|
|
764
|
+
() => items,
|
|
765
|
+
(item) => item,
|
|
766
|
+
(item) => {
|
|
767
|
+
callCount++
|
|
768
|
+
return item * 10
|
|
769
|
+
},
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
mapped()
|
|
773
|
+
expect(callCount).toBe(3)
|
|
774
|
+
|
|
775
|
+
items = [3, 1, 2] // reorder
|
|
776
|
+
const result = mapped()
|
|
777
|
+
expect(result).toEqual([30, 10, 20])
|
|
778
|
+
expect(callCount).toBe(3) // no new calls — all cached
|
|
779
|
+
})
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
// ─── Telemetry ───────────────────────────────────────────────────────────────
|
|
783
|
+
|
|
784
|
+
describe("registerErrorHandler / reportError", () => {
|
|
785
|
+
test("registerErrorHandler registers and calls handler", () => {
|
|
786
|
+
const errors: unknown[] = []
|
|
787
|
+
const unregister = registerErrorHandler((ctx) => {
|
|
788
|
+
errors.push(ctx.error)
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
reportError({ component: "Test", phase: "render", error: "boom", timestamp: Date.now() })
|
|
792
|
+
expect(errors).toEqual(["boom"])
|
|
793
|
+
|
|
794
|
+
unregister()
|
|
795
|
+
reportError({ component: "Test", phase: "render", error: "after", timestamp: Date.now() })
|
|
796
|
+
expect(errors).toEqual(["boom"]) // not called after unregister
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
test("multiple handlers are all called", () => {
|
|
800
|
+
let count = 0
|
|
801
|
+
const unsub1 = registerErrorHandler(() => {
|
|
802
|
+
count++
|
|
803
|
+
})
|
|
804
|
+
const unsub2 = registerErrorHandler(() => {
|
|
805
|
+
count++
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
reportError({ component: "X", phase: "setup", error: "err", timestamp: 0 })
|
|
809
|
+
expect(count).toBe(2)
|
|
810
|
+
|
|
811
|
+
unsub1()
|
|
812
|
+
unsub2()
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
test("handler errors are swallowed", () => {
|
|
816
|
+
let secondCalled = false
|
|
817
|
+
const unsub1 = registerErrorHandler(() => {
|
|
818
|
+
throw new Error("handler crash")
|
|
819
|
+
})
|
|
820
|
+
const unsub2 = registerErrorHandler(() => {
|
|
821
|
+
secondCalled = true
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
// Should not throw
|
|
825
|
+
reportError({ component: "Y", phase: "mount", error: "err", timestamp: 0 })
|
|
826
|
+
expect(secondCalled).toBe(true)
|
|
827
|
+
|
|
828
|
+
unsub1()
|
|
829
|
+
unsub2()
|
|
830
|
+
})
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
// ─── JSX Runtime ─────────────────────────────────────────────────────────────
|
|
834
|
+
|
|
835
|
+
describe("jsx / jsxs / jsxDEV", () => {
|
|
836
|
+
test("jsx creates VNode for DOM element", () => {
|
|
837
|
+
const node = jsx("div", { class: "x" })
|
|
838
|
+
expect(node.type).toBe("div")
|
|
839
|
+
expect(node.props).toEqual({ class: "x" })
|
|
840
|
+
expect(node.children).toEqual([])
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
test("jsx handles children in props for DOM elements", () => {
|
|
844
|
+
const node = jsx("div", { children: "hello" })
|
|
845
|
+
expect(node.children).toEqual(["hello"])
|
|
846
|
+
// children should not be in props for DOM elements
|
|
847
|
+
expect(node.props).toEqual({})
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
test("jsx handles array children for DOM elements", () => {
|
|
851
|
+
const node = jsx("div", { children: ["a", "b", "c"] })
|
|
852
|
+
expect(node.children).toEqual(["a", "b", "c"])
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
test("jsx passes children in props for component functions", () => {
|
|
856
|
+
const Comp: ComponentFn = (props) => h("span", null, String(props.children))
|
|
857
|
+
const node = jsx(Comp, { children: "content" })
|
|
858
|
+
expect(node.type).toBe(Comp)
|
|
859
|
+
// For components, children stay in props
|
|
860
|
+
expect(node.props.children).toBe("content")
|
|
861
|
+
expect(node.children).toEqual([])
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
test("jsx handles key parameter", () => {
|
|
865
|
+
const node = jsx("li", { id: "x" }, "my-key")
|
|
866
|
+
expect(node.key).toBe("my-key")
|
|
867
|
+
// key is added to props by jsx runtime
|
|
868
|
+
expect(node.props).toEqual({ id: "x", key: "my-key" })
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
test("jsx handles Fragment (symbol type) with children", () => {
|
|
872
|
+
const node = jsx(Fragment, { children: ["a", "b"] })
|
|
873
|
+
expect(node.type).toBe(Fragment)
|
|
874
|
+
expect(node.children).toEqual(["a", "b"])
|
|
875
|
+
})
|
|
876
|
+
|
|
877
|
+
test("jsxs is the same as jsx", () => {
|
|
878
|
+
expect(jsxs).toBe(jsx)
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
test("jsxDEV is the same as jsx", () => {
|
|
882
|
+
expect(jsxDEV).toBe(jsx)
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
test("JsxFragment is the same as Fragment", () => {
|
|
886
|
+
expect(JsxFragment).toBe(Fragment)
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
test("jsx with no children in props", () => {
|
|
890
|
+
const node = jsx("span", { id: "test" })
|
|
891
|
+
expect(node.children).toEqual([])
|
|
892
|
+
expect(node.props).toEqual({ id: "test" })
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
test("jsx component with no children", () => {
|
|
896
|
+
const Comp: ComponentFn = () => null
|
|
897
|
+
const node = jsx(Comp, { name: "test" })
|
|
898
|
+
expect(node.props).toEqual({ name: "test" })
|
|
899
|
+
// children should not be injected if not provided
|
|
900
|
+
expect(node.props.children).toBeUndefined()
|
|
901
|
+
})
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
// ─── Lifecycle hooks outside component ───────────────────────────────────────
|
|
905
|
+
|
|
906
|
+
describe("lifecycle hooks", () => {
|
|
907
|
+
test("onMount outside component is a no-op", () => {
|
|
908
|
+
expect(() => onMount(() => undefined)).not.toThrow()
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
test("onUnmount outside component is a no-op", () => {
|
|
912
|
+
expect(() => onUnmount(() => {})).not.toThrow()
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
test("onUpdate outside component is a no-op", () => {
|
|
916
|
+
expect(() => onUpdate(() => {})).not.toThrow()
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
test("onErrorCaptured outside component is a no-op", () => {
|
|
920
|
+
expect(() => onErrorCaptured(() => true)).not.toThrow()
|
|
921
|
+
})
|
|
922
|
+
})
|
|
923
|
+
|
|
924
|
+
// ─── Edge cases ──────────────────────────────────────────────────────────────
|
|
925
|
+
|
|
926
|
+
describe("edge cases", () => {
|
|
927
|
+
test("h() with empty children array", () => {
|
|
928
|
+
const node = h("div", null, ...[])
|
|
929
|
+
expect(node.children).toEqual([])
|
|
930
|
+
})
|
|
931
|
+
|
|
932
|
+
test("h() with mixed children types", () => {
|
|
933
|
+
const node = h("div", null, "text", 42, h("span", null), null, () => "reactive")
|
|
934
|
+
expect(node.children).toHaveLength(5)
|
|
935
|
+
expect(node.children[0]).toBe("text")
|
|
936
|
+
expect(node.children[1]).toBe(42)
|
|
937
|
+
expect((node.children[2] as VNode).type).toBe("span")
|
|
938
|
+
expect(node.children[3]).toBeNull()
|
|
939
|
+
expect(typeof node.children[4]).toBe("function")
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
test("nested Fragments", () => {
|
|
943
|
+
const node = h(Fragment, null, h(Fragment, null, "a", "b"), h(Fragment, null, "c"))
|
|
944
|
+
expect(node.type).toBe(Fragment)
|
|
945
|
+
expect(node.children).toHaveLength(2)
|
|
946
|
+
expect((node.children[0] as VNode).type).toBe(Fragment)
|
|
947
|
+
expect((node.children[1] as VNode).type).toBe(Fragment)
|
|
948
|
+
})
|
|
949
|
+
|
|
950
|
+
test("component that throws during setup is propagated by runWithHooks", () => {
|
|
951
|
+
const Comp: ComponentFn = () => {
|
|
952
|
+
throw new Error("setup error")
|
|
953
|
+
}
|
|
954
|
+
expect(() => runWithHooks(Comp, {})).toThrow("setup error")
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
test("createContext with undefined default", () => {
|
|
958
|
+
const ctx = createContext<string | undefined>(undefined)
|
|
959
|
+
expect(ctx.defaultValue).toBeUndefined()
|
|
960
|
+
expect(useContext(ctx)).toBeUndefined()
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
test("createContext with object default", () => {
|
|
964
|
+
const defaultObj = { a: 1, b: "two" }
|
|
965
|
+
const ctx = createContext(defaultObj)
|
|
966
|
+
expect(useContext(ctx)).toBe(defaultObj)
|
|
967
|
+
})
|
|
968
|
+
|
|
969
|
+
test("Show with VNode children", () => {
|
|
970
|
+
const child = h("div", null, "content")
|
|
971
|
+
const getter = Show({ when: () => true, children: child }) as unknown as () => VNodeChild
|
|
972
|
+
expect(getter()).toBe(child)
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
test("For with objects", () => {
|
|
976
|
+
const items = [
|
|
977
|
+
{ id: 1, name: "a" },
|
|
978
|
+
{ id: 2, name: "b" },
|
|
979
|
+
]
|
|
980
|
+
const node = For<{ id: number; name: string }>({
|
|
981
|
+
each: () => items,
|
|
982
|
+
by: (item) => item.id,
|
|
983
|
+
children: (item) => h("span", null, item.name),
|
|
984
|
+
})
|
|
985
|
+
expect(node.type).toBe(ForSymbol)
|
|
986
|
+
const props = node.props as unknown as { each: () => typeof items }
|
|
987
|
+
expect(props.each()).toBe(items)
|
|
988
|
+
})
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
// ─── lazy() ───────────────────────────────────────────────────────────────────
|
|
992
|
+
|
|
993
|
+
describe("lazy()", () => {
|
|
994
|
+
test("returns a LazyComponent with __loading flag", () => {
|
|
995
|
+
const Comp = lazy<Props>(() => new Promise(() => {})) // never resolves
|
|
996
|
+
expect(typeof Comp).toBe("function")
|
|
997
|
+
expect(typeof Comp.__loading).toBe("function")
|
|
998
|
+
expect(Comp.__loading()).toBe(true)
|
|
999
|
+
})
|
|
1000
|
+
|
|
1001
|
+
test("resolves to the loaded component", async () => {
|
|
1002
|
+
const Inner: ComponentFn<{ name: string }> = (props) => h("span", null, props.name)
|
|
1003
|
+
const Comp = lazy(() => Promise.resolve({ default: Inner }))
|
|
1004
|
+
|
|
1005
|
+
// Wait for microtask to resolve
|
|
1006
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
1007
|
+
|
|
1008
|
+
expect(Comp.__loading()).toBe(false)
|
|
1009
|
+
const result = Comp({ name: "hello" })
|
|
1010
|
+
expect(result).not.toBeNull()
|
|
1011
|
+
// lazy wraps via h(comp, props) so type is the component function
|
|
1012
|
+
expect((result as VNode).type).toBe(Inner)
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
test("throws on import error so ErrorBoundary can catch", async () => {
|
|
1016
|
+
const Comp = lazy<Props>(() => Promise.reject(new Error("load failed")))
|
|
1017
|
+
|
|
1018
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
1019
|
+
|
|
1020
|
+
expect(Comp.__loading()).toBe(false)
|
|
1021
|
+
expect(() => Comp({})).toThrow("load failed")
|
|
1022
|
+
})
|
|
1023
|
+
|
|
1024
|
+
test("wraps non-Error rejection in Error", async () => {
|
|
1025
|
+
const Comp = lazy<Props>(() => Promise.reject("string error"))
|
|
1026
|
+
|
|
1027
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
1028
|
+
|
|
1029
|
+
expect(() => Comp({})).toThrow("string error")
|
|
1030
|
+
})
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
// ─── setContextStackProvider ──────────────────────────────────────────────────
|
|
1034
|
+
|
|
1035
|
+
describe("setContextStackProvider", () => {
|
|
1036
|
+
test("allows overriding the context stack provider", async () => {
|
|
1037
|
+
const { setContextStackProvider } = await import("../context")
|
|
1038
|
+
const customStack: Map<symbol, unknown>[] = []
|
|
1039
|
+
const ctx = createContext("custom-default")
|
|
1040
|
+
|
|
1041
|
+
// Override with custom stack
|
|
1042
|
+
setContextStackProvider(() => customStack)
|
|
1043
|
+
|
|
1044
|
+
// Push onto custom stack
|
|
1045
|
+
customStack.push(new Map([[ctx.id, "custom-value"]]))
|
|
1046
|
+
expect(useContext(ctx)).toBe("custom-value")
|
|
1047
|
+
customStack.pop()
|
|
1048
|
+
expect(useContext(ctx)).toBe("custom-default")
|
|
1049
|
+
|
|
1050
|
+
// Fully restore to module-level default stack
|
|
1051
|
+
const { setContextStackProvider: restore } = await import("../context")
|
|
1052
|
+
const _defaultStack: Map<symbol, unknown>[] = []
|
|
1053
|
+
restore(() => _defaultStack)
|
|
1054
|
+
})
|
|
1055
|
+
})
|
|
1056
|
+
|
|
1057
|
+
// ─── ErrorBoundary advanced ──────────────────────────────────────────────────
|
|
1058
|
+
|
|
1059
|
+
describe("ErrorBoundary — advanced", () => {
|
|
1060
|
+
test("handler returns false when already in error state (double error)", async () => {
|
|
1061
|
+
let result: VNodeChild = null
|
|
1062
|
+
|
|
1063
|
+
runWithHooks(() => {
|
|
1064
|
+
result = ErrorBoundary({
|
|
1065
|
+
fallback: (err) => `Error: ${err}`,
|
|
1066
|
+
children: "child",
|
|
1067
|
+
})
|
|
1068
|
+
return null
|
|
1069
|
+
}, {})
|
|
1070
|
+
|
|
1071
|
+
const getter = result as unknown as () => VNodeChild
|
|
1072
|
+
expect(getter()).toBe("child")
|
|
1073
|
+
|
|
1074
|
+
// First error should be handled
|
|
1075
|
+
const handled1 = dispatchToErrorBoundary(new Error("first"))
|
|
1076
|
+
expect(handled1).toBe(true)
|
|
1077
|
+
expect(getter()).toBe("Error: Error: first")
|
|
1078
|
+
|
|
1079
|
+
// Second error while already in error state should NOT be handled
|
|
1080
|
+
const handled2 = dispatchToErrorBoundary(new Error("second"))
|
|
1081
|
+
expect(handled2).toBe(false)
|
|
1082
|
+
|
|
1083
|
+
// Clean up the boundary
|
|
1084
|
+
const { popErrorBoundary: pop } = await import("../component")
|
|
1085
|
+
pop()
|
|
1086
|
+
})
|
|
1087
|
+
|
|
1088
|
+
test("reset function clears error and re-renders children", async () => {
|
|
1089
|
+
let result: VNodeChild = null
|
|
1090
|
+
let capturedReset: (() => void) | undefined
|
|
1091
|
+
|
|
1092
|
+
runWithHooks(() => {
|
|
1093
|
+
result = ErrorBoundary({
|
|
1094
|
+
fallback: (err, reset) => {
|
|
1095
|
+
capturedReset = reset
|
|
1096
|
+
return `Error: ${err}`
|
|
1097
|
+
},
|
|
1098
|
+
children: "child content",
|
|
1099
|
+
})
|
|
1100
|
+
return null
|
|
1101
|
+
}, {})
|
|
1102
|
+
|
|
1103
|
+
const getter = result as unknown as () => VNodeChild
|
|
1104
|
+
expect(getter()).toBe("child content")
|
|
1105
|
+
|
|
1106
|
+
// Trigger error
|
|
1107
|
+
dispatchToErrorBoundary(new Error("test error"))
|
|
1108
|
+
expect(getter()).toBe("Error: Error: test error")
|
|
1109
|
+
expect(capturedReset).toBeDefined()
|
|
1110
|
+
|
|
1111
|
+
// Reset
|
|
1112
|
+
capturedReset?.()
|
|
1113
|
+
expect(getter()).toBe("child content")
|
|
1114
|
+
|
|
1115
|
+
// Clean up
|
|
1116
|
+
const { popErrorBoundary: pop } = await import("../component")
|
|
1117
|
+
pop()
|
|
1118
|
+
})
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
// ─── Suspense advanced ──────────────────────────────────────────────────────
|
|
1122
|
+
|
|
1123
|
+
describe("Suspense — advanced", () => {
|
|
1124
|
+
test("evaluates function fallback when child is loading", () => {
|
|
1125
|
+
const fallbackVNode = h("div", null, "fb-content")
|
|
1126
|
+
const lazyFn = (() => h("div", null)) as unknown as ComponentFn & { __loading: () => boolean }
|
|
1127
|
+
lazyFn.__loading = () => true
|
|
1128
|
+
const child = h(lazyFn, null)
|
|
1129
|
+
|
|
1130
|
+
const node = Suspense({ fallback: () => fallbackVNode, children: child })
|
|
1131
|
+
const getter = node.children[0] as () => VNodeChild
|
|
1132
|
+
expect(getter()).toBe(fallbackVNode)
|
|
1133
|
+
})
|
|
1134
|
+
|
|
1135
|
+
test("handles null children", () => {
|
|
1136
|
+
const node = Suspense({ fallback: h("span", null, "loading") })
|
|
1137
|
+
const getter = node.children[0] as () => VNodeChild
|
|
1138
|
+
expect(getter()).toBeUndefined()
|
|
1139
|
+
})
|
|
1140
|
+
|
|
1141
|
+
test("handles array children (not loading)", () => {
|
|
1142
|
+
const children = [h("div", null, "a"), h("div", null, "b")]
|
|
1143
|
+
const node = Suspense({
|
|
1144
|
+
fallback: h("span", null, "loading"),
|
|
1145
|
+
children: children as unknown as VNodeChild,
|
|
1146
|
+
})
|
|
1147
|
+
const getter = node.children[0] as () => VNodeChild
|
|
1148
|
+
// Array is not a VNode with a type, so isLoading check should be false
|
|
1149
|
+
const result = getter()
|
|
1150
|
+
expect(result).toBe(children)
|
|
1151
|
+
})
|
|
1152
|
+
})
|
|
1153
|
+
|
|
1154
|
+
// ─── Show edge cases ────────────────────────────────────────────────────────
|
|
1155
|
+
|
|
1156
|
+
describe("Show — edge cases", () => {
|
|
1157
|
+
test("returns null when condition truthy but children is undefined", () => {
|
|
1158
|
+
const getter = Show({ when: () => true }) as unknown as () => VNodeChild
|
|
1159
|
+
expect(getter()).toBeNull()
|
|
1160
|
+
})
|
|
1161
|
+
|
|
1162
|
+
test("returns null when condition falsy and fallback is undefined", () => {
|
|
1163
|
+
const getter = Show({ when: () => false }) as unknown as () => VNodeChild
|
|
1164
|
+
expect(getter()).toBeNull()
|
|
1165
|
+
})
|
|
1166
|
+
})
|
|
1167
|
+
|
|
1168
|
+
// ─── Switch edge cases ──────────────────────────────────────────────────────
|
|
1169
|
+
|
|
1170
|
+
describe("Switch — edge cases", () => {
|
|
1171
|
+
test("skips non-Match VNode children", () => {
|
|
1172
|
+
const result = Switch({
|
|
1173
|
+
fallback: "default",
|
|
1174
|
+
children: [h("div", null, "not-match"), h(Match, { when: () => true }, "match-child")],
|
|
1175
|
+
})
|
|
1176
|
+
const getter = result as unknown as () => VNodeChild
|
|
1177
|
+
// Should skip the div and match the Match branch
|
|
1178
|
+
expect(getter()).toBe("match-child")
|
|
1179
|
+
})
|
|
1180
|
+
|
|
1181
|
+
test("skips null children in branches", () => {
|
|
1182
|
+
const result = Switch({
|
|
1183
|
+
fallback: "default",
|
|
1184
|
+
children: [null as unknown as VNodeChild, h(Match, { when: () => true }, "found")],
|
|
1185
|
+
})
|
|
1186
|
+
const getter = result as unknown as () => VNodeChild
|
|
1187
|
+
expect(getter()).toBe("found")
|
|
1188
|
+
})
|
|
1189
|
+
|
|
1190
|
+
test("Match with children in props.children (not vnode.children)", () => {
|
|
1191
|
+
// When using explicit props.children instead of h() rest args
|
|
1192
|
+
const matchVNode = {
|
|
1193
|
+
type: Match,
|
|
1194
|
+
props: { when: () => true, children: "from-props" },
|
|
1195
|
+
children: [],
|
|
1196
|
+
key: null,
|
|
1197
|
+
} as unknown as VNodeChild
|
|
1198
|
+
const result = Switch({ children: [matchVNode] })
|
|
1199
|
+
const getter = result as unknown as () => VNodeChild
|
|
1200
|
+
expect(getter()).toBe("from-props")
|
|
1201
|
+
})
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
// ─── Dynamic ──────────────────────────────────────────────────────────────────
|
|
1205
|
+
|
|
1206
|
+
describe("Dynamic", () => {
|
|
1207
|
+
test("renders the given component", () => {
|
|
1208
|
+
const Greeting: ComponentFn = (props) => h("span", null, (props as { name: string }).name)
|
|
1209
|
+
const result = Dynamic({ component: Greeting, name: "world" })
|
|
1210
|
+
expect(result).not.toBeNull()
|
|
1211
|
+
expect((result as VNode).type).toBe(Greeting)
|
|
1212
|
+
expect((result as VNode).props).toEqual({ name: "world" })
|
|
1213
|
+
})
|
|
1214
|
+
|
|
1215
|
+
test("renders a string element", () => {
|
|
1216
|
+
const result = Dynamic({ component: "div", class: "box" })
|
|
1217
|
+
expect(result).not.toBeNull()
|
|
1218
|
+
expect((result as VNode).type).toBe("div")
|
|
1219
|
+
expect((result as VNode).props).toEqual({ class: "box" })
|
|
1220
|
+
})
|
|
1221
|
+
|
|
1222
|
+
test("returns null when component is falsy", () => {
|
|
1223
|
+
const result = Dynamic({ component: "" })
|
|
1224
|
+
expect(result).toBeNull()
|
|
1225
|
+
})
|
|
1226
|
+
})
|