@pyreon/react-compat 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +159 -76
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +159 -74
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index2.d.ts +34 -41
- package/lib/types/index2.d.ts.map +1 -1
- package/package.json +14 -4
- package/src/index.ts +196 -175
- package/src/jsx-runtime.ts +169 -0
- package/src/tests/react-compat.test.ts +457 -290
|
@@ -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 [
|
|
45
|
-
const [count] = useState(0)
|
|
46
|
-
expect(count
|
|
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
|
|
80
|
+
test("setter updates value on re-render", () => {
|
|
81
|
+
const runner = createHookRunner()
|
|
82
|
+
const [, setCount] = runner.run(() => useState(0))
|
|
51
83
|
setCount(5)
|
|
52
|
-
|
|
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
|
|
89
|
+
const runner = createHookRunner()
|
|
90
|
+
const [, setCount] = runner.run(() => useState(10))
|
|
57
91
|
setCount((prev) => prev + 1)
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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("
|
|
74
|
-
const
|
|
75
|
-
let
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
setCount(
|
|
81
|
-
expect(
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
166
|
+
|
|
167
|
+
const [state0, dispatch] = runner.run(() => useReducer(reducer, 0))
|
|
168
|
+
expect(state0).toBe(0)
|
|
169
|
+
|
|
96
170
|
dispatch({ type: "inc" })
|
|
97
|
-
|
|
171
|
+
const [state1] = runner.run(() => useReducer(reducer, 0))
|
|
172
|
+
expect(state1).toBe(1)
|
|
173
|
+
|
|
98
174
|
dispatch({ type: "dec" })
|
|
99
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
(
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
221
|
+
test("effect runs after render via compat JSX runtime", async () => {
|
|
120
222
|
const el = container()
|
|
121
|
-
|
|
122
|
-
let runs = 0
|
|
223
|
+
let effectRuns = 0
|
|
123
224
|
|
|
124
225
|
const Comp = () => {
|
|
125
226
|
useEffect(() => {
|
|
126
|
-
|
|
127
|
-
runs++
|
|
227
|
+
effectRuns++
|
|
128
228
|
})
|
|
129
229
|
return h("div", null, "test")
|
|
130
230
|
}
|
|
131
231
|
|
|
132
|
-
mount(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
expect(
|
|
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
|
|
238
|
+
test("effect with empty deps runs once", async () => {
|
|
141
239
|
const el = container()
|
|
142
|
-
|
|
143
|
-
let
|
|
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
|
-
|
|
148
|
-
runs++
|
|
247
|
+
effectRuns++
|
|
149
248
|
}, [])
|
|
150
|
-
return h("div", null,
|
|
249
|
+
return h("div", null, String(count))
|
|
151
250
|
}
|
|
152
251
|
|
|
153
|
-
mount(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
263
|
+
test("effect with deps re-runs when deps change", async () => {
|
|
160
264
|
const el = container()
|
|
161
|
-
let
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}, [])
|
|
169
|
-
return h("div", null, "test")
|
|
272
|
+
effectRuns++
|
|
273
|
+
}, [count])
|
|
274
|
+
return h("div", null, String(count))
|
|
170
275
|
}
|
|
171
276
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
//
|
|
177
|
-
|
|
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("
|
|
289
|
+
test("effect cleanup runs before re-execution", async () => {
|
|
181
290
|
const el = container()
|
|
182
|
-
let
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
298
|
+
return () => {
|
|
299
|
+
cleanups++
|
|
300
|
+
}
|
|
301
|
+
}, [count])
|
|
302
|
+
return h("div", null, String(count))
|
|
190
303
|
}
|
|
191
304
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
})
|
|
305
|
+
mount(jsx(Comp, {}), el)
|
|
306
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
307
|
+
expect(cleanups).toBe(0)
|
|
196
308
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
316
|
+
test("pendingEffects populated during render", () => {
|
|
317
|
+
const runner = createHookRunner()
|
|
318
|
+
runner.run(() => {
|
|
203
319
|
useEffect(() => {
|
|
204
|
-
|
|
205
|
-
runs++
|
|
320
|
+
/* noop */
|
|
206
321
|
})
|
|
207
|
-
|
|
208
|
-
|
|
322
|
+
})
|
|
323
|
+
expect(runner.ctx.pendingEffects).toHaveLength(1)
|
|
324
|
+
})
|
|
209
325
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
s()
|
|
349
|
+
useLayoutEffect(() => {
|
|
227
350
|
effectRuns++
|
|
228
|
-
return () => {
|
|
229
|
-
/* cleanup */
|
|
230
|
-
}
|
|
231
351
|
})
|
|
232
|
-
return h("div", null, "
|
|
352
|
+
return h("div", null, "layout")
|
|
233
353
|
}
|
|
234
354
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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("
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
expect(
|
|
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
|
|
269
|
-
const
|
|
270
|
-
|
|
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("
|
|
277
|
-
const
|
|
278
|
-
const
|
|
279
|
-
expect(
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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("
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
expect(ids
|
|
382
|
-
|
|
383
|
-
|
|
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("
|
|
554
|
+
test("IDs are stable across re-renders", async () => {
|
|
387
555
|
const el = container()
|
|
388
|
-
const
|
|
389
|
-
|
|
556
|
+
const idHistory: string[] = []
|
|
557
|
+
let triggerSet: (v: number) => void = () => {}
|
|
390
558
|
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
602
|
-
expect(typeof
|
|
757
|
+
test("useLayoutEffect is a function", () => {
|
|
758
|
+
expect(typeof useLayoutEffect).toBe("function")
|
|
603
759
|
})
|
|
760
|
+
})
|
|
604
761
|
|
|
605
|
-
|
|
606
|
-
expect(typeof createSelector).toBe("function")
|
|
607
|
-
})
|
|
762
|
+
// ─── jsx-runtime ──────────────────────────────────────────────────────────────
|
|
608
763
|
|
|
609
|
-
|
|
610
|
-
|
|
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("
|
|
614
|
-
|
|
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("
|
|
618
|
-
|
|
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("
|
|
622
|
-
|
|
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("
|
|
626
|
-
|
|
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
|
-
|
|
795
|
+
test("jsx with no children", () => {
|
|
796
|
+
const vnode = jsx("div", {})
|
|
797
|
+
expect(vnode.children).toHaveLength(0)
|
|
798
|
+
})
|
|
631
799
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
// ───
|
|
807
|
+
// ─── Hook error when called outside component ────────────────────────────────
|
|
646
808
|
|
|
647
|
-
describe("
|
|
648
|
-
test("
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
662
|
-
expect(
|
|
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("
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
let cleanups = 0
|
|
822
|
+
test("useMemo throws when called outside render", () => {
|
|
823
|
+
expect(() => useMemo(() => 0, [])).toThrow("Hook called outside")
|
|
824
|
+
})
|
|
672
825
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
684
|
-
expect(
|
|
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
|
-
// ───
|
|
835
|
+
// ─── Edge cases ──────────────────────────────────────────────────────────────
|
|
692
836
|
|
|
693
|
-
describe("
|
|
694
|
-
test("with
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
})
|