@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.
- package/README.md +20 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/analysis/jsx-runtime.js.html +1 -1
- package/lib/index.js +370 -40
- package/lib/jsx-runtime.js +57 -5
- package/lib/types/index.d.ts +205 -4
- package/package.json +8 -4
- package/src/env.d.ts +6 -0
- package/src/index.ts +549 -52
- package/src/jsx-runtime.ts +93 -2
- package/src/react-compat-rerender.browser.test.tsx +59 -0
- package/src/react-compat.browser.test.tsx +34 -0
- package/src/tests/compat-integration.test.tsx +1 -0
- package/src/tests/native-marker-bypass.test.tsx +88 -0
- package/src/tests/new-apis.test.ts +1519 -0
- package/src/tests/react-compat.test.ts +2 -0
- package/lib/dom.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/jsx-runtime.js.map +0 -1
- package/lib/types/dom.d.ts.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
- package/lib/types/jsx-runtime.d.ts.map +0 -1
|
@@ -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
|
+
})
|