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