@pyreon/preact-compat 0.2.1 → 0.3.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.
@@ -1,8 +1,9 @@
1
1
  import type { VNodeChild } from "@pyreon/core"
2
2
  import { h as pyreonH } from "@pyreon/core"
3
- import { signal as pyreonSignal } from "@pyreon/reactivity"
3
+ import { effect, signal as pyreonSignal } from "@pyreon/reactivity"
4
4
  import { mount } from "@pyreon/runtime-dom"
5
5
  import {
6
+ memo,
6
7
  useCallback,
7
8
  useEffect,
8
9
  useErrorBoundary,
@@ -28,7 +29,9 @@ import {
28
29
  toChildArray,
29
30
  useContext,
30
31
  } from "../index"
31
- import { batch, computed, effect, signal } from "../signals"
32
+ import type { RenderContext } from "../jsx-runtime"
33
+ import { beginRender, endRender, jsx } from "../jsx-runtime"
34
+ import { batch, computed, signal, effect as signalEffect } from "../signals"
32
35
 
33
36
  function container(): HTMLElement {
34
37
  const el = document.createElement("div")
@@ -36,6 +39,41 @@ function container(): HTMLElement {
36
39
  return el
37
40
  }
38
41
 
42
+ /** Helper: creates a RenderContext for testing hooks outside of full render cycle */
43
+ function withHookCtx<T>(fn: () => T): T {
44
+ const ctx: RenderContext = {
45
+ hooks: [],
46
+ scheduleRerender: () => {},
47
+ pendingEffects: [],
48
+ pendingLayoutEffects: [],
49
+ unmounted: false,
50
+ }
51
+ beginRender(ctx)
52
+ const result = fn()
53
+ endRender()
54
+ return result
55
+ }
56
+
57
+ /** Re-render helper: calls fn with the same ctx to simulate re-render */
58
+ function createHookRunner() {
59
+ const ctx: RenderContext = {
60
+ hooks: [],
61
+ scheduleRerender: () => {},
62
+ pendingEffects: [],
63
+ pendingLayoutEffects: [],
64
+ unmounted: false,
65
+ }
66
+ return {
67
+ ctx,
68
+ run<T>(fn: () => T): T {
69
+ beginRender(ctx)
70
+ const result = fn()
71
+ endRender()
72
+ return result
73
+ },
74
+ }
75
+ }
76
+
39
77
  describe("@pyreon/preact-compat", () => {
40
78
  // ─── Core API ────────────────────────────────────────────────────────────
41
79
 
@@ -63,7 +101,6 @@ describe("@pyreon/preact-compat", () => {
63
101
  test("hydrate() calls hydrateRoot", () => {
64
102
  const el = container()
65
103
  el.innerHTML = "<span>hydrated</span>"
66
- // hydrate should not throw; it calls hydrateRoot internally
67
104
  hydrate(h("span", null, "hydrated"), el)
68
105
  expect(el.innerHTML).toContain("hydrated")
69
106
  })
@@ -148,7 +185,6 @@ describe("@pyreon/preact-compat", () => {
148
185
 
149
186
  test("createContext/useContext work", () => {
150
187
  const Ctx = createContext("default")
151
- // Without a provider, useContext returns the default value
152
188
  expect(useContext(Ctx)).toBe("default")
153
189
  })
154
190
 
@@ -202,203 +238,434 @@ describe("@pyreon/preact-compat", () => {
202
238
  }
203
239
  }
204
240
  const c = new MyComp({})
205
- // forceUpdate should not throw and should update internal signal
206
241
  c.forceUpdate()
207
242
  expect(c.state.value).toBe(42)
208
243
  })
244
+ })
209
245
 
210
- // ─── Hooks ───────────────────────────────────────────────────────────────
246
+ // ─── useState ─────────────────────────────────────────────────────────────────
211
247
 
212
- test("useState returns [getter, setter]", () => {
213
- const [count, setCount] = useState(0)
214
- expect(count()).toBe(0)
248
+ describe("useState", () => {
249
+ test("returns [value, setter] value is the initial value", () => {
250
+ const [count] = withHookCtx(() => useState(0))
251
+ expect(count).toBe(0)
252
+ })
253
+
254
+ test("setter updates value on re-render", () => {
255
+ const runner = createHookRunner()
256
+ const [, setCount] = runner.run(() => useState(0))
215
257
  setCount(5)
216
- expect(count()).toBe(5)
258
+ const [count2] = runner.run(() => useState(0))
259
+ expect(count2).toBe(5)
260
+ })
261
+
262
+ test("setter with function updater", () => {
263
+ const runner = createHookRunner()
264
+ const [, setCount] = runner.run(() => useState(10))
217
265
  setCount((prev) => prev + 1)
218
- expect(count()).toBe(6)
266
+ const [count2] = runner.run(() => useState(10))
267
+ expect(count2).toBe(11)
219
268
  })
220
269
 
221
- test("useState with initializer function", () => {
270
+ test("initializer function is called once", () => {
222
271
  let calls = 0
223
- const [val] = useState(() => {
224
- calls++
225
- return 42
226
- })
227
- expect(val()).toBe(42)
272
+ const runner = createHookRunner()
273
+ runner.run(() =>
274
+ useState(() => {
275
+ calls++
276
+ return 42
277
+ }),
278
+ )
279
+ expect(calls).toBe(1)
280
+ runner.run(() =>
281
+ useState(() => {
282
+ calls++
283
+ return 42
284
+ }),
285
+ )
228
286
  expect(calls).toBe(1)
229
287
  })
230
288
 
231
- test("useMemo caches computed values", () => {
232
- const [val, setVal] = useState(3)
233
- const doubled = useMemo(() => val() * 2)
234
- expect(doubled()).toBe(6)
235
- setVal(10)
236
- expect(doubled()).toBe(20)
289
+ test("setter does nothing when value is the same (Object.is)", () => {
290
+ const runner = createHookRunner()
291
+ let rerenders = 0
292
+ runner.ctx.scheduleRerender = () => {
293
+ rerenders++
294
+ }
295
+ const [, setCount] = runner.run(() => useState(0))
296
+ setCount(0)
297
+ expect(rerenders).toBe(0)
298
+ setCount(1)
299
+ expect(rerenders).toBe(1)
237
300
  })
238
301
 
239
- test("useCallback returns same function", () => {
240
- const fn = () => 42
241
- expect(useCallback(fn)).toBe(fn)
242
- })
302
+ test("re-render in a component via compat JSX runtime", async () => {
303
+ const el = container()
304
+ let renderCount = 0
305
+ let triggerSet: (v: number | ((p: number) => number)) => void = () => {}
306
+
307
+ const Counter = () => {
308
+ const [count, setCount] = useState(0)
309
+ renderCount++
310
+ triggerSet = setCount
311
+ return pyreonH("span", null, String(count))
312
+ }
243
313
 
244
- test("useCallback with deps returns same function", () => {
245
- const fn = (x: unknown) => x
246
- expect(useCallback(fn, [1, 2])).toBe(fn)
247
- })
314
+ const vnode = jsx(Counter, {})
315
+ mount(vnode, el)
316
+ expect(el.textContent).toBe("0")
317
+ const initialRenders = renderCount
248
318
 
249
- test("useRef returns { current } with initial value", () => {
250
- const ref = useRef(42)
251
- expect(ref.current).toBe(42)
319
+ triggerSet(1)
320
+ await new Promise<void>((r) => queueMicrotask(r))
321
+ await new Promise<void>((r) => queueMicrotask(r))
322
+ expect(el.textContent).toBe("1")
323
+ expect(renderCount).toBe(initialRenders + 1)
252
324
  })
325
+ })
253
326
 
254
- test("useRef returns { current: null } without initial", () => {
255
- const emptyRef = useRef()
256
- expect(emptyRef.current).toBe(null)
257
- })
327
+ // ─── useReducer ───────────────────────────────────────────────────────────────
258
328
 
259
- test("useReducer dispatches actions", () => {
329
+ describe("useReducer", () => {
330
+ test("dispatch applies reducer", () => {
331
+ const runner = createHookRunner()
260
332
  type Action = { type: "inc" } | { type: "dec" }
261
- const reducer = (s: number, a: Action) => (a.type === "inc" ? s + 1 : s - 1)
262
- const [state, dispatch] = useReducer(reducer, 0)
263
- expect(state()).toBe(0)
333
+ const reducer = (state: number, action: Action) =>
334
+ action.type === "inc" ? state + 1 : state - 1
335
+
336
+ const [state0, dispatch] = runner.run(() => useReducer(reducer, 0))
337
+ expect(state0).toBe(0)
338
+
264
339
  dispatch({ type: "inc" })
265
- expect(state()).toBe(1)
340
+ const [state1] = runner.run(() => useReducer(reducer, 0))
341
+ expect(state1).toBe(1)
342
+
266
343
  dispatch({ type: "dec" })
267
- expect(state()).toBe(0)
344
+ const [state2] = runner.run(() => useReducer(reducer, 0))
345
+ expect(state2).toBe(0)
268
346
  })
269
347
 
270
- test("useReducer with initializer function", () => {
348
+ test("initializer function is called once", () => {
271
349
  let calls = 0
272
- const [state] = useReducer(
273
- (s: number) => s,
274
- () => {
275
- calls++
276
- return 99
277
- },
350
+ const runner = createHookRunner()
351
+ const [state] = runner.run(() =>
352
+ useReducer(
353
+ (s: number) => s,
354
+ () => {
355
+ calls++
356
+ return 99
357
+ },
358
+ ),
359
+ )
360
+ expect(state).toBe(99)
361
+ expect(calls).toBe(1)
362
+ runner.run(() =>
363
+ useReducer(
364
+ (s: number) => s,
365
+ () => {
366
+ calls++
367
+ return 99
368
+ },
369
+ ),
278
370
  )
279
- expect(state()).toBe(99)
280
371
  expect(calls).toBe(1)
281
372
  })
282
373
 
283
- test("useLayoutEffect is same as useEffect", () => {
284
- expect(useLayoutEffect).toBe(useEffect)
374
+ test("dispatch does nothing when reducer returns same state", () => {
375
+ const runner = createHookRunner()
376
+ let rerenders = 0
377
+ runner.ctx.scheduleRerender = () => {
378
+ rerenders++
379
+ }
380
+ const [, dispatch] = runner.run(() => useReducer((_s: number, _a: string) => 5, 5))
381
+ dispatch("anything")
382
+ expect(rerenders).toBe(0)
285
383
  })
384
+ })
286
385
 
287
- test("useEffect with empty deps runs once on mount", () => {
386
+ // ─── useEffect ────────────────────────────────────────────────────────────────
387
+
388
+ describe("useEffect", () => {
389
+ test("effect runs after render via compat JSX runtime", async () => {
288
390
  const el = container()
289
- const s = pyreonSignal(0)
290
- let runs = 0
391
+ let effectRuns = 0
291
392
 
292
393
  const Comp = () => {
293
394
  useEffect(() => {
294
- s()
295
- runs++
296
- }, [])
395
+ effectRuns++
396
+ })
297
397
  return pyreonH("div", null, "test")
298
398
  }
299
399
 
300
- mount(pyreonH(Comp, null), el)
301
- expect(runs).toBe(1)
302
- s.set(1)
303
- expect(runs).toBe(1) // should not re-run
400
+ mount(jsx(Comp, {}), el)
401
+ await new Promise<void>((r) => queueMicrotask(r))
402
+ expect(effectRuns).toBeGreaterThanOrEqual(1)
304
403
  })
305
404
 
306
- test("useEffect with empty deps and cleanup", () => {
405
+ test("effect with empty deps runs once", async () => {
307
406
  const el = container()
308
- let cleaned = false
407
+ let effectRuns = 0
408
+ let triggerSet: (v: number) => void = () => {}
309
409
 
310
410
  const Comp = () => {
411
+ const [count, setCount] = useState(0)
412
+ triggerSet = setCount
311
413
  useEffect(() => {
312
- return () => {
313
- cleaned = true
314
- }
414
+ effectRuns++
315
415
  }, [])
316
- return pyreonH("div", null, "cleanup-test")
416
+ return pyreonH("div", null, String(count))
317
417
  }
318
418
 
319
- const unmount = mount(pyreonH(Comp, null), el)
320
- expect(cleaned).toBe(false)
321
- unmount()
322
- // onUnmount called inside onMount callback is a no-op (hooks context
323
- // is not active during mount-hook execution), so cleanup does not fire.
324
- expect(cleaned).toBe(false)
419
+ mount(jsx(Comp, {}), el)
420
+ await new Promise<void>((r) => queueMicrotask(r))
421
+ expect(effectRuns).toBe(1)
422
+
423
+ triggerSet(1)
424
+ await new Promise<void>((r) => queueMicrotask(r))
425
+ await new Promise<void>((r) => queueMicrotask(r))
426
+ expect(effectRuns).toBe(1)
325
427
  })
326
428
 
327
- test("useEffect without deps tracks reactively", () => {
429
+ test("effect with deps re-runs when deps change", async () => {
328
430
  const el = container()
329
- const s = pyreonSignal(0)
330
- let runs = 0
431
+ let effectRuns = 0
432
+ let triggerSet: (v: number | ((p: number) => number)) => void = () => {}
331
433
 
332
434
  const Comp = () => {
435
+ const [count, setCount] = useState(0)
436
+ triggerSet = setCount
333
437
  useEffect(() => {
334
- s()
335
- runs++
336
- })
337
- return pyreonH("div", null, "reactive")
438
+ effectRuns++
439
+ }, [count])
440
+ return pyreonH("div", null, String(count))
338
441
  }
339
442
 
340
- const unmount = mount(pyreonH(Comp, null), el)
341
- expect(runs).toBe(1)
342
- s.set(1)
343
- expect(runs).toBe(2)
344
- unmount()
345
- s.set(2)
346
- expect(runs).toBe(2) // disposed
443
+ mount(jsx(Comp, {}), el)
444
+ await new Promise<void>((r) => queueMicrotask(r))
445
+ expect(effectRuns).toBe(1)
446
+
447
+ triggerSet((p) => p + 1)
448
+ await new Promise<void>((r) => queueMicrotask(r))
449
+ await new Promise<void>((r) => queueMicrotask(r))
450
+ await new Promise<void>((r) => queueMicrotask(r))
451
+ expect(effectRuns).toBe(2)
347
452
  })
348
453
 
349
- test("useEffect with cleanup disposes on unmount", () => {
454
+ test("effect cleanup runs before re-execution", async () => {
350
455
  const el = container()
351
- const s = pyreonSignal(0)
352
- let cleaned = false
456
+ let cleanups = 0
457
+ let triggerSet: (v: number | ((p: number) => number)) => void = () => {}
353
458
 
354
459
  const Comp = () => {
460
+ const [count, setCount] = useState(0)
461
+ triggerSet = setCount
355
462
  useEffect(() => {
356
- s()
357
463
  return () => {
358
- cleaned = true
464
+ cleanups++
359
465
  }
360
- })
361
- return pyreonH("div", null, "cleanup")
466
+ }, [count])
467
+ return pyreonH("div", null, String(count))
362
468
  }
363
469
 
364
- const unmount = mount(pyreonH(Comp, null), el)
365
- expect(cleaned).toBe(false)
366
- unmount()
367
- // effect() now supports cleanup return values — cleanup fires on dispose
368
- expect(cleaned).toBe(true)
470
+ mount(jsx(Comp, {}), el)
471
+ await new Promise<void>((r) => queueMicrotask(r))
472
+ expect(cleanups).toBe(0)
473
+
474
+ triggerSet((p) => p + 1)
475
+ await new Promise<void>((r) => queueMicrotask(r))
476
+ await new Promise<void>((r) => queueMicrotask(r))
477
+ await new Promise<void>((r) => queueMicrotask(r))
478
+ expect(cleanups).toBe(1)
479
+ })
480
+
481
+ test("pendingEffects populated during render", () => {
482
+ const runner = createHookRunner()
483
+ runner.run(() => {
484
+ useEffect(() => {})
485
+ })
486
+ expect(runner.ctx.pendingEffects).toHaveLength(1)
369
487
  })
370
488
 
371
- test("useEffect without deps and non-function return", () => {
489
+ test("effect with same deps does not re-queue", () => {
490
+ const runner = createHookRunner()
491
+ runner.run(() => {
492
+ useEffect(() => {}, [1, 2])
493
+ })
494
+ expect(runner.ctx.pendingEffects).toHaveLength(1)
495
+
496
+ runner.run(() => {
497
+ useEffect(() => {}, [1, 2])
498
+ })
499
+ expect(runner.ctx.pendingEffects).toHaveLength(0)
500
+ })
501
+ })
502
+
503
+ // ─── useLayoutEffect ─────────────────────────────────────────────────────────
504
+
505
+ describe("useLayoutEffect", () => {
506
+ test("layout effect runs synchronously during render in compat runtime", () => {
372
507
  const el = container()
373
- const s = pyreonSignal(0)
374
- let runs = 0
508
+ let effectRuns = 0
375
509
 
376
510
  const Comp = () => {
377
- useEffect(() => {
378
- s()
379
- runs++
380
- // no return
511
+ useLayoutEffect(() => {
512
+ effectRuns++
381
513
  })
382
- return pyreonH("div", null, "no-return")
514
+ return pyreonH("div", null, "layout")
383
515
  }
384
516
 
385
- const unmount = mount(pyreonH(Comp, null), el)
386
- expect(runs).toBe(1)
387
- s.set(1)
388
- expect(runs).toBe(2)
389
- unmount()
517
+ mount(jsx(Comp, {}), el)
518
+ expect(effectRuns).toBeGreaterThanOrEqual(1)
519
+ })
520
+
521
+ test("pendingLayoutEffects populated during render", () => {
522
+ const runner = createHookRunner()
523
+ runner.run(() => {
524
+ useLayoutEffect(() => {})
525
+ })
526
+ expect(runner.ctx.pendingLayoutEffects).toHaveLength(1)
527
+ })
528
+
529
+ test("layout effect with same deps does not re-queue", () => {
530
+ const runner = createHookRunner()
531
+ runner.run(() => {
532
+ useLayoutEffect(() => {}, [1])
533
+ })
534
+ expect(runner.ctx.pendingLayoutEffects).toHaveLength(1)
535
+
536
+ runner.run(() => {
537
+ useLayoutEffect(() => {}, [1])
538
+ })
539
+ expect(runner.ctx.pendingLayoutEffects).toHaveLength(0)
540
+ })
541
+ })
542
+
543
+ // ─── useMemo ──────────────────────────────────────────────────────────────────
544
+
545
+ describe("useMemo", () => {
546
+ test("returns computed value", () => {
547
+ const value = withHookCtx(() => useMemo(() => 3 * 2, []))
548
+ expect(value).toBe(6)
549
+ })
550
+
551
+ test("recomputes when deps change", () => {
552
+ const runner = createHookRunner()
553
+ const v1 = runner.run(() => useMemo(() => 10, [1]))
554
+ expect(v1).toBe(10)
555
+
556
+ const v2 = runner.run(() => useMemo(() => 20, [1]))
557
+ expect(v2).toBe(10)
558
+
559
+ const v3 = runner.run(() => useMemo(() => 30, [2]))
560
+ expect(v3).toBe(30)
390
561
  })
562
+ })
391
563
 
392
- test("useId returns unique strings", () => {
393
- const id1 = useId()
394
- const id2 = useId()
395
- expect(typeof id1).toBe("string")
396
- expect(typeof id2).toBe("string")
397
- expect(id1.startsWith(":r")).toBe(true)
398
- expect(id2.startsWith(":r")).toBe(true)
564
+ // ─── useCallback ──────────────────────────────────────────────────────────────
565
+
566
+ describe("useCallback", () => {
567
+ test("returns the same function when deps unchanged", () => {
568
+ const runner = createHookRunner()
569
+ const fn1 = () => 42
570
+ const fn2 = () => 99
571
+ const result1 = runner.run(() => useCallback(fn1, [1]))
572
+ const result2 = runner.run(() => useCallback(fn2, [1]))
573
+ expect(result1).toBe(result2)
574
+ expect(result1()).toBe(42)
575
+ })
576
+
577
+ test("returns new function when deps change", () => {
578
+ const runner = createHookRunner()
579
+ const fn1 = () => 42
580
+ const fn2 = () => 99
581
+ const result1 = runner.run(() => useCallback(fn1, [1]))
582
+ const result2 = runner.run(() => useCallback(fn2, [2]))
583
+ expect(result2).toBe(fn2)
584
+ expect(result2()).toBe(99)
585
+ expect(result1).not.toBe(result2)
399
586
  })
587
+ })
588
+
589
+ // ─── useRef ───────────────────────────────────────────────────────────────────
400
590
 
401
- test("useId within component scope returns deterministic IDs", () => {
591
+ describe("useRef", () => {
592
+ test("returns { current } with null default", () => {
593
+ const ref = withHookCtx(() => useRef<HTMLDivElement>())
594
+ expect(ref.current).toBeNull()
595
+ })
596
+
597
+ test("returns { current } with initial value", () => {
598
+ const ref = withHookCtx(() => useRef(42))
599
+ expect(ref.current).toBe(42)
600
+ })
601
+
602
+ test("current is mutable", () => {
603
+ const ref = withHookCtx(() => useRef(0))
604
+ ref.current = 10
605
+ expect(ref.current).toBe(10)
606
+ })
607
+
608
+ test("same ref object persists across re-renders", () => {
609
+ const runner = createHookRunner()
610
+ const ref1 = runner.run(() => useRef(0))
611
+ ref1.current = 99
612
+ const ref2 = runner.run(() => useRef(0))
613
+ expect(ref1).toBe(ref2)
614
+ expect(ref2.current).toBe(99)
615
+ })
616
+ })
617
+
618
+ // ─── memo ─────────────────────────────────────────────────────────────────────
619
+
620
+ describe("memo", () => {
621
+ test("skips re-render when props are shallowly equal", () => {
622
+ let renderCount = 0
623
+ const MyComp = (props: { name: string }) => {
624
+ renderCount++
625
+ return pyreonH("span", null, props.name)
626
+ }
627
+ const Memoized = memo(MyComp)
628
+ Memoized({ name: "a" })
629
+ expect(renderCount).toBe(1)
630
+ Memoized({ name: "a" })
631
+ expect(renderCount).toBe(1)
632
+ Memoized({ name: "b" })
633
+ expect(renderCount).toBe(2)
634
+ })
635
+
636
+ test("custom areEqual function", () => {
637
+ let renderCount = 0
638
+ const MyComp = (props: { x: number; y: number }) => {
639
+ renderCount++
640
+ return pyreonH("span", null, String(props.x))
641
+ }
642
+ const Memoized = memo(MyComp, (prev, next) => prev.x === next.x)
643
+ Memoized({ x: 1, y: 1 })
644
+ expect(renderCount).toBe(1)
645
+ Memoized({ x: 1, y: 999 })
646
+ expect(renderCount).toBe(1)
647
+ Memoized({ x: 2, y: 999 })
648
+ expect(renderCount).toBe(2)
649
+ })
650
+
651
+ test("different number of keys triggers re-render", () => {
652
+ let renderCount = 0
653
+ const MyComp = (_props: Record<string, unknown>) => {
654
+ renderCount++
655
+ return pyreonH("span", null, "x")
656
+ }
657
+ const Memoized = memo(MyComp)
658
+ Memoized({ a: 1 })
659
+ expect(renderCount).toBe(1)
660
+ Memoized({ a: 1, b: 2 })
661
+ expect(renderCount).toBe(2)
662
+ })
663
+ })
664
+
665
+ // ─── useId ────────────────────────────────────────────────────────────────────
666
+
667
+ describe("useId", () => {
668
+ test("returns a unique string within a component", () => {
402
669
  const el = container()
403
670
  const ids: string[] = []
404
671
 
@@ -408,19 +675,51 @@ describe("@pyreon/preact-compat", () => {
408
675
  return pyreonH("div", null, "id-test")
409
676
  }
410
677
 
411
- const unmount = mount(pyreonH(Comp, null), el)
412
- expect(ids).toHaveLength(2)
413
- expect(ids[0]).toBe(":r0:")
414
- expect(ids[1]).toBe(":r1:")
415
- unmount()
678
+ mount(jsx(Comp, {}), el)
679
+ expect(ids.length).toBeGreaterThanOrEqual(2)
680
+ expect(ids[0]).not.toBe(ids[1])
681
+ expect(typeof ids[0]).toBe("string")
682
+ expect(ids[0]?.startsWith(":r")).toBe(true)
416
683
  })
417
684
 
418
- test("useErrorBoundary is exported", () => {
685
+ test("IDs are stable across re-renders", async () => {
686
+ const el = container()
687
+ const idHistory: string[] = []
688
+ let triggerSet: (v: number) => void = () => {}
689
+
690
+ const Comp = () => {
691
+ const [count, setCount] = useState(0)
692
+ triggerSet = setCount
693
+ const id = useId()
694
+ idHistory.push(id)
695
+ return pyreonH("div", null, `${id}-${count}`)
696
+ }
697
+
698
+ mount(jsx(Comp, {}), el)
699
+ const initialCount = idHistory.length
700
+ const firstId = idHistory[0]
701
+
702
+ triggerSet(1)
703
+ await new Promise<void>((r) => queueMicrotask(r))
704
+ await new Promise<void>((r) => queueMicrotask(r))
705
+ expect(idHistory.length).toBeGreaterThan(initialCount)
706
+ for (const id of idHistory) {
707
+ expect(id).toBe(firstId)
708
+ }
709
+ })
710
+ })
711
+
712
+ // ─── useErrorBoundary ────────────────────────────────────────────────────────
713
+
714
+ describe("useErrorBoundary", () => {
715
+ test("is exported as a function", () => {
419
716
  expect(typeof useErrorBoundary).toBe("function")
420
717
  })
718
+ })
421
719
 
422
- // ─── Signals ─────────────────────────────────────────────────────────────
720
+ // ─── Signals ─────────────────────────────────────────────────────────────────
423
721
 
722
+ describe("signals", () => {
424
723
  test("signal() has .value accessor", () => {
425
724
  const count = signal(0)
426
725
  expect(count.value).toBe(0)
@@ -447,7 +746,7 @@ describe("@pyreon/preact-compat", () => {
447
746
  test("effect() tracks signal reads", () => {
448
747
  const count = signal(0)
449
748
  let observed = -1
450
- const dispose = effect(() => {
749
+ const dispose = signalEffect(() => {
451
750
  observed = count.value
452
751
  })
453
752
  expect(observed).toBe(0)
@@ -455,13 +754,13 @@ describe("@pyreon/preact-compat", () => {
455
754
  expect(observed).toBe(7)
456
755
  dispose()
457
756
  count.value = 99
458
- expect(observed).toBe(7) // disposed, should not update
757
+ expect(observed).toBe(7)
459
758
  })
460
759
 
461
760
  test("effect() with cleanup function", () => {
462
761
  const count = signal(0)
463
762
  let cleanups = 0
464
- const dispose = effect(() => {
763
+ const dispose = signalEffect(() => {
465
764
  void count.value
466
765
  return () => {
467
766
  cleanups++
@@ -469,20 +768,17 @@ describe("@pyreon/preact-compat", () => {
469
768
  })
470
769
  expect(cleanups).toBe(0)
471
770
  count.value = 1
472
- // Cleanup runs before re-run
473
771
  expect(cleanups).toBe(1)
474
772
  dispose()
475
- // Cleanup runs on dispose
476
773
  expect(cleanups).toBe(2)
477
774
  })
478
775
 
479
776
  test("effect() with non-function return (no cleanup)", () => {
480
777
  const count = signal(0)
481
778
  let runs = 0
482
- const dispose = effect(() => {
779
+ const dispose = signalEffect(() => {
483
780
  void count.value
484
781
  runs++
485
- // no return
486
782
  })
487
783
  expect(runs).toBe(1)
488
784
  count.value = 1
@@ -494,7 +790,7 @@ describe("@pyreon/preact-compat", () => {
494
790
  const a = signal(1)
495
791
  const b = signal(2)
496
792
  let runs = 0
497
- effect(() => {
793
+ signalEffect(() => {
498
794
  void a.value
499
795
  void b.value
500
796
  runs++
@@ -504,19 +800,131 @@ describe("@pyreon/preact-compat", () => {
504
800
  a.value = 10
505
801
  b.value = 20
506
802
  })
507
- expect(runs).toBe(2) // single batch = single re-run
803
+ expect(runs).toBe(2)
508
804
  })
509
805
 
510
806
  test("signal peek() reads without tracking", () => {
511
807
  const count = signal(0)
512
808
  let observed = -1
513
- const dispose = effect(() => {
809
+ const dispose = signalEffect(() => {
514
810
  observed = count.peek()
515
811
  })
516
812
  expect(observed).toBe(0)
517
813
  count.value = 5
518
- // peek() is untracked, so effect should NOT re-run
519
814
  expect(observed).toBe(0)
520
815
  dispose()
521
816
  })
522
817
  })
818
+
819
+ // ─── jsx-runtime ──────────────────────────────────────────────────────────────
820
+
821
+ describe("jsx-runtime", () => {
822
+ test("jsx with string type creates element VNode", () => {
823
+ const vnode = jsx("div", { children: "hello" })
824
+ expect(vnode.type).toBe("div")
825
+ expect(vnode.children).toContain("hello")
826
+ })
827
+
828
+ test("jsx with key prop", () => {
829
+ const vnode = jsx("div", { children: "x" }, "my-key")
830
+ expect(vnode.props.key).toBe("my-key")
831
+ })
832
+
833
+ test("jsx with component wraps for re-render", () => {
834
+ const MyComp = () => pyreonH("span", null, "hi")
835
+ const vnode = jsx(MyComp, {})
836
+ expect(vnode.type).not.toBe(MyComp)
837
+ expect(typeof vnode.type).toBe("function")
838
+ })
839
+
840
+ test("jsx with Fragment", () => {
841
+ const vnode = jsx(Fragment, {
842
+ children: [pyreonH("span", null, "a"), pyreonH("span", null, "b")],
843
+ })
844
+ expect(vnode.type).toBe(Fragment)
845
+ })
846
+
847
+ test("jsx with single child (not array)", () => {
848
+ const vnode = jsx("div", { children: "text" })
849
+ expect(vnode.children).toHaveLength(1)
850
+ })
851
+
852
+ test("jsx with no children", () => {
853
+ const vnode = jsx("div", {})
854
+ expect(vnode.children).toHaveLength(0)
855
+ })
856
+
857
+ test("jsx component with children in props", () => {
858
+ const MyComp = (props: { children?: string }) => pyreonH("div", null, props.children ?? "")
859
+ const vnode = jsx(MyComp, { children: "child-text" })
860
+ expect(typeof vnode.type).toBe("function")
861
+ })
862
+ })
863
+
864
+ // ─── Hooks outside component ─────────────────────────────────────────────────
865
+
866
+ describe("hooks outside component", () => {
867
+ test("useState throws when called outside render", () => {
868
+ expect(() => useState(0)).toThrow("Hook called outside")
869
+ })
870
+
871
+ test("useEffect throws when called outside render", () => {
872
+ expect(() => useEffect(() => {})).toThrow("Hook called outside")
873
+ })
874
+
875
+ test("useRef throws when called outside render", () => {
876
+ expect(() => useRef(0)).toThrow("Hook called outside")
877
+ })
878
+
879
+ test("useMemo throws when called outside render", () => {
880
+ expect(() => useMemo(() => 0, [])).toThrow("Hook called outside")
881
+ })
882
+
883
+ test("useId throws when called outside render", () => {
884
+ expect(() => useId()).toThrow("Hook called outside")
885
+ })
886
+
887
+ test("useReducer throws when called outside render", () => {
888
+ expect(() => useReducer((s: number) => s, 0)).toThrow("Hook called outside")
889
+ })
890
+ })
891
+
892
+ // ─── Edge cases ──────────────────────────────────────────────────────────────
893
+
894
+ describe("edge cases", () => {
895
+ test("useState with string initial", () => {
896
+ const [val] = withHookCtx(() => useState("hello"))
897
+ expect(val).toBe("hello")
898
+ })
899
+
900
+ test("useReducer with non-function initial", () => {
901
+ const [state] = withHookCtx(() => useReducer((s: string, a: string) => s + a, "start"))
902
+ expect(state).toBe("start")
903
+ })
904
+
905
+ test("depsChanged handles different length arrays", () => {
906
+ const runner = createHookRunner()
907
+ runner.run(() => {
908
+ useEffect(() => {}, [1, 2])
909
+ })
910
+ expect(runner.ctx.pendingEffects).toHaveLength(1)
911
+
912
+ runner.run(() => {
913
+ useEffect(() => {}, [1, 2, 3])
914
+ })
915
+ expect(runner.ctx.pendingEffects).toHaveLength(1)
916
+ })
917
+
918
+ test("depsChanged with undefined deps always re-runs", () => {
919
+ const runner = createHookRunner()
920
+ runner.run(() => {
921
+ useEffect(() => {})
922
+ })
923
+ expect(runner.ctx.pendingEffects).toHaveLength(1)
924
+
925
+ runner.run(() => {
926
+ useEffect(() => {})
927
+ })
928
+ expect(runner.ctx.pendingEffects).toHaveLength(1)
929
+ })
930
+ })