@pyreon/react-compat 0.2.0 → 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.
@@ -7,30 +7,26 @@ import {
7
7
  createContext,
8
8
  createElement,
9
9
  createPortal,
10
- createSelector,
11
10
  ErrorBoundary,
12
11
  Fragment,
13
12
  lazy,
14
13
  memo,
15
- onMount,
16
- onUnmount,
17
- onUpdate,
18
14
  Suspense,
19
15
  useCallback,
20
16
  useContext,
21
17
  useDeferredValue,
22
18
  useEffect,
23
- useErrorBoundary,
24
19
  useId,
25
20
  useImperativeHandle,
26
21
  useLayoutEffect,
27
- useLayoutEffect_,
28
22
  useMemo,
29
23
  useReducer,
30
24
  useRef,
31
25
  useState,
32
26
  useTransition,
33
27
  } from "../index"
28
+ import type { RenderContext } from "../jsx-runtime"
29
+ import { beginRender, endRender, jsx } from "../jsx-runtime"
34
30
 
35
31
  function container(): HTMLElement {
36
32
  const el = document.createElement("div")
@@ -38,47 +34,124 @@ function container(): HTMLElement {
38
34
  return el
39
35
  }
40
36
 
37
+ /** Helper: creates a RenderContext for testing hooks outside of full render cycle */
38
+ function withHookCtx<T>(fn: () => T): T {
39
+ const ctx: RenderContext = {
40
+ hooks: [],
41
+ scheduleRerender: () => {},
42
+ pendingEffects: [],
43
+ pendingLayoutEffects: [],
44
+ unmounted: false,
45
+ }
46
+ beginRender(ctx)
47
+ const result = fn()
48
+ endRender()
49
+ return result
50
+ }
51
+
52
+ /** Re-render helper: calls fn with the same ctx to simulate re-render */
53
+ function createHookRunner() {
54
+ const ctx: RenderContext = {
55
+ hooks: [],
56
+ scheduleRerender: () => {},
57
+ pendingEffects: [],
58
+ pendingLayoutEffects: [],
59
+ unmounted: false,
60
+ }
61
+ return {
62
+ ctx,
63
+ run<T>(fn: () => T): T {
64
+ beginRender(ctx)
65
+ const result = fn()
66
+ endRender()
67
+ return result
68
+ },
69
+ }
70
+ }
71
+
41
72
  // ─── useState ─────────────────────────────────────────────────────────────────
42
73
 
43
74
  describe("useState", () => {
44
- test("returns [getter, setter] — getter reads initial value", () => {
45
- const [count] = useState(0)
46
- expect(count()).toBe(0)
75
+ test("returns [value, setter] — value is the initial value", () => {
76
+ const [count] = withHookCtx(() => useState(0))
77
+ expect(count).toBe(0)
47
78
  })
48
79
 
49
- test("setter updates value", () => {
50
- const [count, setCount] = useState(0)
80
+ test("setter updates value on re-render", () => {
81
+ const runner = createHookRunner()
82
+ const [, setCount] = runner.run(() => useState(0))
51
83
  setCount(5)
52
- expect(count()).toBe(5)
84
+ const [count2] = runner.run(() => useState(0))
85
+ expect(count2).toBe(5)
53
86
  })
54
87
 
55
88
  test("setter with function updater", () => {
56
- const [count, setCount] = useState(10)
89
+ const runner = createHookRunner()
90
+ const [, setCount] = runner.run(() => useState(10))
57
91
  setCount((prev) => prev + 1)
58
- expect(count()).toBe(11)
59
- setCount((prev) => prev * 2)
60
- expect(count()).toBe(22)
92
+ const [count2] = runner.run(() => useState(10))
93
+ expect(count2).toBe(11)
61
94
  })
62
95
 
63
96
  test("initializer function is called once", () => {
64
97
  let calls = 0
65
- const [val] = useState(() => {
66
- calls++
67
- return 42
68
- })
69
- expect(val()).toBe(42)
98
+ const runner = createHookRunner()
99
+ runner.run(() =>
100
+ useState(() => {
101
+ calls++
102
+ return 42
103
+ }),
104
+ )
105
+ expect(calls).toBe(1)
106
+ // Second render — initializer should NOT be called again
107
+ runner.run(() =>
108
+ useState(() => {
109
+ calls++
110
+ return 42
111
+ }),
112
+ )
70
113
  expect(calls).toBe(1)
71
114
  })
72
115
 
73
- test("getter is reactive effect tracks it", () => {
74
- const [count, setCount] = useState(0)
75
- let observed = -1
76
- effect(() => {
77
- observed = count()
78
- })
79
- expect(observed).toBe(0)
80
- setCount(7)
81
- expect(observed).toBe(7)
116
+ test("setter does nothing when value is the same (Object.is)", () => {
117
+ const runner = createHookRunner()
118
+ let rerenders = 0
119
+ runner.ctx.scheduleRerender = () => {
120
+ rerenders++
121
+ }
122
+ const [, setCount] = runner.run(() => useState(0))
123
+ setCount(0) // same value
124
+ expect(rerenders).toBe(0)
125
+ setCount(1) // different value
126
+ expect(rerenders).toBe(1)
127
+ })
128
+
129
+ test("re-render in a component via compat JSX runtime", async () => {
130
+ const el = container()
131
+ let renderCount = 0
132
+ let triggerSet: (v: number | ((p: number) => number)) => void = () => {}
133
+
134
+ const Counter = () => {
135
+ const [count, setCount] = useState(0)
136
+ renderCount++
137
+ triggerSet = setCount
138
+ return h("span", null, String(count))
139
+ }
140
+
141
+ // Use compat jsx() to wrap the component
142
+ const vnode = jsx(Counter, {})
143
+ mount(vnode, el)
144
+ expect(el.textContent).toBe("0")
145
+ // mountChild samples the accessor once (untracked) + effect runs it once = 2 renders
146
+ const initialRenders = renderCount
147
+
148
+ // Trigger state change — should re-render via microtask
149
+ triggerSet(1)
150
+ await new Promise<void>((r) => queueMicrotask(r))
151
+ // Need another microtask for the effect to propagate
152
+ await new Promise<void>((r) => queueMicrotask(r))
153
+ expect(el.textContent).toBe("1")
154
+ expect(renderCount).toBe(initialRenders + 1)
82
155
  })
83
156
  })
84
157
 
@@ -86,216 +159,271 @@ describe("useState", () => {
86
159
 
87
160
  describe("useReducer", () => {
88
161
  test("dispatch applies reducer", () => {
162
+ const runner = createHookRunner()
89
163
  type Action = { type: "inc" } | { type: "dec" }
90
164
  const reducer = (state: number, action: Action) =>
91
165
  action.type === "inc" ? state + 1 : state - 1
92
- const [state, dispatch] = useReducer(reducer, 0)
93
- expect(state()).toBe(0)
94
- dispatch({ type: "inc" })
95
- expect(state()).toBe(1)
166
+
167
+ const [state0, dispatch] = runner.run(() => useReducer(reducer, 0))
168
+ expect(state0).toBe(0)
169
+
96
170
  dispatch({ type: "inc" })
97
- expect(state()).toBe(2)
171
+ const [state1] = runner.run(() => useReducer(reducer, 0))
172
+ expect(state1).toBe(1)
173
+
98
174
  dispatch({ type: "dec" })
99
- expect(state()).toBe(1)
175
+ const [state2] = runner.run(() => useReducer(reducer, 0))
176
+ expect(state2).toBe(0)
100
177
  })
101
178
 
102
179
  test("initializer function is called once", () => {
103
180
  let calls = 0
104
- const [state] = useReducer(
105
- (s: number) => s,
106
- () => {
107
- calls++
108
- return 99
109
- },
181
+ const runner = createHookRunner()
182
+ const [state] = runner.run(() =>
183
+ useReducer(
184
+ (s: number) => s,
185
+ () => {
186
+ calls++
187
+ return 99
188
+ },
189
+ ),
190
+ )
191
+ expect(state).toBe(99)
192
+ expect(calls).toBe(1)
193
+ // Second render
194
+ runner.run(() =>
195
+ useReducer(
196
+ (s: number) => s,
197
+ () => {
198
+ calls++
199
+ return 99
200
+ },
201
+ ),
110
202
  )
111
- expect(state()).toBe(99)
112
203
  expect(calls).toBe(1)
113
204
  })
205
+
206
+ test("dispatch does nothing when reducer returns same state", () => {
207
+ const runner = createHookRunner()
208
+ let rerenders = 0
209
+ runner.ctx.scheduleRerender = () => {
210
+ rerenders++
211
+ }
212
+ const [, dispatch] = runner.run(() => useReducer((_s: number, _a: string) => 5, 5))
213
+ dispatch("anything") // reducer returns 5, same as current
214
+ expect(rerenders).toBe(0)
215
+ })
114
216
  })
115
217
 
116
218
  // ─── useEffect ────────────────────────────────────────────────────────────────
117
219
 
118
220
  describe("useEffect", () => {
119
- test("runs reactively when signals change", () => {
221
+ test("effect runs after render via compat JSX runtime", async () => {
120
222
  const el = container()
121
- const s = signal(0)
122
- let runs = 0
223
+ let effectRuns = 0
123
224
 
124
225
  const Comp = () => {
125
226
  useEffect(() => {
126
- s() // read signal to track
127
- runs++
227
+ effectRuns++
128
228
  })
129
229
  return h("div", null, "test")
130
230
  }
131
231
 
132
- mount(h(Comp, null), el)
133
- expect(runs).toBe(1)
134
- s.set(1)
135
- expect(runs).toBe(2)
136
- s.set(2)
137
- expect(runs).toBe(3)
232
+ mount(jsx(Comp, {}), el)
233
+ // Effects are scheduled via microtask; mountChild samples accessor once + effect runs it
234
+ await new Promise<void>((r) => queueMicrotask(r))
235
+ expect(effectRuns).toBeGreaterThanOrEqual(1)
138
236
  })
139
237
 
140
- test("with empty deps [] — runs once on mount only", () => {
238
+ test("effect with empty deps runs once", async () => {
141
239
  const el = container()
142
- const s = signal(0)
143
- let runs = 0
240
+ let effectRuns = 0
241
+ let triggerSet: (v: number) => void = () => {}
144
242
 
145
243
  const Comp = () => {
244
+ const [count, setCount] = useState(0)
245
+ triggerSet = setCount
146
246
  useEffect(() => {
147
- s()
148
- runs++
247
+ effectRuns++
149
248
  }, [])
150
- return h("div", null, "test")
249
+ return h("div", null, String(count))
151
250
  }
152
251
 
153
- mount(h(Comp, null), el)
154
- expect(runs).toBe(1)
155
- s.set(1)
156
- expect(runs).toBe(1)
252
+ mount(jsx(Comp, {}), el)
253
+ await new Promise<void>((r) => queueMicrotask(r))
254
+ expect(effectRuns).toBe(1)
255
+
256
+ // Re-render — effect should NOT run again (empty deps)
257
+ triggerSet(1)
258
+ await new Promise<void>((r) => queueMicrotask(r))
259
+ await new Promise<void>((r) => queueMicrotask(r))
260
+ expect(effectRuns).toBe(1)
157
261
  })
158
262
 
159
- test("with empty deps [] and cleanup on unmount", () => {
263
+ test("effect with deps re-runs when deps change", async () => {
160
264
  const el = container()
161
- let cleaned = false
265
+ let effectRuns = 0
266
+ let triggerSet: (v: number | ((p: number) => number)) => void = () => {}
162
267
 
163
268
  const Comp = () => {
269
+ const [count, setCount] = useState(0)
270
+ triggerSet = setCount
164
271
  useEffect(() => {
165
- return () => {
166
- cleaned = true
167
- }
168
- }, [])
169
- return h("div", null, "test")
272
+ effectRuns++
273
+ }, [count])
274
+ return h("div", null, String(count))
170
275
  }
171
276
 
172
- const unmount = mount(h(Comp, null), el)
173
- expect(cleaned).toBe(false)
174
- unmount()
175
- // onUnmount called inside onMount callback is a no-op (hooks context
176
- // is not active during mount-hook execution), so cleanup does not fire.
177
- expect(cleaned).toBe(false)
277
+ mount(jsx(Comp, {}), el)
278
+ await new Promise<void>((r) => queueMicrotask(r))
279
+ expect(effectRuns).toBe(1)
280
+
281
+ // Change deps value effect should re-run
282
+ triggerSet((p) => p + 1)
283
+ await new Promise<void>((r) => queueMicrotask(r))
284
+ await new Promise<void>((r) => queueMicrotask(r))
285
+ await new Promise<void>((r) => queueMicrotask(r))
286
+ expect(effectRuns).toBe(2)
178
287
  })
179
288
 
180
- test("with empty deps [] and no cleanup return", () => {
289
+ test("effect cleanup runs before re-execution", async () => {
181
290
  const el = container()
182
- let runs = 0
291
+ let cleanups = 0
292
+ let triggerSet: (v: number | ((p: number) => number)) => void = () => {}
183
293
 
184
294
  const Comp = () => {
295
+ const [count, setCount] = useState(0)
296
+ triggerSet = setCount
185
297
  useEffect(() => {
186
- runs++
187
- // no return
188
- }, [])
189
- return h("div", null, "test")
298
+ return () => {
299
+ cleanups++
300
+ }
301
+ }, [count])
302
+ return h("div", null, String(count))
190
303
  }
191
304
 
192
- const unmount = mount(h(Comp, null), el)
193
- expect(runs).toBe(1)
194
- unmount()
195
- })
305
+ mount(jsx(Comp, {}), el)
306
+ await new Promise<void>((r) => queueMicrotask(r))
307
+ expect(cleanups).toBe(0)
196
308
 
197
- test("effect without deps re-runs and disposes on unmount", () => {
198
- const el = container()
199
- const s = signal(0)
200
- let runs = 0
309
+ triggerSet((p) => p + 1)
310
+ await new Promise<void>((r) => queueMicrotask(r))
311
+ await new Promise<void>((r) => queueMicrotask(r))
312
+ await new Promise<void>((r) => queueMicrotask(r))
313
+ expect(cleanups).toBe(1)
314
+ })
201
315
 
202
- const Comp = () => {
316
+ test("pendingEffects populated during render", () => {
317
+ const runner = createHookRunner()
318
+ runner.run(() => {
203
319
  useEffect(() => {
204
- s()
205
- runs++
320
+ /* noop */
206
321
  })
207
- return h("div", null, "test")
208
- }
322
+ })
323
+ expect(runner.ctx.pendingEffects).toHaveLength(1)
324
+ })
209
325
 
210
- const unmount = mount(h(Comp, null), el)
211
- expect(runs).toBe(1)
212
- s.set(10)
213
- expect(runs).toBe(2)
214
- unmount()
215
- s.set(20)
216
- expect(runs).toBe(2)
326
+ test("effect with same deps does not re-queue", () => {
327
+ const runner = createHookRunner()
328
+ runner.run(() => {
329
+ useEffect(() => {}, [1, 2])
330
+ })
331
+ expect(runner.ctx.pendingEffects).toHaveLength(1)
332
+
333
+ // Second render with same deps
334
+ runner.run(() => {
335
+ useEffect(() => {}, [1, 2])
336
+ })
337
+ expect(runner.ctx.pendingEffects).toHaveLength(0)
217
338
  })
339
+ })
218
340
 
219
- test("effect with cleanup function disposes on unmount", () => {
341
+ // ─── useLayoutEffect ─────────────────────────────────────────────────────────
342
+
343
+ describe("useLayoutEffect", () => {
344
+ test("layout effect runs synchronously during render in compat runtime", () => {
220
345
  const el = container()
221
- const s = signal(0)
222
346
  let effectRuns = 0
223
347
 
224
348
  const Comp = () => {
225
- useEffect(() => {
226
- s()
349
+ useLayoutEffect(() => {
227
350
  effectRuns++
228
- return () => {
229
- /* cleanup */
230
- }
231
351
  })
232
- return h("div", null, "cleanup")
352
+ return h("div", null, "layout")
233
353
  }
234
354
 
235
- const unmount = mount(h(Comp, null), el)
236
- expect(effectRuns).toBe(1)
237
- s.set(1)
238
- expect(effectRuns).toBe(2)
239
- unmount()
240
- s.set(2)
241
- expect(effectRuns).toBe(2)
355
+ mount(jsx(Comp, {}), el)
356
+ // Layout effects run synchronously; mountChild samples + effect = 2 runs
357
+ expect(effectRuns).toBeGreaterThanOrEqual(1)
242
358
  })
243
359
 
244
- test("non-function return from effect is handled", () => {
245
- const el = container()
246
- const s = signal(0)
247
- let runs = 0
360
+ test("pendingLayoutEffects populated during render", () => {
361
+ const runner = createHookRunner()
362
+ runner.run(() => {
363
+ useLayoutEffect(() => {})
364
+ })
365
+ expect(runner.ctx.pendingLayoutEffects).toHaveLength(1)
366
+ })
248
367
 
249
- const Comp = () => {
250
- useEffect(() => {
251
- s()
252
- runs++
253
- })
254
- return h("div", null, "no-cleanup")
255
- }
368
+ test("layout effect with same deps does not re-queue", () => {
369
+ const runner = createHookRunner()
370
+ runner.run(() => {
371
+ useLayoutEffect(() => {}, [1])
372
+ })
373
+ expect(runner.ctx.pendingLayoutEffects).toHaveLength(1)
256
374
 
257
- const unmount = mount(h(Comp, null), el)
258
- expect(runs).toBe(1)
259
- s.set(1)
260
- expect(runs).toBe(2)
261
- unmount()
375
+ runner.run(() => {
376
+ useLayoutEffect(() => {}, [1])
377
+ })
378
+ expect(runner.ctx.pendingLayoutEffects).toHaveLength(0)
262
379
  })
263
380
  })
264
381
 
265
382
  // ─── useMemo ──────────────────────────────────────────────────────────────────
266
383
 
267
384
  describe("useMemo", () => {
268
- test("returns computed getter", () => {
269
- const s = signal(3)
270
- const doubled = useMemo(() => s() * 2)
271
- expect(doubled()).toBe(6)
272
- s.set(5)
273
- expect(doubled()).toBe(10)
385
+ test("returns computed value", () => {
386
+ const value = withHookCtx(() => useMemo(() => 3 * 2, []))
387
+ expect(value).toBe(6)
274
388
  })
275
389
 
276
- test("deps array is ignored — still auto-tracks", () => {
277
- const s = signal(1)
278
- const memo = useMemo(() => s() + 100, [])
279
- expect(memo()).toBe(101)
280
- s.set(2)
281
- expect(memo()).toBe(102)
390
+ test("recomputes when deps change", () => {
391
+ const runner = createHookRunner()
392
+ const v1 = runner.run(() => useMemo(() => 10, [1]))
393
+ expect(v1).toBe(10)
394
+
395
+ // Same deps — should return cached
396
+ const v2 = runner.run(() => useMemo(() => 20, [1]))
397
+ expect(v2).toBe(10)
398
+
399
+ // Different deps — should recompute
400
+ const v3 = runner.run(() => useMemo(() => 30, [2]))
401
+ expect(v3).toBe(30)
282
402
  })
283
403
  })
284
404
 
285
405
  // ─── useCallback ──────────────────────────────────────────────────────────────
286
406
 
287
407
  describe("useCallback", () => {
288
- test("returns the same function", () => {
289
- const fn = () => 42
290
- const result = useCallback(fn)
291
- expect(result).toBe(fn)
292
- expect(result()).toBe(42)
293
- })
294
-
295
- test("with deps array — still returns same function", () => {
296
- const fn = (x: unknown) => x
297
- const result = useCallback(fn, [1, 2, 3])
298
- expect(result).toBe(fn)
408
+ test("returns the same function when deps unchanged", () => {
409
+ const runner = createHookRunner()
410
+ const fn1 = () => 42
411
+ const fn2 = () => 99
412
+ const result1 = runner.run(() => useCallback(fn1, [1]))
413
+ const result2 = runner.run(() => useCallback(fn2, [1]))
414
+ expect(result1).toBe(result2) // same deps → cached
415
+ expect(result1()).toBe(42)
416
+ })
417
+
418
+ test("returns new function when deps change", () => {
419
+ const runner = createHookRunner()
420
+ const fn1 = () => 42
421
+ const fn2 = () => 99
422
+ const result1 = runner.run(() => useCallback(fn1, [1]))
423
+ const result2 = runner.run(() => useCallback(fn2, [2]))
424
+ expect(result2).toBe(fn2)
425
+ expect(result2()).toBe(99)
426
+ expect(result1).not.toBe(result2)
299
427
  })
300
428
  })
301
429
 
@@ -303,29 +431,76 @@ describe("useCallback", () => {
303
431
 
304
432
  describe("useRef", () => {
305
433
  test("returns { current } with null default", () => {
306
- const ref = useRef<HTMLDivElement>()
434
+ const ref = withHookCtx(() => useRef<HTMLDivElement>())
307
435
  expect(ref.current).toBeNull()
308
436
  })
309
437
 
310
438
  test("returns { current } with initial value", () => {
311
- const ref = useRef(42)
439
+ const ref = withHookCtx(() => useRef(42))
312
440
  expect(ref.current).toBe(42)
313
441
  })
314
442
 
315
443
  test("current is mutable", () => {
316
- const ref = useRef(0)
444
+ const ref = withHookCtx(() => useRef(0))
317
445
  ref.current = 10
318
446
  expect(ref.current).toBe(10)
319
447
  })
448
+
449
+ test("same ref object persists across re-renders", () => {
450
+ const runner = createHookRunner()
451
+ const ref1 = runner.run(() => useRef(0))
452
+ ref1.current = 99
453
+ const ref2 = runner.run(() => useRef(0))
454
+ expect(ref1).toBe(ref2)
455
+ expect(ref2.current).toBe(99)
456
+ })
320
457
  })
321
458
 
322
459
  // ─── memo ─────────────────────────────────────────────────────────────────────
323
460
 
324
461
  describe("memo", () => {
325
- test("returns component as-is (no-op)", () => {
326
- const MyComp = (props: { name: string }) => h("span", null, props.name)
462
+ test("skips re-render when props are shallowly equal", () => {
463
+ let renderCount = 0
464
+ const MyComp = (props: { name: string }) => {
465
+ renderCount++
466
+ return h("span", null, props.name)
467
+ }
468
+ const Memoized = memo(MyComp)
469
+ Memoized({ name: "a" })
470
+ expect(renderCount).toBe(1)
471
+ Memoized({ name: "a" })
472
+ expect(renderCount).toBe(1) // same props — skipped
473
+ Memoized({ name: "b" })
474
+ expect(renderCount).toBe(2) // different props — re-rendered
475
+ })
476
+
477
+ test("custom areEqual function", () => {
478
+ let renderCount = 0
479
+ const MyComp = (props: { x: number; y: number }) => {
480
+ renderCount++
481
+ return h("span", null, String(props.x))
482
+ }
483
+ // Only compare x, ignore y
484
+ const Memoized = memo(MyComp, (prev, next) => prev.x === next.x)
485
+ Memoized({ x: 1, y: 1 })
486
+ expect(renderCount).toBe(1)
487
+ Memoized({ x: 1, y: 999 })
488
+ expect(renderCount).toBe(1) // y changed but x same → skipped
489
+ Memoized({ x: 2, y: 999 })
490
+ expect(renderCount).toBe(2) // x changed → re-rendered
491
+ })
492
+
493
+ test("different number of keys triggers re-render", () => {
494
+ let renderCount = 0
495
+ const MyComp = (_props: Record<string, unknown>) => {
496
+ renderCount++
497
+ return h("span", null, "x")
498
+ }
327
499
  const Memoized = memo(MyComp)
328
- expect(Memoized).toBe(MyComp)
500
+ Memoized({ a: 1 })
501
+ expect(renderCount).toBe(1)
502
+ Memoized({ a: 1, b: 2 })
503
+ expect(renderCount).toBe(2)
329
504
  })
330
505
  })
331
506
 
@@ -357,16 +532,7 @@ describe("useDeferredValue", () => {
357
532
  // ─── useId ────────────────────────────────────────────────────────────────────
358
533
 
359
534
  describe("useId", () => {
360
- test("returns a unique string", () => {
361
- const id1 = useId()
362
- const id2 = useId()
363
- expect(typeof id1).toBe("string")
364
- expect(typeof id2).toBe("string")
365
- expect(id1.startsWith(":r")).toBe(true)
366
- expect(id2.startsWith(":r")).toBe(true)
367
- })
368
-
369
- test("returns deterministic IDs within a component", () => {
535
+ test("returns a unique string within a component", () => {
370
536
  const el = container()
371
537
  const ids: string[] = []
372
538
 
@@ -376,31 +542,40 @@ describe("useId", () => {
376
542
  return h("div", null, "id-test")
377
543
  }
378
544
 
379
- const unmount = mount(h(Comp, null), el)
380
- expect(ids).toHaveLength(2)
381
- expect(ids[0]).toBe(":r0:")
382
- expect(ids[1]).toBe(":r1:")
383
- unmount()
545
+ mount(jsx(Comp, {}), el)
546
+ // mountChild samples the accessor + effect runs it = 2 renders, 4 IDs pushed
547
+ expect(ids.length).toBeGreaterThanOrEqual(2)
548
+ // Within a single render, two useId calls produce different IDs
549
+ expect(ids[0]).not.toBe(ids[1])
550
+ expect(typeof ids[0]).toBe("string")
551
+ expect(ids[0]?.startsWith(":r")).toBe(true)
384
552
  })
385
553
 
386
- test("different components get independent counters", () => {
554
+ test("IDs are stable across re-renders", async () => {
387
555
  const el = container()
388
- const ids1: string[] = []
389
- const ids2: string[] = []
556
+ const idHistory: string[] = []
557
+ let triggerSet: (v: number) => void = () => {}
390
558
 
391
- const Comp1 = () => {
392
- ids1.push(useId())
393
- return h("div", null, "c1")
394
- }
395
- const Comp2 = () => {
396
- ids2.push(useId())
397
- return h("div", null, "c2")
559
+ const Comp = () => {
560
+ const [count, setCount] = useState(0)
561
+ triggerSet = setCount
562
+ const id = useId()
563
+ idHistory.push(id)
564
+ return h("div", null, `${id}-${count}`)
398
565
  }
399
566
 
400
- const unmount = mount(h("div", null, h(Comp1, null), h(Comp2, null)), el)
401
- expect(ids1[0]).toBe(":r0:")
402
- expect(ids2[0]).toBe(":r0:")
403
- unmount()
567
+ mount(jsx(Comp, {}), el)
568
+ const initialCount = idHistory.length // mountChild samples + effect = 2 renders
569
+ const firstId = idHistory[0]
570
+
571
+ triggerSet(1)
572
+ await new Promise<void>((r) => queueMicrotask(r))
573
+ await new Promise<void>((r) => queueMicrotask(r))
574
+ expect(idHistory.length).toBeGreaterThan(initialCount)
575
+ // All IDs should be the same (stable across renders)
576
+ for (const id of idHistory) {
577
+ expect(id).toBe(firstId)
578
+ }
404
579
  })
405
580
  })
406
581
 
@@ -511,7 +686,7 @@ describe("render", () => {
511
686
  // ─── useImperativeHandle ─────────────────────────────────────────────────────
512
687
 
513
688
  describe("useImperativeHandle", () => {
514
- test("sets ref.current on mount", () => {
689
+ test("sets ref.current via layout effect", () => {
515
690
  const el = container()
516
691
  const ref = { current: null as { greet: () => string } | null }
517
692
 
@@ -522,26 +697,9 @@ describe("useImperativeHandle", () => {
522
697
  return h("div", null, "imp")
523
698
  }
524
699
 
525
- const unmount = mount(h(Comp, null), el)
700
+ mount(jsx(Comp, {}), el)
526
701
  expect(ref.current).not.toBeNull()
527
702
  expect(ref.current?.greet()).toBe("hello")
528
- unmount()
529
- })
530
-
531
- test("clears ref.current on unmount", () => {
532
- const el = container()
533
- const ref = { current: null as { value: number } | null }
534
-
535
- const Comp = () => {
536
- useImperativeHandle(ref, () => ({ value: 42 }))
537
- return h("div", null, "imp")
538
- }
539
-
540
- const unmount = mount(h(Comp, null), el)
541
- expect(ref.current).not.toBeNull()
542
- expect(ref.current?.value).toBe(42)
543
- unmount()
544
- expect(ref.current).toBeNull()
545
703
  })
546
704
 
547
705
  test("no-op when ref is null", () => {
@@ -552,8 +710,7 @@ describe("useImperativeHandle", () => {
552
710
  return h("div", null, "no-ref")
553
711
  }
554
712
 
555
- const unmount = mount(h(Comp, null), el)
556
- unmount()
713
+ mount(jsx(Comp, {}), el)
557
714
  })
558
715
 
559
716
  test("no-op when ref is undefined", () => {
@@ -564,8 +721,7 @@ describe("useImperativeHandle", () => {
564
721
  return h("div", null, "undef-ref")
565
722
  }
566
723
 
567
- const unmount = mount(h(Comp, null), el)
568
- unmount()
724
+ mount(jsx(Comp, {}), el)
569
725
  })
570
726
  })
571
727
 
@@ -598,111 +754,122 @@ describe("re-exports", () => {
598
754
  expect(typeof ErrorBoundary).toBe("function")
599
755
  })
600
756
 
601
- test("useErrorBoundary is a function", () => {
602
- expect(typeof useErrorBoundary).toBe("function")
757
+ test("useLayoutEffect is a function", () => {
758
+ expect(typeof useLayoutEffect).toBe("function")
603
759
  })
760
+ })
604
761
 
605
- test("createSelector is a function", () => {
606
- expect(typeof createSelector).toBe("function")
607
- })
762
+ // ─── jsx-runtime ──────────────────────────────────────────────────────────────
608
763
 
609
- test("onMount is a function", () => {
610
- expect(typeof onMount).toBe("function")
764
+ describe("jsx-runtime", () => {
765
+ test("jsx with string type creates element VNode", () => {
766
+ const vnode = jsx("div", { className: "test", children: "hello" })
767
+ // className should be mapped to class
768
+ expect(vnode.props.class).toBe("test")
769
+ expect(vnode.props.className).toBeUndefined()
611
770
  })
612
771
 
613
- test("onUnmount is a function", () => {
614
- expect(typeof onUnmount).toBe("function")
772
+ test("jsx with key prop", () => {
773
+ const vnode = jsx("div", { children: "x" }, "my-key")
774
+ expect(vnode.props.key).toBe("my-key")
615
775
  })
616
776
 
617
- test("onUpdate is a function", () => {
618
- expect(typeof onUpdate).toBe("function")
777
+ test("jsx with component wraps for re-render", () => {
778
+ const MyComp = () => h("span", null, "hi")
779
+ const vnode = jsx(MyComp, {})
780
+ // The component should be wrapped (different from original)
781
+ expect(vnode.type).not.toBe(MyComp)
782
+ expect(typeof vnode.type).toBe("function")
619
783
  })
620
784
 
621
- test("useLayoutEffect is exported from core", () => {
622
- expect(typeof useLayoutEffect).toBe("function")
785
+ test("jsx with Fragment", () => {
786
+ const vnode = jsx(Fragment, { children: [h("span", null, "a"), h("span", null, "b")] })
787
+ expect(vnode.type).toBe(Fragment)
623
788
  })
624
789
 
625
- test("useLayoutEffect_ is same as useEffect", () => {
626
- expect(useLayoutEffect_).toBe(useEffect)
790
+ test("jsx with single child (not array)", () => {
791
+ const vnode = jsx("div", { children: "text" })
792
+ expect(vnode.children).toHaveLength(1)
627
793
  })
628
- })
629
794
 
630
- // ─── Additional coverage: useId outside scope ──────────────────────────────
795
+ test("jsx with no children", () => {
796
+ const vnode = jsx("div", {})
797
+ expect(vnode.children).toHaveLength(0)
798
+ })
631
799
 
632
- describe("useId no scope fallback", () => {
633
- test("returns random-ish id when called outside a component (no scope)", () => {
634
- // getCurrentScope() returns null outside a component, hitting the fallback branch
635
- const id = useId()
636
- expect(typeof id).toBe("string")
637
- expect(id.startsWith(":r")).toBe(true)
638
- expect(id.endsWith(":")).toBe(true)
639
- // Two calls should produce different IDs (random)
640
- const id2 = useId()
641
- expect(id).not.toBe(id2)
800
+ test("jsx component with children in props", () => {
801
+ const MyComp = (props: { children?: string }) => h("div", null, props.children ?? "")
802
+ const vnode = jsx(MyComp, { children: "child-text" })
803
+ expect(typeof vnode.type).toBe("function")
642
804
  })
643
805
  })
644
806
 
645
- // ─── Additional coverage: useEffect with non-empty deps ─────────────────────
807
+ // ─── Hook error when called outside component ────────────────────────────────
646
808
 
647
- describe("useEffect non-empty deps array", () => {
648
- test("with non-empty deps array, still runs reactively (deps ignored)", () => {
649
- const el = container()
650
- const s = signal(0)
651
- let runs = 0
809
+ describe("hooks outside component", () => {
810
+ test("useState throws when called outside render", () => {
811
+ expect(() => useState(0)).toThrow("Hook called outside")
812
+ })
652
813
 
653
- const Comp = () => {
654
- useEffect(() => {
655
- s()
656
- runs++
657
- }, [s])
658
- return h("div", null, "non-empty-deps")
659
- }
814
+ test("useEffect throws when called outside render", () => {
815
+ expect(() => useEffect(() => {})).toThrow("Hook called outside")
816
+ })
660
817
 
661
- const unmount = mount(h(Comp, null), el)
662
- expect(runs).toBe(1)
663
- s.set(1)
664
- expect(runs).toBe(2)
665
- unmount()
818
+ test("useRef throws when called outside render", () => {
819
+ expect(() => useRef(0)).toThrow("Hook called outside")
666
820
  })
667
821
 
668
- test("with non-empty deps and cleanup, cleanup runs on re-execution", () => {
669
- const el = container()
670
- const s = signal(0)
671
- let cleanups = 0
822
+ test("useMemo throws when called outside render", () => {
823
+ expect(() => useMemo(() => 0, [])).toThrow("Hook called outside")
824
+ })
672
825
 
673
- const Comp = () => {
674
- useEffect(() => {
675
- s()
676
- return () => {
677
- cleanups++
678
- }
679
- }, [s])
680
- return h("div", null, "cleanup-deps")
681
- }
826
+ test("useId throws when called outside render", () => {
827
+ expect(() => useId()).toThrow("Hook called outside")
828
+ })
682
829
 
683
- const unmount = mount(h(Comp, null), el)
684
- expect(cleanups).toBe(0)
685
- s.set(1) // re-runs effect, previous cleanup should fire
686
- expect(cleanups).toBe(1)
687
- unmount()
830
+ test("useReducer throws when called outside render", () => {
831
+ expect(() => useReducer((s: number) => s, 0)).toThrow("Hook called outside")
688
832
  })
689
833
  })
690
834
 
691
- // ─── Additional coverage: useState with non-function initial ─────────────────
835
+ // ─── Edge cases ──────────────────────────────────────────────────────────────
692
836
 
693
- describe("useState — edge cases", () => {
694
- test("with function-valued non-initializer (explicit type)", () => {
695
- // When T is not a function type, direct value is used
696
- const [val] = useState("hello")
697
- expect(val()).toBe("hello")
837
+ describe("edge cases", () => {
838
+ test("useState with string initial", () => {
839
+ const [val] = withHookCtx(() => useState("hello"))
840
+ expect(val).toBe("hello")
698
841
  })
699
- })
700
842
 
701
- // ─── Additional coverage: useReducer with non-function initial ───────────────
843
+ test("useReducer with non-function initial", () => {
844
+ const [state] = withHookCtx(() => useReducer((s: string, a: string) => s + a, "start"))
845
+ expect(state).toBe("start")
846
+ })
847
+
848
+ test("depsChanged handles different length arrays", () => {
849
+ const runner = createHookRunner()
850
+ runner.run(() => {
851
+ useEffect(() => {}, [1, 2])
852
+ })
853
+ expect(runner.ctx.pendingEffects).toHaveLength(1)
702
854
 
703
- describe("useReducer edge cases", () => {
704
- test("with non-function initial value", () => {
705
- const [state] = useReducer((s: string, a: string) => s + a, "start")
706
- expect(state()).toBe("start")
855
+ // Different length deps should re-queue
856
+ runner.run(() => {
857
+ useEffect(() => {}, [1, 2, 3])
858
+ })
859
+ expect(runner.ctx.pendingEffects).toHaveLength(1)
860
+ })
861
+
862
+ test("depsChanged with undefined deps always re-runs", () => {
863
+ const runner = createHookRunner()
864
+ runner.run(() => {
865
+ useEffect(() => {})
866
+ })
867
+ expect(runner.ctx.pendingEffects).toHaveLength(1)
868
+
869
+ // No deps — always re-queue
870
+ runner.run(() => {
871
+ useEffect(() => {})
872
+ })
873
+ expect(runner.ctx.pendingEffects).toHaveLength(1)
707
874
  })
708
875
  })