@pyreon/react-compat 0.13.1 → 0.15.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,1519 @@
1
+ import { createContext as pyreonCreateContext, h } from '@pyreon/core'
2
+ import type { VNodeChild } from '@pyreon/core'
3
+ import { mount } from '@pyreon/runtime-dom'
4
+ import type { RenderContext } from '../jsx-runtime'
5
+ import { beginRender, endRender, jsx } from '../jsx-runtime'
6
+ import {
7
+ act,
8
+ Children,
9
+ cloneElement,
10
+ Component,
11
+ createContext as createCompatContext,
12
+ createRef,
13
+ flushSync,
14
+ forwardRef,
15
+ isValidElement,
16
+ memo,
17
+ Profiler,
18
+ PureComponent,
19
+ startTransition,
20
+ StrictMode,
21
+ use,
22
+ useActionState,
23
+ useContext,
24
+ useDebugValue,
25
+ useEffect,
26
+ useInsertionEffect,
27
+ useLayoutEffect,
28
+ useReducer,
29
+ useState,
30
+ useSyncExternalStore,
31
+ version,
32
+ } from '../index'
33
+ import type {
34
+ ChangeEvent,
35
+ Dispatch,
36
+ FC,
37
+ FocusEvent,
38
+ FormEvent,
39
+ ForwardedRef,
40
+ FunctionComponent,
41
+ HTMLAttributes,
42
+ KeyboardEvent,
43
+ MouseEvent,
44
+ MutableRefObject,
45
+ PropsWithChildren,
46
+ PropsWithRef,
47
+ ReactElement,
48
+ ReactNode,
49
+ RefCallback,
50
+ RefObject,
51
+ SetStateAction,
52
+ SyntheticEvent,
53
+ } from '../index'
54
+
55
+ function container(): HTMLElement {
56
+ const el = document.createElement('div')
57
+ document.body.appendChild(el)
58
+ return el
59
+ }
60
+
61
+ function createHookRunner() {
62
+ const ctx: RenderContext = {
63
+ hooks: [],
64
+ scheduleRerender: () => {},
65
+ pendingInsertionEffects: [],
66
+ pendingEffects: [],
67
+ pendingLayoutEffects: [],
68
+ unmounted: false,
69
+ }
70
+ return {
71
+ ctx,
72
+ run<T>(fn: () => T): T {
73
+ beginRender(ctx)
74
+ const result = fn()
75
+ endRender()
76
+ return result
77
+ },
78
+ }
79
+ }
80
+
81
+ function withHookCtx<T>(fn: () => T): T {
82
+ const ctx: RenderContext = {
83
+ hooks: [],
84
+ scheduleRerender: () => {},
85
+ pendingInsertionEffects: [],
86
+ pendingEffects: [],
87
+ pendingLayoutEffects: [],
88
+ unmounted: false,
89
+ }
90
+ beginRender(ctx)
91
+ const result = fn()
92
+ endRender()
93
+ return result
94
+ }
95
+
96
+ // ─── useSyncExternalStore ──────────────────────────────────────────────────
97
+
98
+ describe('useSyncExternalStore', () => {
99
+ function createStore(initial: number) {
100
+ let value = initial
101
+ const listeners = new Set<() => void>()
102
+ return {
103
+ getSnapshot: () => value,
104
+ subscribe: (cb: () => void) => {
105
+ listeners.add(cb)
106
+ return () => listeners.delete(cb)
107
+ },
108
+ set(next: number) {
109
+ value = next
110
+ for (const l of listeners) l()
111
+ },
112
+ }
113
+ }
114
+
115
+ test('returns initial snapshot', () => {
116
+ const store = createStore(42)
117
+ const result = withHookCtx(() =>
118
+ useSyncExternalStore(store.subscribe, store.getSnapshot),
119
+ )
120
+ expect(result).toBe(42)
121
+ })
122
+
123
+ test('re-renders when store changes', async () => {
124
+ const el = container()
125
+ const store = createStore(0)
126
+ const renders: number[] = []
127
+
128
+ const Comp = () => {
129
+ const value = useSyncExternalStore(store.subscribe, store.getSnapshot)
130
+ renders.push(value)
131
+ return h('span', null, String(value))
132
+ }
133
+
134
+ mount(jsx(Comp, {}), el)
135
+ await new Promise<void>((r) => queueMicrotask(r))
136
+
137
+ store.set(10)
138
+ await new Promise<void>((r) => queueMicrotask(r))
139
+ await new Promise<void>((r) => queueMicrotask(r))
140
+
141
+ expect(el.textContent).toBe('10')
142
+ })
143
+
144
+ test('unsubscribes on unmount', () => {
145
+ const el = container()
146
+ let unsubCalled = false
147
+ const store = {
148
+ getSnapshot: () => 1,
149
+ subscribe: (_cb: () => void) => {
150
+ return () => {
151
+ unsubCalled = true
152
+ }
153
+ },
154
+ }
155
+
156
+ const Comp = () => {
157
+ useSyncExternalStore(store.subscribe, store.getSnapshot)
158
+ return h('div', null, 'x')
159
+ }
160
+
161
+ const cleanup = mount(jsx(Comp, {}), el)
162
+ expect(unsubCalled).toBe(false)
163
+ cleanup()
164
+ expect(unsubCalled).toBe(true)
165
+ })
166
+
167
+ test('no spurious re-render when snapshot is Object.is equal', () => {
168
+ const runner = createHookRunner()
169
+ let rerenders = 0
170
+ runner.ctx.scheduleRerender = () => {
171
+ rerenders++
172
+ }
173
+
174
+ let currentVal = 5
175
+ let listener: (() => void) | null = null
176
+ const subscribe = (cb: () => void) => {
177
+ listener = cb
178
+ return () => {
179
+ listener = null
180
+ }
181
+ }
182
+ const getSnapshot = () => currentVal
183
+ const notify = () => { if (listener) listener() }
184
+
185
+ runner.run(() => useSyncExternalStore(subscribe, getSnapshot))
186
+ // Notify without changing value
187
+ notify()
188
+ expect(rerenders).toBe(0)
189
+
190
+ // Now change value and notify
191
+ currentVal = 10
192
+ notify()
193
+ expect(rerenders).toBe(1)
194
+ })
195
+
196
+ test('works with Redux-like store pattern', () => {
197
+ type State = { count: number; name: string }
198
+ type Action = { type: 'increment' } | { type: 'setName'; name: string }
199
+
200
+ function createReduxLikeStore(initial: State) {
201
+ let state = initial
202
+ const listeners = new Set<() => void>()
203
+ return {
204
+ getState: () => state,
205
+ subscribe: (cb: () => void) => {
206
+ listeners.add(cb)
207
+ return () => listeners.delete(cb)
208
+ },
209
+ dispatch(action: Action) {
210
+ if (action.type === 'increment') {
211
+ state = { ...state, count: state.count + 1 }
212
+ } else if (action.type === 'setName') {
213
+ state = { ...state, name: action.name }
214
+ }
215
+ for (const l of listeners) l()
216
+ },
217
+ }
218
+ }
219
+
220
+ const store = createReduxLikeStore({ count: 0, name: 'test' })
221
+ const result = withHookCtx(() =>
222
+ useSyncExternalStore(store.subscribe, store.getState),
223
+ )
224
+ expect(result).toEqual({ count: 0, name: 'test' })
225
+
226
+ store.dispatch({ type: 'increment' })
227
+ const result2 = withHookCtx(() =>
228
+ useSyncExternalStore(store.subscribe, store.getState),
229
+ )
230
+ expect(result2).toEqual({ count: 1, name: 'test' })
231
+ })
232
+
233
+ test('multiple components sharing one store', async () => {
234
+ const el = container()
235
+ const store = createStore(0)
236
+
237
+ const CompA = () => {
238
+ const v = useSyncExternalStore(store.subscribe, store.getSnapshot)
239
+ return h('span', { class: 'a' }, String(v))
240
+ }
241
+ const CompB = () => {
242
+ const v = useSyncExternalStore(store.subscribe, store.getSnapshot)
243
+ return h('span', { class: 'b' }, String(v))
244
+ }
245
+
246
+ mount(h('div', null, jsx(CompA, {}), jsx(CompB, {})), el)
247
+ await new Promise<void>((r) => queueMicrotask(r))
248
+
249
+ expect(el.querySelector('.a')?.textContent).toBe('0')
250
+ expect(el.querySelector('.b')?.textContent).toBe('0')
251
+
252
+ store.set(99)
253
+ await new Promise<void>((r) => queueMicrotask(r))
254
+ await new Promise<void>((r) => queueMicrotask(r))
255
+ await new Promise<void>((r) => queueMicrotask(r))
256
+
257
+ expect(el.querySelector('.a')?.textContent).toBe('99')
258
+ expect(el.querySelector('.b')?.textContent).toBe('99')
259
+ })
260
+
261
+ test('reads fresh snapshot on each render', () => {
262
+ const runner = createHookRunner()
263
+ let val = 1
264
+ const subscribe = (cb: () => void) => {
265
+ return () => {}
266
+ }
267
+
268
+ const v1 = runner.run(() => useSyncExternalStore(subscribe, () => val))
269
+ expect(v1).toBe(1)
270
+
271
+ val = 2
272
+ const v2 = runner.run(() => useSyncExternalStore(subscribe, () => val))
273
+ expect(v2).toBe(2)
274
+ })
275
+
276
+ test('accepts optional getServerSnapshot', () => {
277
+ const result = withHookCtx(() =>
278
+ useSyncExternalStore(
279
+ () => () => {},
280
+ () => 'client',
281
+ () => 'server',
282
+ ),
283
+ )
284
+ expect(result).toBe('client')
285
+ })
286
+
287
+ test('handles subscribe returning different unsubscribe per call', () => {
288
+ const runner = createHookRunner()
289
+ const unsubs: Array<() => void> = []
290
+ const subscribe = (cb: () => void) => {
291
+ const unsub = () => {}
292
+ unsubs.push(unsub)
293
+ return unsub
294
+ }
295
+
296
+ runner.run(() => useSyncExternalStore(subscribe, () => 0))
297
+ expect(unsubs).toHaveLength(1)
298
+ })
299
+
300
+ test('getSnapshot returning same object reference does not schedule rerender', () => {
301
+ const runner = createHookRunner()
302
+ let rerenders = 0
303
+ runner.ctx.scheduleRerender = () => {
304
+ rerenders++
305
+ }
306
+
307
+ const obj = { x: 1 }
308
+ let listener: (() => void) | null = null
309
+ const subscribe = (cb: () => void) => {
310
+ listener = cb
311
+ return () => {}
312
+ }
313
+
314
+ runner.run(() => useSyncExternalStore(subscribe, () => obj))
315
+ ;(listener as (() => void) | null)?.() // same obj reference
316
+ expect(rerenders).toBe(0)
317
+ })
318
+ })
319
+
320
+ // ─── use() ─────────────────────────────────────────────────────────────────
321
+
322
+ describe('use', () => {
323
+ test('reads context value', () => {
324
+ const Ctx = pyreonCreateContext('hello')
325
+ const result = use(Ctx)
326
+ expect(result).toBe('hello')
327
+ })
328
+
329
+ test('returns value from resolved promise synchronously', async () => {
330
+ const p = Promise.resolve(42)
331
+ // First call triggers the .then() registration in the cache
332
+ try {
333
+ use(p)
334
+ } catch {
335
+ // Expected: first call throws the promise (pending state)
336
+ }
337
+ // Let the promise .then() callback run to update the cache
338
+ await new Promise<void>((r) => queueMicrotask(r))
339
+ await new Promise<void>((r) => queueMicrotask(r))
340
+
341
+ const result = use(p)
342
+ expect(result).toBe(42)
343
+ })
344
+
345
+ test('throws promise for Suspense when pending', () => {
346
+ let resolveFn: (v: string) => void = () => {}
347
+ const p = new Promise<string>((r) => {
348
+ resolveFn = r
349
+ })
350
+ expect(() => use(p)).toThrow(p)
351
+ // Clean up
352
+ resolveFn('done')
353
+ })
354
+
355
+ test('throws error from rejected promise', async () => {
356
+ const err = new Error('fail')
357
+ const p = Promise.reject(err)
358
+ // Suppress unhandled rejection
359
+ p.catch(() => {})
360
+ // First call registers the .then/.catch in the cache
361
+ try {
362
+ use(p)
363
+ } catch {
364
+ // Expected: first call throws the promise (pending)
365
+ }
366
+ // Let the rejection callback run
367
+ await new Promise<void>((r) => queueMicrotask(r))
368
+ await new Promise<void>((r) => queueMicrotask(r))
369
+
370
+ expect(() => use(p)).toThrow(err)
371
+ })
372
+
373
+ test('caches promise result across calls', async () => {
374
+ const p = Promise.resolve('cached')
375
+ // First call registers in cache
376
+ try {
377
+ use(p)
378
+ } catch {
379
+ // Expected: pending
380
+ }
381
+ await new Promise<void>((r) => queueMicrotask(r))
382
+ await new Promise<void>((r) => queueMicrotask(r))
383
+
384
+ const r1 = use(p)
385
+ const r2 = use(p)
386
+ expect(r1).toBe(r2)
387
+ expect(r1).toBe('cached')
388
+ })
389
+ })
390
+
391
+ // ─── startTransition ───────────────────────────────────────────────────────
392
+
393
+ describe('startTransition', () => {
394
+ test('runs callback synchronously', () => {
395
+ let ran = false
396
+ startTransition(() => {
397
+ ran = true
398
+ })
399
+ expect(ran).toBe(true)
400
+ })
401
+
402
+ test('does not throw', () => {
403
+ // Verify startTransition is callable without error (void return)
404
+ let executed = false
405
+ startTransition(() => { executed = true })
406
+ expect(executed).toBe(true)
407
+ })
408
+ })
409
+
410
+ // ─── isValidElement ────────────────────────────────────────────────────────
411
+
412
+ describe('isValidElement', () => {
413
+ test('returns true for VNode from h()', () => {
414
+ const vnode = h('div', {})
415
+ expect(isValidElement(vnode)).toBe(true)
416
+ })
417
+
418
+ test('returns false for null', () => {
419
+ expect(isValidElement(null)).toBe(false)
420
+ })
421
+
422
+ test('returns false for undefined', () => {
423
+ expect(isValidElement(undefined)).toBe(false)
424
+ })
425
+
426
+ test('returns false for string', () => {
427
+ expect(isValidElement('hello')).toBe(false)
428
+ })
429
+
430
+ test('returns false for number', () => {
431
+ expect(isValidElement(42)).toBe(false)
432
+ })
433
+
434
+ test('returns false for plain object without type', () => {
435
+ expect(isValidElement({ props: {} })).toBe(false)
436
+ })
437
+
438
+ test('returns true for object with type and props', () => {
439
+ expect(isValidElement({ type: 'div', props: {} })).toBe(true)
440
+ })
441
+ })
442
+
443
+ // ─── useDebugValue ─────────────────────────────────────────────────────────
444
+
445
+ describe('useDebugValue', () => {
446
+ test('is a no-op that does not throw', () => {
447
+ expect(() => useDebugValue('test')).not.toThrow()
448
+ expect(() => useDebugValue(42, (v) => `value: ${v}`)).not.toThrow()
449
+ })
450
+ })
451
+
452
+ // ─── useInsertionEffect ────────────────────────────────────────────────────
453
+
454
+ describe('useInsertionEffect', () => {
455
+ test('fires callback in compat JSX runtime', async () => {
456
+ const el = container()
457
+ let effectRuns = 0
458
+
459
+ const Comp = () => {
460
+ useInsertionEffect(() => {
461
+ effectRuns++
462
+ })
463
+ return h('div', null, 'insertion')
464
+ }
465
+
466
+ mount(jsx(Comp, {}), el)
467
+ // Insertion effects run synchronously (before layout effects)
468
+ expect(effectRuns).toBeGreaterThanOrEqual(1)
469
+ })
470
+
471
+ test('respects deps - does not re-fire when deps unchanged', () => {
472
+ const runner = createHookRunner()
473
+ runner.run(() => {
474
+ useInsertionEffect(() => {}, [1, 2])
475
+ })
476
+ expect(runner.ctx.pendingInsertionEffects).toHaveLength(1)
477
+
478
+ runner.run(() => {
479
+ useInsertionEffect(() => {}, [1, 2])
480
+ })
481
+ expect(runner.ctx.pendingInsertionEffects).toHaveLength(0)
482
+ })
483
+
484
+ test('re-fires when deps change', () => {
485
+ const runner = createHookRunner()
486
+ runner.run(() => {
487
+ useInsertionEffect(() => {}, [1])
488
+ })
489
+ expect(runner.ctx.pendingInsertionEffects).toHaveLength(1)
490
+
491
+ runner.run(() => {
492
+ useInsertionEffect(() => {}, [2])
493
+ })
494
+ expect(runner.ctx.pendingInsertionEffects).toHaveLength(1)
495
+ })
496
+
497
+ test('cleanup runs before re-fire', async () => {
498
+ const el = container()
499
+ let cleanups = 0
500
+ let triggerSet: (v: number) => void = () => {}
501
+
502
+ const Comp = () => {
503
+ const [count, setCount] = useState(0)
504
+ triggerSet = setCount
505
+ useInsertionEffect(() => {
506
+ return () => {
507
+ cleanups++
508
+ }
509
+ }, [count])
510
+ return h('div', null, String(count))
511
+ }
512
+
513
+ mount(jsx(Comp, {}), el)
514
+ expect(cleanups).toBe(0)
515
+
516
+ triggerSet(1)
517
+ await new Promise<void>((r) => queueMicrotask(r))
518
+ await new Promise<void>((r) => queueMicrotask(r))
519
+ await new Promise<void>((r) => queueMicrotask(r))
520
+ expect(cleanups).toBe(1)
521
+ })
522
+ })
523
+
524
+ // ─── useActionState ────────────────────────────────────────────────────────
525
+
526
+ describe('useActionState', () => {
527
+ test('returns [initialState, dispatch, false] initially', () => {
528
+ const [state, dispatch, isPending] = withHookCtx(() =>
529
+ useActionState((_s: number, _p: number) => _s + _p, 0),
530
+ )
531
+ expect(state).toBe(0)
532
+ expect(typeof dispatch).toBe('function')
533
+ expect(isPending).toBe(false)
534
+ })
535
+
536
+ test('updates state when sync action completes', () => {
537
+ const runner = createHookRunner()
538
+ const [, dispatch] = runner.run(() =>
539
+ useActionState((s: number, p: number) => s + p, 0),
540
+ )
541
+
542
+ dispatch(5)
543
+ const [state2, , isPending2] = runner.run(() =>
544
+ useActionState((s: number, p: number) => s + p, 0),
545
+ )
546
+ expect(state2).toBe(5)
547
+ expect(isPending2).toBe(false)
548
+ })
549
+
550
+ test('sets isPending during async action', async () => {
551
+ const runner = createHookRunner()
552
+ let resolveFn: (v: number) => void = () => {}
553
+ runner.ctx.scheduleRerender = () => {}
554
+
555
+ const [, dispatch] = runner.run(() =>
556
+ useActionState(
557
+ (_s: number, _p: number) => new Promise<number>((r) => {
558
+ resolveFn = r
559
+ }),
560
+ 0,
561
+ ),
562
+ )
563
+
564
+ dispatch(1)
565
+ const [, , isPending] = runner.run(() =>
566
+ useActionState(
567
+ (_s: number, _p: number) => Promise.resolve(0),
568
+ 0,
569
+ ),
570
+ )
571
+ expect(isPending).toBe(true)
572
+
573
+ resolveFn(42)
574
+ await new Promise<void>((r) => queueMicrotask(r))
575
+ const [state3, , isPending3] = runner.run(() =>
576
+ useActionState(
577
+ (_s: number, _p: number) => Promise.resolve(0),
578
+ 0,
579
+ ),
580
+ )
581
+ expect(state3).toBe(42)
582
+ expect(isPending3).toBe(false)
583
+ })
584
+ })
585
+
586
+ // ─── flushSync ─────────────────────────────────────────────────────────────
587
+
588
+ describe('flushSync', () => {
589
+ test('runs callback and returns result', () => {
590
+ const result = flushSync(() => 42)
591
+ expect(result).toBe(42)
592
+ })
593
+
594
+ test('state updates inside are scheduled', () => {
595
+ const runner = createHookRunner()
596
+ let rerenders = 0
597
+ runner.ctx.scheduleRerender = () => {
598
+ rerenders++
599
+ }
600
+
601
+ const [, setCount] = runner.run(() => useState(0))
602
+ flushSync(() => {
603
+ setCount(1)
604
+ })
605
+ expect(rerenders).toBe(1)
606
+ })
607
+ })
608
+
609
+ // ─── StrictMode / Profiler ─────────────────────────────────────────────────
610
+
611
+ describe('StrictMode', () => {
612
+ test('renders children', () => {
613
+ const el = container()
614
+ mount(h(StrictMode, {}, h('span', null, 'strict-child')), el)
615
+ expect(el.textContent).toBe('strict-child')
616
+ })
617
+ })
618
+
619
+ describe('Profiler', () => {
620
+ test('renders children', () => {
621
+ const el = container()
622
+ mount(h(Profiler as any, { id: 'test' }, h('span', null, 'profiler-child')), el)
623
+ expect(el.textContent).toBe('profiler-child')
624
+ })
625
+ })
626
+
627
+ // ─── version ───────────────────────────────────────────────────────────────
628
+
629
+ describe('version', () => {
630
+ test('exports a string starting with "19"', () => {
631
+ expect(typeof version).toBe('string')
632
+ expect(version.startsWith('19')).toBe(true)
633
+ })
634
+ })
635
+
636
+ // ─── useReducer 3rd arg ───────────────────────────────────────────────────
637
+
638
+ describe('useReducer 3rd arg (init function)', () => {
639
+ test('init function is called with initialArg', () => {
640
+ const runner = createHookRunner()
641
+ let initArg: number | null = null
642
+ const [state] = runner.run(() =>
643
+ useReducer(
644
+ (s: number, a: number) => s + a,
645
+ 10,
646
+ (arg) => {
647
+ initArg = arg
648
+ return arg * 2
649
+ },
650
+ ),
651
+ )
652
+ expect(initArg).toBe(10)
653
+ expect(state).toBe(20)
654
+ })
655
+
656
+ test('standard 2-arg still works', () => {
657
+ const runner = createHookRunner()
658
+ const [state] = runner.run(() =>
659
+ useReducer((s: number, a: number) => s + a, 5),
660
+ )
661
+ expect(state).toBe(5)
662
+ })
663
+ })
664
+
665
+ // ─── forwardRef displayName ────────────────────────────────────────────────
666
+
667
+ describe('forwardRef displayName', () => {
668
+ test('sets displayName from render function name', () => {
669
+ function MyInput(_props: Record<string, unknown>, _ref: { current: unknown } | null) {
670
+ return h('input', {})
671
+ }
672
+ const Forwarded = forwardRef(MyInput)
673
+ expect((Forwarded as any).displayName).toBe('MyInput')
674
+ })
675
+
676
+ test('writable displayName property', () => {
677
+ const Forwarded = forwardRef((_props: Record<string, unknown>, _ref) => h('div', null))
678
+ ;(Forwarded as any).displayName = 'Custom'
679
+ expect((Forwarded as any).displayName).toBe('Custom')
680
+ })
681
+ })
682
+
683
+ // ─── memo displayName ──────────────────────────────────────────────────────
684
+
685
+ describe('memo displayName', () => {
686
+ test('sets displayName from component name', () => {
687
+ function NamedComponent(_props: Record<string, unknown>) {
688
+ return h('div', null)
689
+ }
690
+ const Memoized = memo(NamedComponent)
691
+ expect((Memoized as any).displayName).toBe('NamedComponent')
692
+ })
693
+ })
694
+
695
+ // ─── onChange -> onInput mapping ───────────────────────────────────────────
696
+
697
+ describe('onChange -> onInput mapping', () => {
698
+ test('input element onChange maps to onInput', () => {
699
+ const handler = () => {}
700
+ const vnode = jsx('input', { onChange: handler })
701
+ expect(vnode.props.onInput).toBe(handler)
702
+ expect(vnode.props.onChange).toBeUndefined()
703
+ })
704
+
705
+ test('textarea element onChange maps to onInput', () => {
706
+ const handler = () => {}
707
+ const vnode = jsx('textarea', { onChange: handler })
708
+ expect(vnode.props.onInput).toBe(handler)
709
+ expect(vnode.props.onChange).toBeUndefined()
710
+ })
711
+
712
+ test('non-form element onChange stays as onChange', () => {
713
+ const handler = () => {}
714
+ const vnode = jsx('div', { onChange: handler })
715
+ expect(vnode.props.onChange).toBe(handler)
716
+ })
717
+ })
718
+
719
+ // ─── autoFocus mapping ─────────────────────────────────────────────────────
720
+
721
+ describe('autoFocus mapping', () => {
722
+ test('autoFocus maps to autofocus', () => {
723
+ const vnode = jsx('input', { autoFocus: true })
724
+ expect(vnode.props.autofocus).toBe(true)
725
+ expect(vnode.props.autoFocus).toBeUndefined()
726
+ })
727
+ })
728
+
729
+ // ─── defaultValue / defaultChecked ─────────────────────────────────────────
730
+
731
+ describe('defaultValue / defaultChecked', () => {
732
+ test('defaultValue maps to value when no value prop', () => {
733
+ const vnode = jsx('input', { defaultValue: 'hello' })
734
+ expect(vnode.props.value).toBe('hello')
735
+ expect(vnode.props.defaultValue).toBeUndefined()
736
+ })
737
+
738
+ test('defaultChecked maps to checked when no checked prop', () => {
739
+ const vnode = jsx('input', { defaultChecked: true })
740
+ expect(vnode.props.checked).toBe(true)
741
+ expect(vnode.props.defaultChecked).toBeUndefined()
742
+ })
743
+
744
+ test('defaultValue does NOT override explicit value prop', () => {
745
+ const vnode = jsx('input', { value: 'explicit', defaultValue: 'fallback' })
746
+ expect(vnode.props.value).toBe('explicit')
747
+ })
748
+ })
749
+
750
+ // ─── suppressHydrationWarning / suppressContentEditableWarning ─────────────
751
+
752
+ describe('suppressHydrationWarning / suppressContentEditableWarning', () => {
753
+ test('props are stripped', () => {
754
+ const vnode = jsx('div', {
755
+ suppressHydrationWarning: true,
756
+ suppressContentEditableWarning: true,
757
+ })
758
+ expect(vnode.props.suppressHydrationWarning).toBeUndefined()
759
+ expect(vnode.props.suppressContentEditableWarning).toBeUndefined()
760
+ })
761
+ })
762
+
763
+ // ─── createRef ─────────────────────────────────────────────────────────────
764
+
765
+ describe('createRef', () => {
766
+ test('returns { current: null }', () => {
767
+ const ref = createRef()
768
+ expect(ref).toEqual({ current: null })
769
+ expect(ref.current).toBeNull()
770
+ })
771
+ })
772
+
773
+ // ─── act ───────────────────────────────────────────────────────────────────
774
+
775
+ describe('act', () => {
776
+ test('flushes sync callback', async () => {
777
+ let ran = false
778
+ await act(() => {
779
+ ran = true
780
+ })
781
+ expect(ran).toBe(true)
782
+ })
783
+
784
+ test('flushes async callback', async () => {
785
+ let ran = false
786
+ await act(async () => {
787
+ await new Promise<void>((r) => setTimeout(r, 5))
788
+ ran = true
789
+ })
790
+ expect(ran).toBe(true)
791
+ })
792
+ })
793
+
794
+ // ─── Component / PureComponent ─────────────────────────────────────────────
795
+
796
+ describe('Component', () => {
797
+ test('constructor sets props', () => {
798
+ const comp = new Component({ name: 'test' })
799
+ expect(comp.props).toEqual({ name: 'test' })
800
+ })
801
+
802
+ test('state is initialized to empty object', () => {
803
+ const comp = new Component({})
804
+ expect(comp.state).toEqual({})
805
+ })
806
+
807
+ test('setState warns', () => {
808
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
809
+ const comp = new Component({})
810
+ comp.setState({ x: 1 })
811
+ expect(spy).toHaveBeenCalledWith(
812
+ expect.stringContaining('Class component setState is not supported'),
813
+ )
814
+ spy.mockRestore()
815
+ })
816
+
817
+ test('forceUpdate warns', () => {
818
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
819
+ const comp = new Component({})
820
+ comp.forceUpdate()
821
+ expect(spy).toHaveBeenCalledWith(
822
+ expect.stringContaining('Class component forceUpdate is not supported'),
823
+ )
824
+ spy.mockRestore()
825
+ })
826
+ })
827
+
828
+ describe('PureComponent', () => {
829
+ test('extends Component', () => {
830
+ const comp = new PureComponent({ name: 'pure' })
831
+ expect(comp instanceof Component).toBe(true)
832
+ expect(comp.props).toEqual({ name: 'pure' })
833
+ })
834
+
835
+ test('instanceof checks work', () => {
836
+ const comp = new PureComponent({})
837
+ expect(comp instanceof PureComponent).toBe(true)
838
+ expect(comp instanceof Component).toBe(true)
839
+ })
840
+ })
841
+
842
+ // ─── Type exports ──────────────────────────────────────────────────────────
843
+
844
+ describe('type exports', () => {
845
+ test('FC, ReactNode, etc. are importable types', () => {
846
+ // This test verifies at compile time that these types exist and are usable.
847
+ // At runtime we just verify the test file compiled successfully.
848
+ const _fc: FC<{ name: string }> = (_props) => null
849
+ const _fcComponent: FunctionComponent = () => null
850
+ const _node: ReactNode = null
851
+ const _element: ReactElement = h('div', {})
852
+ const _dispatch: Dispatch<string> = (_a) => {}
853
+ const _action: SetStateAction<number> = 5
854
+ const _ref: RefObject<HTMLDivElement> = { current: null }
855
+ const _mutableRef: MutableRefObject<number> = { current: 0 }
856
+ const _refCb: RefCallback<HTMLDivElement> = (_el) => {}
857
+ const _fwdRef: ForwardedRef<HTMLDivElement> = null
858
+ const _children: PropsWithChildren = {}
859
+ const _withRef: PropsWithRef<{ x: number }> = { x: 1 }
860
+ const _htmlAttrs: HTMLAttributes = {}
861
+ const _syntheticEvent: SyntheticEvent | null = null
862
+ const _changeEvent: ChangeEvent | null = null
863
+ const _formEvent: FormEvent | null = null
864
+ const _mouseEvent: MouseEvent | null = null
865
+ const _keyboardEvent: KeyboardEvent | null = null
866
+ const _focusEvent: FocusEvent | null = null
867
+
868
+ // If this compiles, the types are exported correctly
869
+ expect(true).toBe(true)
870
+ })
871
+ })
872
+
873
+ // ─── Real-world integration patterns ───────────────────────────────────────
874
+
875
+ describe('real-world integration patterns', () => {
876
+ test('Redux-like store with useSyncExternalStore + dispatch', async () => {
877
+ const el = container()
878
+
879
+ type State = { count: number }
880
+ type Action = { type: 'inc' } | { type: 'dec' }
881
+
882
+ let state: State = { count: 0 }
883
+ const listeners = new Set<() => void>()
884
+ const store = {
885
+ getState: () => state,
886
+ subscribe: (cb: () => void) => {
887
+ listeners.add(cb)
888
+ return () => listeners.delete(cb)
889
+ },
890
+ dispatch(action: Action) {
891
+ if (action.type === 'inc') state = { count: state.count + 1 }
892
+ else state = { count: state.count - 1 }
893
+ for (const l of listeners) l()
894
+ },
895
+ }
896
+
897
+ const Counter = () => {
898
+ const s = useSyncExternalStore(store.subscribe, store.getState)
899
+ return h('span', null, String(s.count))
900
+ }
901
+
902
+ mount(jsx(Counter, {}), el)
903
+ await new Promise<void>((r) => queueMicrotask(r))
904
+ expect(el.textContent).toBe('0')
905
+
906
+ store.dispatch({ type: 'inc' })
907
+ await new Promise<void>((r) => queueMicrotask(r))
908
+ await new Promise<void>((r) => queueMicrotask(r))
909
+ expect(el.textContent).toBe('1')
910
+ })
911
+
912
+ test('controlled input form with onChange', () => {
913
+ const vnode = jsx('input', {
914
+ type: 'text',
915
+ value: 'hello',
916
+ onChange: () => {},
917
+ })
918
+ // onChange should be mapped to onInput for input elements
919
+ expect(vnode.props.onInput).toBeDefined()
920
+ expect(vnode.props.onChange).toBeUndefined()
921
+ expect(vnode.props.value).toBe('hello')
922
+ })
923
+
924
+ test('context provider chain', () => {
925
+ const ThemeCtx = pyreonCreateContext('light')
926
+ const result = use(ThemeCtx)
927
+ expect(result).toBe('light')
928
+ })
929
+
930
+ test('memo component tree does not re-render on same props', () => {
931
+ let parentRenders = 0
932
+ let childRenders = 0
933
+
934
+ const Child = memo((_props: { label: string }) => {
935
+ childRenders++
936
+ return h('span', null, _props.label)
937
+ })
938
+
939
+ const Parent = () => {
940
+ parentRenders++
941
+ return Child({ label: 'fixed' })
942
+ }
943
+
944
+ Parent()
945
+ expect(parentRenders).toBe(1)
946
+ expect(childRenders).toBe(1)
947
+
948
+ // Second call with same props
949
+ Parent()
950
+ expect(parentRenders).toBe(2)
951
+ expect(childRenders).toBe(1) // memo skips re-render
952
+ })
953
+
954
+ test('useState + useEffect pattern', async () => {
955
+ const el = container()
956
+ const effectLog: string[] = []
957
+
958
+ const Comp = () => {
959
+ const [count] = useState(0)
960
+ useEffect(() => {
961
+ effectLog.push(`effect:${count}`)
962
+ return () => {
963
+ effectLog.push(`cleanup:${count}`)
964
+ }
965
+ }, [count])
966
+ return h('div', null, String(count))
967
+ }
968
+
969
+ mount(jsx(Comp, {}), el)
970
+ await new Promise<void>((r) => queueMicrotask(r))
971
+ expect(effectLog).toContain('effect:0')
972
+ })
973
+
974
+ test('select element onChange maps to onInput', () => {
975
+ const handler = () => {}
976
+ const vnode = jsx('select', { onChange: handler })
977
+ expect(vnode.props.onInput).toBe(handler)
978
+ expect(vnode.props.onChange).toBeUndefined()
979
+ })
980
+ })
981
+
982
+ // ─── Children.forEach index fix ───────────────────────────────────────────
983
+
984
+ describe('Children.forEach index', () => {
985
+ test('passes correct sequential index skipping nulls', () => {
986
+ const indices: number[] = []
987
+ const values: unknown[] = []
988
+ Children.forEach([null, 'a', null, 'b', undefined, 'c'], (child, idx) => {
989
+ indices.push(idx)
990
+ values.push(child)
991
+ })
992
+ expect(indices).toEqual([0, 1, 2])
993
+ expect(values).toEqual(['a', 'b', 'c'])
994
+ })
995
+
996
+ test('passes correct index skipping booleans', () => {
997
+ const indices: number[] = []
998
+ Children.forEach([true, 'x', false, 'y'], (_, idx) => indices.push(idx))
999
+ expect(indices).toEqual([0, 1])
1000
+ })
1001
+ })
1002
+
1003
+ // ─── useSyncExternalStore re-subscribe on identity change ─────────────────
1004
+
1005
+ describe('useSyncExternalStore re-subscribe', () => {
1006
+ test('re-subscribes when subscribe identity changes', () => {
1007
+ const runner = createHookRunner()
1008
+ let listener: (() => void) | null = null
1009
+ let unsubCount = 0
1010
+ const sub1 = (cb: () => void) => { listener = cb; return () => { unsubCount++; listener = null } }
1011
+ const sub2 = (cb: () => void) => { listener = cb; return () => { unsubCount++; listener = null } }
1012
+ let val = 1
1013
+
1014
+ runner.run(() => useSyncExternalStore(sub1, () => val))
1015
+ expect(unsubCount).toBe(0)
1016
+
1017
+ // Change to sub2 — should unsub from sub1
1018
+ runner.run(() => useSyncExternalStore(sub2, () => val))
1019
+ expect(unsubCount).toBe(1)
1020
+ })
1021
+ })
1022
+
1023
+ // ─── useSyncExternalStore unsubscribe on unmount ──────────────────────────
1024
+
1025
+ describe('useSyncExternalStore unmount cleanup', () => {
1026
+ test('unsubscribes on component unmount', () => {
1027
+ const el = container()
1028
+ let unsubbed = false
1029
+ const subscribe = (_cb: () => void) => () => { unsubbed = true }
1030
+
1031
+ const Comp = () => {
1032
+ useSyncExternalStore(subscribe, () => 1)
1033
+ return h('div', null, 'x')
1034
+ }
1035
+
1036
+ const cleanup = mount(jsx(Comp, {}), el)
1037
+ expect(unsubbed).toBe(false)
1038
+ cleanup()
1039
+ expect(unsubbed).toBe(true)
1040
+ })
1041
+ })
1042
+
1043
+ // ─── useActionState async transitions ─────────────────────────────────────
1044
+
1045
+ describe('useActionState async transitions', () => {
1046
+ test('isPending transitions false → true → false during async action', async () => {
1047
+ const runner = createHookRunner()
1048
+ let resolveFn: (v: number) => void = () => {}
1049
+ runner.ctx.scheduleRerender = () => {}
1050
+
1051
+ const [, dispatch] = runner.run(() =>
1052
+ useActionState(
1053
+ (_s: number, _p: number) => new Promise<number>((r) => { resolveFn = r }),
1054
+ 0,
1055
+ ),
1056
+ )
1057
+
1058
+ // Initially not pending
1059
+ const [, , isPending0] = runner.run(() =>
1060
+ useActionState((_s: number, _p: number) => Promise.resolve(0), 0),
1061
+ )
1062
+ expect(isPending0).toBe(false)
1063
+
1064
+ // After dispatch, should be pending
1065
+ dispatch(1)
1066
+ const [, , isPending1] = runner.run(() =>
1067
+ useActionState((_s: number, _p: number) => Promise.resolve(0), 0),
1068
+ )
1069
+ expect(isPending1).toBe(true)
1070
+
1071
+ // After resolve, should no longer be pending
1072
+ resolveFn(42)
1073
+ await new Promise<void>((r) => queueMicrotask(r))
1074
+ const [state, , isPending2] = runner.run(() =>
1075
+ useActionState((_s: number, _p: number) => Promise.resolve(0), 0),
1076
+ )
1077
+ expect(state).toBe(42)
1078
+ expect(isPending2).toBe(false)
1079
+ })
1080
+ })
1081
+
1082
+ // ─── Component.render default ─────────────────────────────────────────────
1083
+
1084
+ describe('Component.render', () => {
1085
+ test('default render returns null', () => {
1086
+ const comp = new Component({})
1087
+ expect(comp.render()).toBeNull()
1088
+ })
1089
+ })
1090
+
1091
+ // ─── PureComponent ────────────────────────────────────────────────────────
1092
+
1093
+ describe('PureComponent additional', () => {
1094
+ test('PureComponent extends Component', () => {
1095
+ const c = new PureComponent({ x: 1 })
1096
+ expect(c instanceof Component).toBe(true)
1097
+ expect(c instanceof PureComponent).toBe(true)
1098
+ })
1099
+
1100
+ test('PureComponent state is empty object', () => {
1101
+ const c = new PureComponent({})
1102
+ expect(c.state).toEqual({})
1103
+ })
1104
+ })
1105
+
1106
+ // ─── Hook count tracking ──────────────────────────────────────────────────
1107
+
1108
+ describe('hook count tracking', () => {
1109
+ test('_hookCount is tracked between renders', () => {
1110
+ const runner = createHookRunner()
1111
+ runner.run(() => { useState(0); useState(0) })
1112
+ expect(runner.ctx._hookCount).toBe(2)
1113
+ })
1114
+
1115
+ test('_hookCount updates on re-render', () => {
1116
+ const runner = createHookRunner()
1117
+ runner.run(() => { useState(0); useState(0); useState(0) })
1118
+ expect(runner.ctx._hookCount).toBe(3)
1119
+ })
1120
+ })
1121
+
1122
+ // ─── memo per-instance cache ──────────────────────────────────────────────
1123
+
1124
+ describe('memo per-instance cache', () => {
1125
+ test('separate instances have separate caches', () => {
1126
+ let callCount = 0
1127
+ const Inner = (props: { x: number }) => { callCount++; return h('span', null, String(props.x)) }
1128
+ const Memoized = memo(Inner)
1129
+
1130
+ const runner1 = createHookRunner()
1131
+ const runner2 = createHookRunner()
1132
+ runner1.run(() => Memoized({ x: 1 }))
1133
+ runner2.run(() => Memoized({ x: 2 }))
1134
+ expect(callCount).toBe(2) // Both should render independently
1135
+
1136
+ // Re-render with same props — instance 1 cached
1137
+ runner1.run(() => Memoized({ x: 1 }))
1138
+ expect(callCount).toBe(2) // Instance 1 cached, not re-rendered
1139
+ })
1140
+ })
1141
+
1142
+ // ─── cloneElement with ref ────────────────────────────────────────────────
1143
+
1144
+ describe('cloneElement with ref', () => {
1145
+ test('merges ref prop', () => {
1146
+ const ref = { current: null }
1147
+ const el = h('div', {})
1148
+ const cloned = cloneElement(el, { ref })
1149
+ expect(cloned.props.ref).toBe(ref)
1150
+ })
1151
+ })
1152
+
1153
+ // ─── flushSync returns result ─────────────────────────────────────────────
1154
+
1155
+ describe('flushSync return value', () => {
1156
+ test('returns callback result', () => {
1157
+ expect(flushSync(() => 42)).toBe(42)
1158
+ })
1159
+
1160
+ test('returns string result', () => {
1161
+ expect(flushSync(() => 'hello')).toBe('hello')
1162
+ })
1163
+
1164
+ test('returns undefined for void callback', () => {
1165
+ expect(flushSync(() => {})).toBeUndefined()
1166
+ })
1167
+ })
1168
+
1169
+ // ─── act with async callback ──────────────────────────────────────────────
1170
+
1171
+ describe('act async', () => {
1172
+ test('handles async callback', async () => {
1173
+ let resolved = false
1174
+ await act(async () => {
1175
+ await new Promise(r => setTimeout(r, 10))
1176
+ resolved = true
1177
+ })
1178
+ expect(resolved).toBe(true)
1179
+ })
1180
+ })
1181
+
1182
+ // ─── StrictMode / Profiler with no children ───────────────────────────────
1183
+
1184
+ describe('StrictMode / Profiler edge cases', () => {
1185
+ test('StrictMode with no children returns null', () => {
1186
+ const result = StrictMode({})
1187
+ expect(result).toBeNull()
1188
+ })
1189
+
1190
+ test('Profiler with no children returns null', () => {
1191
+ const result = Profiler({ id: 'test' })
1192
+ expect(result).toBeNull()
1193
+ })
1194
+
1195
+ test('Profiler with onRender callback', () => {
1196
+ const el = container()
1197
+ let called = false
1198
+ mount(h(Profiler as any, { id: 'p', onRender: () => { called = true } }, h('span', null, 'child')), el)
1199
+ expect(el.textContent).toBe('child')
1200
+ })
1201
+ })
1202
+
1203
+ // ─── Children.map key assignment ──────────────────────────────────────────
1204
+
1205
+ describe('Children.map key assignment', () => {
1206
+ test('assigns keys to mapped VNode children that lack keys', () => {
1207
+ const children = [h('span', null, 'a'), h('span', null, 'b')]
1208
+ const mapped = Children.map(children, (child) => child)
1209
+ expect((mapped[0] as any).key).toBe('.0')
1210
+ expect((mapped[1] as any).key).toBe('.1')
1211
+ })
1212
+
1213
+ test('does not overwrite existing keys', () => {
1214
+ const child = h('span', { key: 'existing' }, 'a')
1215
+ const mapped = Children.map([child], (c) => c)
1216
+ expect((mapped[0] as any).key).toBe('existing')
1217
+ })
1218
+
1219
+ test('skips key assignment for non-VNode mapped results', () => {
1220
+ const children = [h('span', null, 'a')]
1221
+ const mapped = Children.map(children, (_child, idx) => `text-${idx}`)
1222
+ expect(mapped[0]).toBe('text-0')
1223
+ })
1224
+ })
1225
+
1226
+ // ─── Children.map with nulls ──────────────────────────────────────────────
1227
+
1228
+ describe('Children.map with nulls', () => {
1229
+ test('skips null/undefined/boolean children', () => {
1230
+ const indices: number[] = []
1231
+ const mapped = Children.map([null, 'a', undefined, false, true, 'b'], (_child, idx) => {
1232
+ indices.push(idx)
1233
+ return idx
1234
+ })
1235
+ expect(mapped).toEqual([0, 1])
1236
+ expect(indices).toEqual([0, 1])
1237
+ })
1238
+ })
1239
+
1240
+ // ─── Children.only edge cases ─────────────────────────────────────────────
1241
+
1242
+ describe('Children.only edge', () => {
1243
+ test('works with single non-array child', () => {
1244
+ const child = h('div', null, 'only')
1245
+ expect(Children.only(child)).toBe(child)
1246
+ })
1247
+ })
1248
+
1249
+ // ─── flattenChildren edge cases ───────────────────────────────────────────
1250
+
1251
+ describe('Children.toArray deep nesting', () => {
1252
+ test('flattens deeply nested arrays', () => {
1253
+ const children = [h('a', null), [h('b', null), [h('c', null)]]] as VNodeChild[]
1254
+ const arr = Children.toArray(children)
1255
+ expect(arr).toHaveLength(3)
1256
+ })
1257
+ })
1258
+
1259
+ // ─── jsx runtime edge cases ──────────────────────────────────────────────
1260
+
1261
+ describe('jsx runtime additional', () => {
1262
+ test('jsx with null key does not set key prop', () => {
1263
+ const vnode = jsx('div', {}, null)
1264
+ expect(vnode.props.key).toBeUndefined()
1265
+ })
1266
+
1267
+ test('input onChange does not override existing onInput', () => {
1268
+ const inputHandler = () => 'input'
1269
+ const changeHandler = () => 'change'
1270
+ const vnode = jsx('input', { onInput: inputHandler, onChange: changeHandler })
1271
+ // onInput already set, onChange should be deleted but onInput preserved
1272
+ expect(vnode.props.onInput).toBe(inputHandler)
1273
+ expect(vnode.props.onChange).toBeUndefined()
1274
+ })
1275
+
1276
+ test('textarea defaultValue maps to value', () => {
1277
+ const vnode = jsx('textarea', { defaultValue: 'default text' })
1278
+ expect(vnode.props.value).toBe('default text')
1279
+ expect(vnode.props.defaultValue).toBeUndefined()
1280
+ })
1281
+
1282
+ test('textarea defaultValue does NOT override explicit value', () => {
1283
+ const vnode = jsx('textarea', { value: 'explicit', defaultValue: 'default' })
1284
+ expect(vnode.props.value).toBe('explicit')
1285
+ })
1286
+
1287
+ test('input defaultChecked does NOT override explicit checked', () => {
1288
+ const vnode = jsx('input', { checked: false, defaultChecked: true })
1289
+ expect(vnode.props.checked).toBe(false)
1290
+ })
1291
+
1292
+ test('jsx component wrapping is cached', () => {
1293
+ const MyComp = () => h('div', null, 'test')
1294
+ const vnode1 = jsx(MyComp, {})
1295
+ const vnode2 = jsx(MyComp, {})
1296
+ // Same component function should produce same wrapped type
1297
+ expect(vnode1.type).toBe(vnode2.type)
1298
+ })
1299
+
1300
+ test('jsx with array children for DOM element', () => {
1301
+ const vnode = jsx('div', { children: ['a', 'b', 'c'] })
1302
+ expect(vnode.children).toHaveLength(3)
1303
+ })
1304
+ })
1305
+
1306
+ // ─── scheduleEffects skips when unmounted ─────────────────────────────────
1307
+
1308
+ describe('effect scheduling respects unmount', () => {
1309
+ test('effects do not run after unmount', async () => {
1310
+ const el = container()
1311
+ let effectRuns = 0
1312
+ let triggerSet: (v: number) => void = () => {}
1313
+
1314
+ const Comp = () => {
1315
+ const [count, setCount] = useState(0)
1316
+ triggerSet = setCount
1317
+ useEffect(() => {
1318
+ effectRuns++
1319
+ }, [count])
1320
+ return h('div', null, String(count))
1321
+ }
1322
+
1323
+ const cleanup = mount(jsx(Comp, {}), el)
1324
+ await new Promise<void>((r) => queueMicrotask(r))
1325
+ const initialRuns = effectRuns
1326
+
1327
+ // Trigger state change then immediately unmount
1328
+ triggerSet(1)
1329
+ cleanup()
1330
+ await new Promise<void>((r) => queueMicrotask(r))
1331
+ await new Promise<void>((r) => queueMicrotask(r))
1332
+ // Effect should not have run again after unmount
1333
+ expect(effectRuns).toBe(initialRuns)
1334
+ })
1335
+ })
1336
+
1337
+ // ─── wrapCompatComponent cleanup on unmount ───────────────────────────────
1338
+
1339
+ describe('wrapCompatComponent cleanup', () => {
1340
+ test('cleans up effect entries on unmount', async () => {
1341
+ const el = container()
1342
+ let cleanupRan = false
1343
+
1344
+ const Comp = () => {
1345
+ useEffect(() => {
1346
+ return () => { cleanupRan = true }
1347
+ }, [])
1348
+ return h('div', null, 'cleanup')
1349
+ }
1350
+
1351
+ const unmount = mount(jsx(Comp, {}), el)
1352
+ await new Promise<void>((r) => queueMicrotask(r))
1353
+ expect(cleanupRan).toBe(false)
1354
+
1355
+ unmount()
1356
+ expect(cleanupRan).toBe(true)
1357
+ })
1358
+
1359
+ test('cleans up useSyncExternalStore subscription on unmount', () => {
1360
+ const el = container()
1361
+ let unsubCount = 0
1362
+ // Use a stable subscribe function identity so re-renders don't trigger unsub
1363
+ const stableSub = (_cb: () => void) => () => { unsubCount++ }
1364
+
1365
+ const Comp = () => {
1366
+ useSyncExternalStore(stableSub, () => 1)
1367
+ return h('div', null, 'sub')
1368
+ }
1369
+
1370
+ const unmount = mount(jsx(Comp, {}), el)
1371
+ const preUnmountCount = unsubCount
1372
+ unmount()
1373
+ expect(unsubCount).toBe(preUnmountCount + 1)
1374
+ })
1375
+ })
1376
+
1377
+ // ─── scheduleRerender deduplication ───────────────────────────────────────
1378
+
1379
+ describe('scheduleRerender deduplication', () => {
1380
+ test('multiple state updates in same tick produce single re-render', async () => {
1381
+ const el = container()
1382
+ let renderCount = 0
1383
+ let triggerSet: (v: number | ((p: number) => number)) => void = () => {}
1384
+
1385
+ const Comp = () => {
1386
+ const [count, setCount] = useState(0)
1387
+ triggerSet = setCount
1388
+ renderCount++
1389
+ return h('span', null, String(count))
1390
+ }
1391
+
1392
+ mount(jsx(Comp, {}), el)
1393
+ await new Promise<void>((r) => queueMicrotask(r))
1394
+ const initialRenders = renderCount
1395
+
1396
+ // Multiple rapid updates should batch
1397
+ triggerSet(1)
1398
+ triggerSet(2)
1399
+ triggerSet(3)
1400
+ await new Promise<void>((r) => queueMicrotask(r))
1401
+ await new Promise<void>((r) => queueMicrotask(r))
1402
+ // Should have at most 1 additional render (batched)
1403
+ expect(renderCount - initialRenders).toBeLessThanOrEqual(1)
1404
+ })
1405
+ })
1406
+
1407
+ // ─── layout effect cleanup on unmount ─────────────────────────────────────
1408
+
1409
+ describe('layout effect cleanup on unmount', () => {
1410
+ test('layout effect cleanup runs on unmount', () => {
1411
+ const el = container()
1412
+ let cleanupRan = false
1413
+
1414
+ const Comp = () => {
1415
+ useLayoutEffect(() => {
1416
+ return () => { cleanupRan = true }
1417
+ }, [])
1418
+ return h('div', null, 'layout')
1419
+ }
1420
+
1421
+ const unmount = mount(jsx(Comp, {}), el)
1422
+ expect(cleanupRan).toBe(false)
1423
+ unmount()
1424
+ expect(cleanupRan).toBe(true)
1425
+ })
1426
+ })
1427
+
1428
+ // ─── useState setter identity stability ─────────────────────────────────────
1429
+
1430
+ describe('useState setter identity stability', () => {
1431
+ test('setter has stable identity across renders', () => {
1432
+ const runner = createHookRunner()
1433
+ const [, setter1] = runner.run(() => useState(0))
1434
+ setter1(5)
1435
+ const [, setter2] = runner.run(() => useState(0))
1436
+ expect(setter1).toBe(setter2)
1437
+ })
1438
+
1439
+ test('setter reads latest value when called multiple times', () => {
1440
+ const runner = createHookRunner()
1441
+ const [, setter] = runner.run(() => useState(0))
1442
+ setter(1)
1443
+ setter((prev) => prev + 1) // should read 1, not 0
1444
+ const [value] = runner.run(() => useState(0))
1445
+ expect(value).toBe(2)
1446
+ })
1447
+
1448
+ test('setter identity stable even without state changes', () => {
1449
+ const runner = createHookRunner()
1450
+ const [, setter1] = runner.run(() => useState('hello'))
1451
+ const [, setter2] = runner.run(() => useState('hello'))
1452
+ const [, setter3] = runner.run(() => useState('hello'))
1453
+ expect(setter1).toBe(setter2)
1454
+ expect(setter2).toBe(setter3)
1455
+ })
1456
+ })
1457
+
1458
+ // ─── useReducer dispatch identity stability ─────────────────────────────────
1459
+
1460
+ describe('useReducer dispatch identity stability', () => {
1461
+ test('dispatch has stable identity across renders', () => {
1462
+ const runner = createHookRunner()
1463
+ const reducer = (s: number, a: number) => s + a
1464
+ const [, dispatch1] = runner.run(() => useReducer(reducer, 0))
1465
+ dispatch1(5)
1466
+ const [, dispatch2] = runner.run(() => useReducer(reducer, 0))
1467
+ expect(dispatch1).toBe(dispatch2)
1468
+ })
1469
+
1470
+ test('dispatch reads latest value when called multiple times', () => {
1471
+ const runner = createHookRunner()
1472
+ const reducer = (s: number, a: number) => s + a
1473
+ const [, dispatch] = runner.run(() => useReducer(reducer, 0))
1474
+ dispatch(10)
1475
+ dispatch(5)
1476
+ const [value] = runner.run(() => useReducer(reducer, 0))
1477
+ expect(value).toBe(15)
1478
+ })
1479
+ })
1480
+
1481
+ // ─── Compat context ─────────────────────────────────────────────────────────
1482
+
1483
+ describe('compat context', () => {
1484
+ test('default value works without provider', () => {
1485
+ const Ctx = createCompatContext('default-val')
1486
+ const value = withHookCtx(() => useContext(Ctx))
1487
+ expect(value).toBe('default-val')
1488
+ })
1489
+
1490
+ test('useContext works with Pyreon native context fallback', () => {
1491
+ const Ctx = pyreonCreateContext(99)
1492
+ const value = useContext(Ctx)
1493
+ expect(value).toBe(99)
1494
+ })
1495
+
1496
+ test('provider passes value to consumer via DOM mount', () => {
1497
+ const el = container()
1498
+ const Ctx = createCompatContext('hello')
1499
+ let readValue = ''
1500
+
1501
+ const Consumer = () => {
1502
+ readValue = useContext(Ctx)
1503
+ return h('span', null, readValue)
1504
+ }
1505
+
1506
+ mount(
1507
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1508
+ jsx(Ctx.Provider as any, { value: 'world', children: jsx(Consumer, {}) }),
1509
+ el,
1510
+ )
1511
+ expect(readValue).toBe('world')
1512
+ })
1513
+
1514
+ test('use() reads compat context', () => {
1515
+ const Ctx = createCompatContext('from-use')
1516
+ const value = withHookCtx(() => use(Ctx))
1517
+ expect(value).toBe('from-use')
1518
+ })
1519
+ })