@pyreon/react-compat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +131 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +129 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +73 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +54 -0
- package/src/dom.ts +39 -0
- package/src/index.ts +276 -0
- package/src/tests/react-compat.test.ts +708 -0
- package/src/tests/setup.ts +3 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import { h } from "@pyreon/core"
|
|
2
|
+
import { effect, signal } from "@pyreon/reactivity"
|
|
3
|
+
import { mount } from "@pyreon/runtime-dom"
|
|
4
|
+
import { createRoot, render } from "../dom"
|
|
5
|
+
import {
|
|
6
|
+
batch,
|
|
7
|
+
createContext,
|
|
8
|
+
createElement,
|
|
9
|
+
createPortal,
|
|
10
|
+
createSelector,
|
|
11
|
+
ErrorBoundary,
|
|
12
|
+
Fragment,
|
|
13
|
+
lazy,
|
|
14
|
+
memo,
|
|
15
|
+
onMount,
|
|
16
|
+
onUnmount,
|
|
17
|
+
onUpdate,
|
|
18
|
+
Suspense,
|
|
19
|
+
useCallback,
|
|
20
|
+
useContext,
|
|
21
|
+
useDeferredValue,
|
|
22
|
+
useEffect,
|
|
23
|
+
useErrorBoundary,
|
|
24
|
+
useId,
|
|
25
|
+
useImperativeHandle,
|
|
26
|
+
useLayoutEffect,
|
|
27
|
+
useLayoutEffect_,
|
|
28
|
+
useMemo,
|
|
29
|
+
useReducer,
|
|
30
|
+
useRef,
|
|
31
|
+
useState,
|
|
32
|
+
useTransition,
|
|
33
|
+
} from "../index"
|
|
34
|
+
|
|
35
|
+
function container(): HTMLElement {
|
|
36
|
+
const el = document.createElement("div")
|
|
37
|
+
document.body.appendChild(el)
|
|
38
|
+
return el
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── useState ─────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
describe("useState", () => {
|
|
44
|
+
test("returns [getter, setter] — getter reads initial value", () => {
|
|
45
|
+
const [count] = useState(0)
|
|
46
|
+
expect(count()).toBe(0)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("setter updates value", () => {
|
|
50
|
+
const [count, setCount] = useState(0)
|
|
51
|
+
setCount(5)
|
|
52
|
+
expect(count()).toBe(5)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("setter with function updater", () => {
|
|
56
|
+
const [count, setCount] = useState(10)
|
|
57
|
+
setCount((prev) => prev + 1)
|
|
58
|
+
expect(count()).toBe(11)
|
|
59
|
+
setCount((prev) => prev * 2)
|
|
60
|
+
expect(count()).toBe(22)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("initializer function is called once", () => {
|
|
64
|
+
let calls = 0
|
|
65
|
+
const [val] = useState(() => {
|
|
66
|
+
calls++
|
|
67
|
+
return 42
|
|
68
|
+
})
|
|
69
|
+
expect(val()).toBe(42)
|
|
70
|
+
expect(calls).toBe(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
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)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// ─── useReducer ───────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe("useReducer", () => {
|
|
88
|
+
test("dispatch applies reducer", () => {
|
|
89
|
+
type Action = { type: "inc" } | { type: "dec" }
|
|
90
|
+
const reducer = (state: number, action: Action) =>
|
|
91
|
+
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)
|
|
96
|
+
dispatch({ type: "inc" })
|
|
97
|
+
expect(state()).toBe(2)
|
|
98
|
+
dispatch({ type: "dec" })
|
|
99
|
+
expect(state()).toBe(1)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test("initializer function is called once", () => {
|
|
103
|
+
let calls = 0
|
|
104
|
+
const [state] = useReducer(
|
|
105
|
+
(s: number) => s,
|
|
106
|
+
() => {
|
|
107
|
+
calls++
|
|
108
|
+
return 99
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
expect(state()).toBe(99)
|
|
112
|
+
expect(calls).toBe(1)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// ─── useEffect ────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe("useEffect", () => {
|
|
119
|
+
test("runs reactively when signals change", () => {
|
|
120
|
+
const el = container()
|
|
121
|
+
const s = signal(0)
|
|
122
|
+
let runs = 0
|
|
123
|
+
|
|
124
|
+
const Comp = () => {
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
s() // read signal to track
|
|
127
|
+
runs++
|
|
128
|
+
})
|
|
129
|
+
return h("div", null, "test")
|
|
130
|
+
}
|
|
131
|
+
|
|
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)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test("with empty deps [] — runs once on mount only", () => {
|
|
141
|
+
const el = container()
|
|
142
|
+
const s = signal(0)
|
|
143
|
+
let runs = 0
|
|
144
|
+
|
|
145
|
+
const Comp = () => {
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
s()
|
|
148
|
+
runs++
|
|
149
|
+
}, [])
|
|
150
|
+
return h("div", null, "test")
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
mount(h(Comp, null), el)
|
|
154
|
+
expect(runs).toBe(1)
|
|
155
|
+
s.set(1)
|
|
156
|
+
expect(runs).toBe(1)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test("with empty deps [] and cleanup on unmount", () => {
|
|
160
|
+
const el = container()
|
|
161
|
+
let cleaned = false
|
|
162
|
+
|
|
163
|
+
const Comp = () => {
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
return () => {
|
|
166
|
+
cleaned = true
|
|
167
|
+
}
|
|
168
|
+
}, [])
|
|
169
|
+
return h("div", null, "test")
|
|
170
|
+
}
|
|
171
|
+
|
|
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)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test("with empty deps [] and no cleanup return", () => {
|
|
181
|
+
const el = container()
|
|
182
|
+
let runs = 0
|
|
183
|
+
|
|
184
|
+
const Comp = () => {
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
runs++
|
|
187
|
+
// no return
|
|
188
|
+
}, [])
|
|
189
|
+
return h("div", null, "test")
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const unmount = mount(h(Comp, null), el)
|
|
193
|
+
expect(runs).toBe(1)
|
|
194
|
+
unmount()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test("effect without deps re-runs and disposes on unmount", () => {
|
|
198
|
+
const el = container()
|
|
199
|
+
const s = signal(0)
|
|
200
|
+
let runs = 0
|
|
201
|
+
|
|
202
|
+
const Comp = () => {
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
s()
|
|
205
|
+
runs++
|
|
206
|
+
})
|
|
207
|
+
return h("div", null, "test")
|
|
208
|
+
}
|
|
209
|
+
|
|
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)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test("effect with cleanup function disposes on unmount", () => {
|
|
220
|
+
const el = container()
|
|
221
|
+
const s = signal(0)
|
|
222
|
+
let effectRuns = 0
|
|
223
|
+
|
|
224
|
+
const Comp = () => {
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
s()
|
|
227
|
+
effectRuns++
|
|
228
|
+
return () => {
|
|
229
|
+
/* cleanup */
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
return h("div", null, "cleanup")
|
|
233
|
+
}
|
|
234
|
+
|
|
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)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test("non-function return from effect is handled", () => {
|
|
245
|
+
const el = container()
|
|
246
|
+
const s = signal(0)
|
|
247
|
+
let runs = 0
|
|
248
|
+
|
|
249
|
+
const Comp = () => {
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
s()
|
|
252
|
+
runs++
|
|
253
|
+
})
|
|
254
|
+
return h("div", null, "no-cleanup")
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const unmount = mount(h(Comp, null), el)
|
|
258
|
+
expect(runs).toBe(1)
|
|
259
|
+
s.set(1)
|
|
260
|
+
expect(runs).toBe(2)
|
|
261
|
+
unmount()
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// ─── useMemo ──────────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
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)
|
|
274
|
+
})
|
|
275
|
+
|
|
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)
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
// ─── useCallback ──────────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
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)
|
|
299
|
+
})
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
// ─── useRef ───────────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
describe("useRef", () => {
|
|
305
|
+
test("returns { current } with null default", () => {
|
|
306
|
+
const ref = useRef<HTMLDivElement>()
|
|
307
|
+
expect(ref.current).toBeNull()
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test("returns { current } with initial value", () => {
|
|
311
|
+
const ref = useRef(42)
|
|
312
|
+
expect(ref.current).toBe(42)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
test("current is mutable", () => {
|
|
316
|
+
const ref = useRef(0)
|
|
317
|
+
ref.current = 10
|
|
318
|
+
expect(ref.current).toBe(10)
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
// ─── memo ─────────────────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
describe("memo", () => {
|
|
325
|
+
test("returns component as-is (no-op)", () => {
|
|
326
|
+
const MyComp = (props: { name: string }) => h("span", null, props.name)
|
|
327
|
+
const Memoized = memo(MyComp)
|
|
328
|
+
expect(Memoized).toBe(MyComp)
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
// ─── useTransition ────────────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
describe("useTransition", () => {
|
|
335
|
+
test("returns [false, fn => fn()]", () => {
|
|
336
|
+
const [isPending, startTransition] = useTransition()
|
|
337
|
+
expect(isPending).toBe(false)
|
|
338
|
+
let ran = false
|
|
339
|
+
startTransition(() => {
|
|
340
|
+
ran = true
|
|
341
|
+
})
|
|
342
|
+
expect(ran).toBe(true)
|
|
343
|
+
})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
// ─── useDeferredValue ─────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
describe("useDeferredValue", () => {
|
|
349
|
+
test("returns value as-is", () => {
|
|
350
|
+
expect(useDeferredValue(42)).toBe(42)
|
|
351
|
+
expect(useDeferredValue("hello")).toBe("hello")
|
|
352
|
+
const obj = { a: 1 }
|
|
353
|
+
expect(useDeferredValue(obj)).toBe(obj)
|
|
354
|
+
})
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
// ─── useId ────────────────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
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", () => {
|
|
370
|
+
const el = container()
|
|
371
|
+
const ids: string[] = []
|
|
372
|
+
|
|
373
|
+
const Comp = () => {
|
|
374
|
+
ids.push(useId())
|
|
375
|
+
ids.push(useId())
|
|
376
|
+
return h("div", null, "id-test")
|
|
377
|
+
}
|
|
378
|
+
|
|
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()
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
test("different components get independent counters", () => {
|
|
387
|
+
const el = container()
|
|
388
|
+
const ids1: string[] = []
|
|
389
|
+
const ids2: string[] = []
|
|
390
|
+
|
|
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")
|
|
398
|
+
}
|
|
399
|
+
|
|
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()
|
|
404
|
+
})
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
// ─── createPortal ─────────────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
describe("createPortal", () => {
|
|
410
|
+
test("creates a portal VNode that renders into target", () => {
|
|
411
|
+
const src = container()
|
|
412
|
+
const target = container()
|
|
413
|
+
mount(createPortal(h("span", null, "portaled"), target), src)
|
|
414
|
+
expect(target.querySelector("span")?.textContent).toBe("portaled")
|
|
415
|
+
expect(src.querySelector("span")).toBeNull()
|
|
416
|
+
})
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
// ─── lazy ─────────────────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
describe("lazy", () => {
|
|
422
|
+
test("returns a component that loads async", async () => {
|
|
423
|
+
const MyComp = (props: { text: string }) => h("p", null, props.text)
|
|
424
|
+
const Lazy = lazy(() => Promise.resolve({ default: MyComp }))
|
|
425
|
+
|
|
426
|
+
expect(Lazy({ text: "hello" })).toBeNull()
|
|
427
|
+
|
|
428
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
429
|
+
const result = Lazy({ text: "hello" })
|
|
430
|
+
expect(result).not.toBeNull()
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
test("__loading reports loading state", async () => {
|
|
434
|
+
const MyComp = () => h("div", null, "loaded")
|
|
435
|
+
const Lazy = lazy(() => Promise.resolve({ default: MyComp }))
|
|
436
|
+
expect(Lazy.__loading()).toBe(true)
|
|
437
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
438
|
+
expect(Lazy.__loading()).toBe(false)
|
|
439
|
+
})
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
// ─── batch ────────────────────────────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
describe("batch", () => {
|
|
445
|
+
test("groups multiple signal updates into one flush", () => {
|
|
446
|
+
const a = signal(0)
|
|
447
|
+
const b = signal(0)
|
|
448
|
+
let runs = 0
|
|
449
|
+
effect(() => {
|
|
450
|
+
a()
|
|
451
|
+
b()
|
|
452
|
+
runs++
|
|
453
|
+
})
|
|
454
|
+
expect(runs).toBe(1)
|
|
455
|
+
batch(() => {
|
|
456
|
+
a.set(1)
|
|
457
|
+
b.set(1)
|
|
458
|
+
})
|
|
459
|
+
expect(runs).toBe(2)
|
|
460
|
+
})
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
// ─── createRoot (dom.ts) ──────────────────────────────────────────────────────
|
|
464
|
+
|
|
465
|
+
describe("createRoot", () => {
|
|
466
|
+
test("render mounts element into container", () => {
|
|
467
|
+
const el = container()
|
|
468
|
+
const root = createRoot(el)
|
|
469
|
+
root.render(h("div", { id: "root-test" }, "hello"))
|
|
470
|
+
expect(el.querySelector("#root-test")?.textContent).toBe("hello")
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
test("unmount removes content", () => {
|
|
474
|
+
const el = container()
|
|
475
|
+
const root = createRoot(el)
|
|
476
|
+
root.render(h("p", null, "mounted"))
|
|
477
|
+
expect(el.querySelector("p")).not.toBeNull()
|
|
478
|
+
root.unmount()
|
|
479
|
+
expect(el.innerHTML).toBe("")
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
test("re-render replaces previous content", () => {
|
|
483
|
+
const el = container()
|
|
484
|
+
const root = createRoot(el)
|
|
485
|
+
root.render(h("span", null, "first"))
|
|
486
|
+
expect(el.textContent).toBe("first")
|
|
487
|
+
root.render(h("span", null, "second"))
|
|
488
|
+
expect(el.textContent).toBe("second")
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
test("unmount after unmount is safe (no-op)", () => {
|
|
492
|
+
const el = container()
|
|
493
|
+
const root = createRoot(el)
|
|
494
|
+
root.render(h("div", null, "x"))
|
|
495
|
+
root.unmount()
|
|
496
|
+
root.unmount()
|
|
497
|
+
expect(el.innerHTML).toBe("")
|
|
498
|
+
})
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
// ─── render (dom.ts) ──────────────────────────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
describe("render", () => {
|
|
504
|
+
test("mounts element into container", () => {
|
|
505
|
+
const el = container()
|
|
506
|
+
render(h("div", { id: "render-test" }, "world"), el)
|
|
507
|
+
expect(el.querySelector("#render-test")?.textContent).toBe("world")
|
|
508
|
+
})
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
// ─── useImperativeHandle ─────────────────────────────────────────────────────
|
|
512
|
+
|
|
513
|
+
describe("useImperativeHandle", () => {
|
|
514
|
+
test("sets ref.current on mount", () => {
|
|
515
|
+
const el = container()
|
|
516
|
+
const ref = { current: null as { greet: () => string } | null }
|
|
517
|
+
|
|
518
|
+
const Comp = () => {
|
|
519
|
+
useImperativeHandle(ref, () => ({
|
|
520
|
+
greet: () => "hello",
|
|
521
|
+
}))
|
|
522
|
+
return h("div", null, "imp")
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const unmount = mount(h(Comp, null), el)
|
|
526
|
+
expect(ref.current).not.toBeNull()
|
|
527
|
+
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
|
+
})
|
|
546
|
+
|
|
547
|
+
test("no-op when ref is null", () => {
|
|
548
|
+
const el = container()
|
|
549
|
+
|
|
550
|
+
const Comp = () => {
|
|
551
|
+
useImperativeHandle(null, () => ({ value: 42 }))
|
|
552
|
+
return h("div", null, "no-ref")
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const unmount = mount(h(Comp, null), el)
|
|
556
|
+
unmount()
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
test("no-op when ref is undefined", () => {
|
|
560
|
+
const el = container()
|
|
561
|
+
|
|
562
|
+
const Comp = () => {
|
|
563
|
+
useImperativeHandle(undefined, () => ({ value: 42 }))
|
|
564
|
+
return h("div", null, "undef-ref")
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const unmount = mount(h(Comp, null), el)
|
|
568
|
+
unmount()
|
|
569
|
+
})
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
// ─── Re-exports ───────────────────────────────────────────────────────────────
|
|
573
|
+
|
|
574
|
+
describe("re-exports", () => {
|
|
575
|
+
test("createElement is h", () => {
|
|
576
|
+
expect(createElement).toBe(h)
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
test("Fragment is exported", () => {
|
|
580
|
+
expect(typeof Fragment).toBe("symbol")
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
test("createContext creates context with default", () => {
|
|
584
|
+
const Ctx = createContext("default")
|
|
585
|
+
expect(useContext(Ctx)).toBe("default")
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
test("useContext reads from context", () => {
|
|
589
|
+
const Ctx = createContext(42)
|
|
590
|
+
expect(useContext(Ctx)).toBe(42)
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
test("Suspense is exported", () => {
|
|
594
|
+
expect(typeof Suspense).toBe("function")
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
test("ErrorBoundary is exported", () => {
|
|
598
|
+
expect(typeof ErrorBoundary).toBe("function")
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
test("useErrorBoundary is a function", () => {
|
|
602
|
+
expect(typeof useErrorBoundary).toBe("function")
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
test("createSelector is a function", () => {
|
|
606
|
+
expect(typeof createSelector).toBe("function")
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
test("onMount is a function", () => {
|
|
610
|
+
expect(typeof onMount).toBe("function")
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
test("onUnmount is a function", () => {
|
|
614
|
+
expect(typeof onUnmount).toBe("function")
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
test("onUpdate is a function", () => {
|
|
618
|
+
expect(typeof onUpdate).toBe("function")
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
test("useLayoutEffect is exported from core", () => {
|
|
622
|
+
expect(typeof useLayoutEffect).toBe("function")
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
test("useLayoutEffect_ is same as useEffect", () => {
|
|
626
|
+
expect(useLayoutEffect_).toBe(useEffect)
|
|
627
|
+
})
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
// ─── Additional coverage: useId outside scope ──────────────────────────────
|
|
631
|
+
|
|
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)
|
|
642
|
+
})
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
// ─── Additional coverage: useEffect with non-empty deps ─────────────────────
|
|
646
|
+
|
|
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
|
|
652
|
+
|
|
653
|
+
const Comp = () => {
|
|
654
|
+
useEffect(() => {
|
|
655
|
+
s()
|
|
656
|
+
runs++
|
|
657
|
+
}, [s])
|
|
658
|
+
return h("div", null, "non-empty-deps")
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const unmount = mount(h(Comp, null), el)
|
|
662
|
+
expect(runs).toBe(1)
|
|
663
|
+
s.set(1)
|
|
664
|
+
expect(runs).toBe(2)
|
|
665
|
+
unmount()
|
|
666
|
+
})
|
|
667
|
+
|
|
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
|
|
672
|
+
|
|
673
|
+
const Comp = () => {
|
|
674
|
+
useEffect(() => {
|
|
675
|
+
s()
|
|
676
|
+
return () => {
|
|
677
|
+
cleanups++
|
|
678
|
+
}
|
|
679
|
+
}, [s])
|
|
680
|
+
return h("div", null, "cleanup-deps")
|
|
681
|
+
}
|
|
682
|
+
|
|
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()
|
|
688
|
+
})
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
// ─── Additional coverage: useState with non-function initial ─────────────────
|
|
692
|
+
|
|
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")
|
|
698
|
+
})
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
// ─── Additional coverage: useReducer with non-function initial ───────────────
|
|
702
|
+
|
|
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")
|
|
707
|
+
})
|
|
708
|
+
})
|