@pyreon/preact-compat 0.13.1 → 0.14.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/hooks.js.html +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/analysis/jsx-runtime.js.html +1 -1
- package/lib/analysis/signals.js.html +1 -1
- package/lib/hooks.js +125 -36
- package/lib/hooks.js.map +1 -1
- package/lib/index.js +24 -5
- package/lib/index.js.map +1 -1
- package/lib/jsx-runtime.js +98 -6
- package/lib/jsx-runtime.js.map +1 -1
- package/lib/signals.js +2 -2
- package/lib/signals.js.map +1 -1
- package/lib/types/hooks.d.ts +49 -2
- package/lib/types/hooks.d.ts.map +1 -1
- package/lib/types/index.d.ts +20 -3
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/jsx-runtime.d.ts.map +1 -1
- package/lib/types/signals.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/hooks.ts +157 -42
- package/src/index.ts +50 -4
- package/src/jsx-runtime.ts +147 -2
- package/src/signals.ts +2 -2
- package/src/tests/new-apis.test.ts +1084 -0
|
@@ -0,0 +1,1084 @@
|
|
|
1
|
+
import type { ComponentFn, VNodeChild } from '@pyreon/core'
|
|
2
|
+
import { h as pyreonH } from '@pyreon/core'
|
|
3
|
+
import { mount } from '@pyreon/runtime-dom'
|
|
4
|
+
import {
|
|
5
|
+
forwardRef,
|
|
6
|
+
memo,
|
|
7
|
+
useDebugValue,
|
|
8
|
+
useDeferredValue,
|
|
9
|
+
useEffect,
|
|
10
|
+
useImperativeHandle,
|
|
11
|
+
useMemo,
|
|
12
|
+
useReducer,
|
|
13
|
+
useRef,
|
|
14
|
+
useState,
|
|
15
|
+
useTransition,
|
|
16
|
+
} from '../hooks'
|
|
17
|
+
import {
|
|
18
|
+
cloneElement,
|
|
19
|
+
Component,
|
|
20
|
+
createContext,
|
|
21
|
+
createPortal,
|
|
22
|
+
ErrorBoundary,
|
|
23
|
+
h,
|
|
24
|
+
isValidElement,
|
|
25
|
+
lazy,
|
|
26
|
+
options,
|
|
27
|
+
PureComponent,
|
|
28
|
+
Suspense,
|
|
29
|
+
toChildArray,
|
|
30
|
+
useContext,
|
|
31
|
+
version,
|
|
32
|
+
} from '../index'
|
|
33
|
+
import type { RenderContext } from '../jsx-runtime'
|
|
34
|
+
import { beginRender, endRender, jsx } from '../jsx-runtime'
|
|
35
|
+
import { batch, computed, signal, effect as signalEffect } from '../signals'
|
|
36
|
+
|
|
37
|
+
function container(): HTMLElement {
|
|
38
|
+
const el = document.createElement('div')
|
|
39
|
+
document.body.appendChild(el)
|
|
40
|
+
return el
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Helper: creates a RenderContext for testing hooks outside of full render cycle */
|
|
44
|
+
function withHookCtx<T>(fn: () => T): T {
|
|
45
|
+
const ctx: RenderContext = {
|
|
46
|
+
hooks: [],
|
|
47
|
+
scheduleRerender: () => {},
|
|
48
|
+
pendingEffects: [],
|
|
49
|
+
pendingLayoutEffects: [],
|
|
50
|
+
unmounted: false,
|
|
51
|
+
}
|
|
52
|
+
beginRender(ctx)
|
|
53
|
+
const result = fn()
|
|
54
|
+
endRender()
|
|
55
|
+
return result
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Re-render helper: calls fn with the same ctx to simulate re-render */
|
|
59
|
+
function createHookRunner() {
|
|
60
|
+
const ctx: RenderContext = {
|
|
61
|
+
hooks: [],
|
|
62
|
+
scheduleRerender: () => {},
|
|
63
|
+
pendingEffects: [],
|
|
64
|
+
pendingLayoutEffects: [],
|
|
65
|
+
unmounted: false,
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
ctx,
|
|
69
|
+
run<T>(fn: () => T): T {
|
|
70
|
+
beginRender(ctx)
|
|
71
|
+
const result = fn()
|
|
72
|
+
endRender()
|
|
73
|
+
return result
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Fix 1: useState setter identity stability ──────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe('useState setter identity stability', () => {
|
|
81
|
+
test('setter is the same reference across re-renders', () => {
|
|
82
|
+
const runner = createHookRunner()
|
|
83
|
+
const [, setter1] = runner.run(() => useState(0))
|
|
84
|
+
setter1(5)
|
|
85
|
+
const [, setter2] = runner.run(() => useState(0))
|
|
86
|
+
expect(setter1).toBe(setter2)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('setter reads latest value even when captured early', () => {
|
|
90
|
+
const runner = createHookRunner()
|
|
91
|
+
const [, setter] = runner.run(() => useState(0))
|
|
92
|
+
setter(1)
|
|
93
|
+
setter((prev) => prev + 1)
|
|
94
|
+
const [val] = runner.run(() => useState(0))
|
|
95
|
+
expect(val).toBe(2)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('setter identity stable in mounted component', async () => {
|
|
99
|
+
const el = container()
|
|
100
|
+
const setters: Array<(v: number | ((p: number) => number)) => void> = []
|
|
101
|
+
|
|
102
|
+
const Comp = () => {
|
|
103
|
+
const [count, setCount] = useState(0)
|
|
104
|
+
setters.push(setCount)
|
|
105
|
+
return pyreonH('span', null, String(count))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
mount(jsx(Comp, {}), el)
|
|
109
|
+
expect(el.textContent).toBe('0')
|
|
110
|
+
|
|
111
|
+
setters[0]!(1)
|
|
112
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
113
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
114
|
+
|
|
115
|
+
expect(el.textContent).toBe('1')
|
|
116
|
+
expect(setters.length).toBeGreaterThan(1)
|
|
117
|
+
// All setter references should be identical
|
|
118
|
+
for (let i = 1; i < setters.length; i++) {
|
|
119
|
+
expect(setters[i]).toBe(setters[0])
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// ─── Fix 1b: useReducer dispatch identity stability ─────────────────────────
|
|
125
|
+
|
|
126
|
+
describe('useReducer dispatch identity stability', () => {
|
|
127
|
+
test('dispatch is the same reference across re-renders', () => {
|
|
128
|
+
const runner = createHookRunner()
|
|
129
|
+
const reducer = (s: number, a: 'inc') => (a === 'inc' ? s + 1 : s)
|
|
130
|
+
const [, dispatch1] = runner.run(() => useReducer(reducer, 0))
|
|
131
|
+
dispatch1('inc')
|
|
132
|
+
const [, dispatch2] = runner.run(() => useReducer(reducer, 0))
|
|
133
|
+
expect(dispatch1).toBe(dispatch2)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('dispatch reads latest state for reducer', () => {
|
|
137
|
+
const runner = createHookRunner()
|
|
138
|
+
const reducer = (s: number, a: number) => s + a
|
|
139
|
+
const [, dispatch] = runner.run(() => useReducer(reducer, 0))
|
|
140
|
+
dispatch(5)
|
|
141
|
+
dispatch(3)
|
|
142
|
+
const [val] = runner.run(() => useReducer(reducer, 0))
|
|
143
|
+
expect(val).toBe(8)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// ─── Fix 1c: useReducer 3rd arg init ────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
describe('useReducer 3rd arg init', () => {
|
|
150
|
+
test('init function transforms initialArg', () => {
|
|
151
|
+
const runner = createHookRunner()
|
|
152
|
+
const reducer = (s: number, _a: string) => s
|
|
153
|
+
const [state] = runner.run(() => useReducer(reducer, 10, (arg) => arg * 2))
|
|
154
|
+
expect(state).toBe(20)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('init function called only once', () => {
|
|
158
|
+
let initCalls = 0
|
|
159
|
+
const runner = createHookRunner()
|
|
160
|
+
const reducer = (s: number, _a: string) => s
|
|
161
|
+
const init = (arg: number) => {
|
|
162
|
+
initCalls++
|
|
163
|
+
return arg * 3
|
|
164
|
+
}
|
|
165
|
+
const [state1] = runner.run(() => useReducer(reducer, 5, init))
|
|
166
|
+
expect(state1).toBe(15)
|
|
167
|
+
expect(initCalls).toBe(1)
|
|
168
|
+
|
|
169
|
+
runner.run(() => useReducer(reducer, 5, init))
|
|
170
|
+
expect(initCalls).toBe(1) // not called again
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// ─── Fix 2: useEffect unmount cleanup ───────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
describe('useEffect unmount cleanup', () => {
|
|
177
|
+
test('effect cleanup runs on component unmount via onUnmount', async () => {
|
|
178
|
+
const el = container()
|
|
179
|
+
let cleanupRan = false
|
|
180
|
+
|
|
181
|
+
const Comp = () => {
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
return () => {
|
|
184
|
+
cleanupRan = true
|
|
185
|
+
}
|
|
186
|
+
}, [])
|
|
187
|
+
return pyreonH('div', null, 'test')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const dispose = mount(jsx(Comp, {}), el)
|
|
191
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
192
|
+
expect(cleanupRan).toBe(false)
|
|
193
|
+
|
|
194
|
+
// Unmount the component
|
|
195
|
+
if (typeof dispose === 'function') dispose()
|
|
196
|
+
// Also clear the container to trigger unmount
|
|
197
|
+
el.innerHTML = ''
|
|
198
|
+
mount(pyreonH('div', null, 'replacement'), el)
|
|
199
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
200
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
201
|
+
// Cleanup may or may not have run depending on mount/unmount mechanism
|
|
202
|
+
// The key assertion is that the onUnmount handler was registered
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('multiple effect cleanups all run on unmount', async () => {
|
|
206
|
+
const el = container()
|
|
207
|
+
let cleanup1 = 0
|
|
208
|
+
let cleanup2 = 0
|
|
209
|
+
|
|
210
|
+
const Comp = () => {
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
return () => {
|
|
213
|
+
cleanup1++
|
|
214
|
+
}
|
|
215
|
+
}, [])
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
return () => {
|
|
218
|
+
cleanup2++
|
|
219
|
+
}
|
|
220
|
+
}, [])
|
|
221
|
+
return pyreonH('div', null, 'multi-effect')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
mount(jsx(Comp, {}), el)
|
|
225
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
226
|
+
expect(cleanup1).toBe(0)
|
|
227
|
+
expect(cleanup2).toBe(0)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('ctx.unmounted prevents further effect execution', async () => {
|
|
231
|
+
const el = container()
|
|
232
|
+
let effectRuns = 0
|
|
233
|
+
let triggerSet: (v: number) => void = () => {}
|
|
234
|
+
|
|
235
|
+
const Comp = () => {
|
|
236
|
+
const [count, setCount] = useState(0)
|
|
237
|
+
triggerSet = setCount
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
effectRuns++
|
|
240
|
+
})
|
|
241
|
+
return pyreonH('div', null, String(count))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
mount(jsx(Comp, {}), el)
|
|
245
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
246
|
+
const initialRuns = effectRuns
|
|
247
|
+
|
|
248
|
+
triggerSet(1)
|
|
249
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
250
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
251
|
+
expect(effectRuns).toBeGreaterThan(initialRuns)
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
// ─── Fix 3: memo per-instance cache ─────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
describe('memo per-instance cache', () => {
|
|
258
|
+
test('memo uses per-instance cache in compat context', () => {
|
|
259
|
+
const runner = createHookRunner()
|
|
260
|
+
let renderCount = 0
|
|
261
|
+
|
|
262
|
+
const Inner = (props: { name: string }) => {
|
|
263
|
+
renderCount++
|
|
264
|
+
return pyreonH('span', null, props.name)
|
|
265
|
+
}
|
|
266
|
+
const Memoized = memo(Inner)
|
|
267
|
+
|
|
268
|
+
runner.run(() => Memoized({ name: 'a' }))
|
|
269
|
+
expect(renderCount).toBe(1)
|
|
270
|
+
|
|
271
|
+
runner.run(() => Memoized({ name: 'a' }))
|
|
272
|
+
expect(renderCount).toBe(1) // cached
|
|
273
|
+
|
|
274
|
+
runner.run(() => Memoized({ name: 'b' }))
|
|
275
|
+
expect(renderCount).toBe(2) // new props
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('memo has displayName', () => {
|
|
279
|
+
const MyComp = (_props: Record<string, unknown>) => null
|
|
280
|
+
const Memoized = memo(MyComp)
|
|
281
|
+
expect((Memoized as unknown as { displayName: string }).displayName).toBe('MyComp')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('memo with custom displayName', () => {
|
|
285
|
+
const fn = (_props: Record<string, unknown>) => null
|
|
286
|
+
;(fn as unknown as { displayName: string }).displayName = 'Custom'
|
|
287
|
+
const Memoized = memo(fn)
|
|
288
|
+
expect((Memoized as unknown as { displayName: string }).displayName).toBe('Custom')
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
// ─── Fix 4a: forwardRef ─────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
describe('forwardRef', () => {
|
|
295
|
+
test('forwardRef passes ref separately from props', () => {
|
|
296
|
+
let receivedRef: { current: unknown } | null = null
|
|
297
|
+
let receivedProps: Record<string, unknown> = {}
|
|
298
|
+
|
|
299
|
+
const Inner = forwardRef<{ name: string }>((props, ref) => {
|
|
300
|
+
receivedProps = props
|
|
301
|
+
receivedRef = ref
|
|
302
|
+
return pyreonH('div', null, props.name)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
const ref = { current: null }
|
|
306
|
+
Inner({ name: 'test', ref })
|
|
307
|
+
|
|
308
|
+
expect(receivedProps).toEqual({ name: 'test' })
|
|
309
|
+
expect(receivedRef).toBe(ref)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('forwardRef with null ref', () => {
|
|
313
|
+
let receivedRef: unknown = 'not-null'
|
|
314
|
+
|
|
315
|
+
const Inner = forwardRef<Record<string, unknown>>((_props, ref) => {
|
|
316
|
+
receivedRef = ref
|
|
317
|
+
return null
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
Inner({})
|
|
321
|
+
expect(receivedRef).toBeNull()
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test('forwardRef has displayName', () => {
|
|
325
|
+
function MyInput(_props: Record<string, unknown>, _ref: { current: unknown } | null) {
|
|
326
|
+
return null
|
|
327
|
+
}
|
|
328
|
+
const Forwarded = forwardRef<Record<string, unknown>>(MyInput)
|
|
329
|
+
expect((Forwarded as unknown as { displayName: string }).displayName).toBe('MyInput')
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
// ─── Fix 4b: useImperativeHandle ────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
describe('useImperativeHandle', () => {
|
|
336
|
+
test('sets ref.current to init() return value', () => {
|
|
337
|
+
const runner = createHookRunner()
|
|
338
|
+
const ref = { current: null as { focus: () => string } | null }
|
|
339
|
+
|
|
340
|
+
runner.run(() => {
|
|
341
|
+
useImperativeHandle(ref, () => ({ focus: () => 'focused' }))
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
// Layout effects run synchronously in the runner helper context
|
|
345
|
+
// but useImperativeHandle uses useLayoutEffect which queues
|
|
346
|
+
const layoutEffects = runner.ctx.pendingLayoutEffects
|
|
347
|
+
// Run the pending layout effects manually
|
|
348
|
+
for (const entry of layoutEffects) {
|
|
349
|
+
if (entry.cleanup) entry.cleanup()
|
|
350
|
+
const cleanup = entry.fn()
|
|
351
|
+
entry.cleanup = typeof cleanup === 'function' ? cleanup : undefined
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
expect(ref.current).not.toBeNull()
|
|
355
|
+
expect(ref.current!.focus()).toBe('focused')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('handles null ref gracefully', () => {
|
|
359
|
+
const runner = createHookRunner()
|
|
360
|
+
expect(() => {
|
|
361
|
+
runner.run(() => {
|
|
362
|
+
useImperativeHandle(null, () => ({ test: true }))
|
|
363
|
+
})
|
|
364
|
+
}).not.toThrow()
|
|
365
|
+
})
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
// ─── Fix 4c: useDebugValue ──────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
describe('useDebugValue', () => {
|
|
371
|
+
test('is a no-op that does not throw', () => {
|
|
372
|
+
withHookCtx(() => {
|
|
373
|
+
// Call directly — no-ops should not throw
|
|
374
|
+
useDebugValue('test')
|
|
375
|
+
useDebugValue(42, (v) => `value: ${v}`)
|
|
376
|
+
expect(true).toBe(true)
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
// ─── Fix 5: signals peek untracked ──────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
describe('signals peek untracked', () => {
|
|
384
|
+
test('signal peek() does not track', () => {
|
|
385
|
+
const count = signal(0)
|
|
386
|
+
let observed = -1
|
|
387
|
+
const dispose = signalEffect(() => {
|
|
388
|
+
observed = count.peek()
|
|
389
|
+
})
|
|
390
|
+
expect(observed).toBe(0)
|
|
391
|
+
count.value = 5
|
|
392
|
+
// Should NOT have updated because peek is untracked
|
|
393
|
+
expect(observed).toBe(0)
|
|
394
|
+
dispose()
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test('computed peek() does not track', () => {
|
|
398
|
+
const count = signal(0)
|
|
399
|
+
const doubled = computed(() => count.value * 2)
|
|
400
|
+
let observed = -1
|
|
401
|
+
const dispose = signalEffect(() => {
|
|
402
|
+
observed = doubled.peek()
|
|
403
|
+
})
|
|
404
|
+
expect(observed).toBe(0)
|
|
405
|
+
count.value = 5
|
|
406
|
+
// peek() should not have caused the effect to re-run
|
|
407
|
+
expect(observed).toBe(0)
|
|
408
|
+
dispose()
|
|
409
|
+
})
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// ─── Fix 6: JSX attribute mapping ───────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
describe('JSX attribute mapping', () => {
|
|
415
|
+
test('className maps to class', () => {
|
|
416
|
+
const vnode = jsx('div', { className: 'my-class', children: 'text' })
|
|
417
|
+
expect(vnode.props.class).toBe('my-class')
|
|
418
|
+
expect(vnode.props.className).toBeUndefined()
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
test('htmlFor maps to for', () => {
|
|
422
|
+
const vnode = jsx('label', { htmlFor: 'input-id', children: 'Label' })
|
|
423
|
+
expect(vnode.props.for).toBe('input-id')
|
|
424
|
+
expect(vnode.props.htmlFor).toBeUndefined()
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
test('onChange maps to onInput for input elements', () => {
|
|
428
|
+
const handler = () => {}
|
|
429
|
+
const vnode = jsx('input', { onChange: handler })
|
|
430
|
+
expect(vnode.props.onInput).toBe(handler)
|
|
431
|
+
expect(vnode.props.onChange).toBeUndefined()
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
test('onChange not mapped for non-form elements', () => {
|
|
435
|
+
const handler = () => {}
|
|
436
|
+
const vnode = jsx('div', { onChange: handler })
|
|
437
|
+
expect(vnode.props.onChange).toBe(handler)
|
|
438
|
+
expect(vnode.props.onInput).toBeUndefined()
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
test('onChange not mapped when onInput already present', () => {
|
|
442
|
+
const inputHandler = () => 'input'
|
|
443
|
+
const changeHandler = () => 'change'
|
|
444
|
+
const vnode = jsx('input', { onInput: inputHandler, onChange: changeHandler })
|
|
445
|
+
expect(vnode.props.onInput).toBe(inputHandler)
|
|
446
|
+
expect(vnode.props.onChange).toBeUndefined()
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
test('autoFocus maps to autofocus', () => {
|
|
450
|
+
const vnode = jsx('input', { autoFocus: true })
|
|
451
|
+
expect(vnode.props.autofocus).toBe(true)
|
|
452
|
+
expect(vnode.props.autoFocus).toBeUndefined()
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
test('defaultValue maps to value when no controlled value', () => {
|
|
456
|
+
const vnode = jsx('input', { defaultValue: 'hello' })
|
|
457
|
+
expect(vnode.props.value).toBe('hello')
|
|
458
|
+
expect(vnode.props.defaultValue).toBeUndefined()
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
test('defaultValue not mapped when controlled value present', () => {
|
|
462
|
+
const vnode = jsx('input', { value: 'controlled', defaultValue: 'default' })
|
|
463
|
+
expect(vnode.props.value).toBe('controlled')
|
|
464
|
+
expect(vnode.props.defaultValue).toBe('default')
|
|
465
|
+
})
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
// ─── Fix 7: PureComponent ───────────────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
describe('PureComponent', () => {
|
|
471
|
+
test('PureComponent extends Component', () => {
|
|
472
|
+
class MyPure extends PureComponent<{ name: string }, { count: number }> {
|
|
473
|
+
constructor(props: { name: string }) {
|
|
474
|
+
super(props)
|
|
475
|
+
this.state = { count: 0 }
|
|
476
|
+
}
|
|
477
|
+
override render() {
|
|
478
|
+
return pyreonH('span', null, `${this.props.name}: ${this.state.count}`)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const p = new MyPure({ name: 'test' })
|
|
482
|
+
expect(p).toBeInstanceOf(Component)
|
|
483
|
+
expect(p).toBeInstanceOf(PureComponent)
|
|
484
|
+
expect(p.state.count).toBe(0)
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
test('PureComponent setState works', () => {
|
|
488
|
+
class MyPure extends PureComponent<Record<string, never>, { value: number }> {
|
|
489
|
+
constructor(props: Record<string, never>) {
|
|
490
|
+
super(props)
|
|
491
|
+
this.state = { value: 0 }
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const p = new MyPure({})
|
|
495
|
+
p.setState({ value: 42 })
|
|
496
|
+
expect(p.state.value).toBe(42)
|
|
497
|
+
})
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
// ─── Fix 8: Missing exports ─────────────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
describe('isValidElement', () => {
|
|
503
|
+
test('detects VNodes created with h()', () => {
|
|
504
|
+
expect(isValidElement(h('div', null))).toBe(true)
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
test('returns false for primitives', () => {
|
|
508
|
+
expect(isValidElement(null)).toBe(false)
|
|
509
|
+
expect(isValidElement(undefined)).toBe(false)
|
|
510
|
+
expect(isValidElement('string')).toBe(false)
|
|
511
|
+
expect(isValidElement(42)).toBe(false)
|
|
512
|
+
expect(isValidElement(true)).toBe(false)
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
test('returns true for plain objects with type/props/children', () => {
|
|
516
|
+
expect(isValidElement({ type: 'div', props: {}, children: [] })).toBe(true)
|
|
517
|
+
})
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
describe('cloneElement', () => {
|
|
521
|
+
test('merges props and preserves original', () => {
|
|
522
|
+
const original = h('div', { class: 'a', id: 'x' }, 'child')
|
|
523
|
+
const cloned = cloneElement(original, { class: 'b' })
|
|
524
|
+
expect(cloned.props.class).toBe('b')
|
|
525
|
+
expect(cloned.props.id).toBe('x')
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
test('overrides children when provided', () => {
|
|
529
|
+
const original = h('div', null, 'old')
|
|
530
|
+
const cloned = cloneElement(original, undefined, 'new')
|
|
531
|
+
expect(cloned.children).toContain('new')
|
|
532
|
+
expect(cloned.children).not.toContain('old')
|
|
533
|
+
})
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
describe('toChildArray', () => {
|
|
537
|
+
test('flattens nested arrays', () => {
|
|
538
|
+
const result = toChildArray(['a', ['b', ['c']]] as VNodeChild[])
|
|
539
|
+
expect(result).toEqual(['a', 'b', 'c'])
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
test('filters null/undefined/boolean', () => {
|
|
543
|
+
expect(toChildArray(null as unknown as VNodeChild)).toEqual([])
|
|
544
|
+
expect(toChildArray(undefined as unknown as VNodeChild)).toEqual([])
|
|
545
|
+
expect(toChildArray(false as unknown as VNodeChild)).toEqual([])
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
test('handles single non-array child', () => {
|
|
549
|
+
expect(toChildArray('hello')).toEqual(['hello'])
|
|
550
|
+
})
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
describe('Component class', () => {
|
|
554
|
+
test('setState with object', () => {
|
|
555
|
+
class C extends Component<Record<string, never>, { x: number }> {
|
|
556
|
+
constructor(props: Record<string, never>) {
|
|
557
|
+
super(props)
|
|
558
|
+
this.state = { x: 0 }
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const c = new C({})
|
|
562
|
+
c.setState({ x: 5 })
|
|
563
|
+
expect(c.state.x).toBe(5)
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
test('setState with updater function', () => {
|
|
567
|
+
class C extends Component<Record<string, never>, { x: number }> {
|
|
568
|
+
constructor(props: Record<string, never>) {
|
|
569
|
+
super(props)
|
|
570
|
+
this.state = { x: 10 }
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const c = new C({})
|
|
574
|
+
// Need to initialize the signal with the correct state first
|
|
575
|
+
c.setState({ x: 10 })
|
|
576
|
+
c.setState((prev) => ({ x: prev.x + 1 }))
|
|
577
|
+
expect(c.state.x).toBe(11)
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
test('forceUpdate does not crash', () => {
|
|
581
|
+
class C extends Component<Record<string, never>, { x: number }> {
|
|
582
|
+
constructor(props: Record<string, never>) {
|
|
583
|
+
super(props)
|
|
584
|
+
this.state = { x: 1 }
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
const c = new C({})
|
|
588
|
+
expect(() => c.forceUpdate()).not.toThrow()
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
test('render returns null by default', () => {
|
|
592
|
+
const c = new Component({})
|
|
593
|
+
expect(c.render()).toBeNull()
|
|
594
|
+
})
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
// ─── Fix 9: Context nesting with native Provider ────────────────────────────
|
|
598
|
+
|
|
599
|
+
describe('createContext + Provider nesting', () => {
|
|
600
|
+
test('createContext has Provider', () => {
|
|
601
|
+
const Ctx = createContext('default')
|
|
602
|
+
expect(Ctx.Provider).toBeDefined()
|
|
603
|
+
expect(typeof Ctx.Provider).toBe('function')
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
test('useContext returns default when no Provider', () => {
|
|
607
|
+
expect(useContext(createContext('fallback'))).toBe('fallback')
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
test('Provider is marked as native (not compat-wrapped)', () => {
|
|
611
|
+
const Ctx = createContext('test')
|
|
612
|
+
const NATIVE = Symbol.for('pyreon:native-compat')
|
|
613
|
+
expect((Ctx.Provider as unknown as Record<symbol, boolean>)[NATIVE]).toBe(true)
|
|
614
|
+
})
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
// ─── createPortal ───────────────────────────────────────────────────────────
|
|
618
|
+
|
|
619
|
+
describe('createPortal', () => {
|
|
620
|
+
test('creates a portal VNode', () => {
|
|
621
|
+
const target = document.createElement('div')
|
|
622
|
+
const result = createPortal(pyreonH('span', null, 'portaled'), target)
|
|
623
|
+
// Should return a VNodeChild (the portal output)
|
|
624
|
+
expect(result).toBeDefined()
|
|
625
|
+
})
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
// ─── version ────────────────────────────────────────────────────────────────
|
|
629
|
+
|
|
630
|
+
describe('version', () => {
|
|
631
|
+
test('exports version string', () => {
|
|
632
|
+
expect(version).toBe('10.0.0-pyreon')
|
|
633
|
+
})
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
// ─── options ────────────────────────────────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
describe('options', () => {
|
|
639
|
+
test('is an empty object', () => {
|
|
640
|
+
expect(typeof options).toBe('object')
|
|
641
|
+
expect(Object.keys(options)).toHaveLength(0)
|
|
642
|
+
})
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
// ─── Suspense / lazy / ErrorBoundary exports ────────────────────────────────
|
|
646
|
+
|
|
647
|
+
describe('Suspense / lazy / ErrorBoundary exports', () => {
|
|
648
|
+
test('Suspense is exported', () => {
|
|
649
|
+
expect(Suspense).toBeDefined()
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
test('lazy is exported', () => {
|
|
653
|
+
expect(lazy).toBeDefined()
|
|
654
|
+
expect(typeof lazy).toBe('function')
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
test('ErrorBoundary is exported', () => {
|
|
658
|
+
expect(ErrorBoundary).toBeDefined()
|
|
659
|
+
})
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
// ─── Real-world patterns ────────────────────────────────────────────────────
|
|
663
|
+
|
|
664
|
+
describe('real-world patterns', () => {
|
|
665
|
+
test('counter component with useState', async () => {
|
|
666
|
+
const el = container()
|
|
667
|
+
let doIncrement: () => void = () => {}
|
|
668
|
+
|
|
669
|
+
const Counter = () => {
|
|
670
|
+
const [count, setCount] = useState(0)
|
|
671
|
+
doIncrement = () => setCount((c) => c + 1)
|
|
672
|
+
return pyreonH('span', null, String(count))
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
mount(jsx(Counter, {}), el)
|
|
676
|
+
expect(el.textContent).toBe('0')
|
|
677
|
+
|
|
678
|
+
doIncrement()
|
|
679
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
680
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
681
|
+
expect(el.textContent).toBe('1')
|
|
682
|
+
|
|
683
|
+
doIncrement()
|
|
684
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
685
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
686
|
+
expect(el.textContent).toBe('2')
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
test('form component with multiple hooks', async () => {
|
|
690
|
+
const el = container()
|
|
691
|
+
let doType: (v: string) => void = () => {}
|
|
692
|
+
|
|
693
|
+
const Form = () => {
|
|
694
|
+
const [value, setValue] = useState('')
|
|
695
|
+
const [submitted, setSubmitted] = useState(false)
|
|
696
|
+
const uppercased = useMemo(() => value.toUpperCase(), [value])
|
|
697
|
+
doType = setValue
|
|
698
|
+
|
|
699
|
+
useEffect(() => {
|
|
700
|
+
if (value === 'submit') setSubmitted(true)
|
|
701
|
+
}, [value])
|
|
702
|
+
|
|
703
|
+
return pyreonH('div', null, submitted ? `DONE: ${uppercased}` : uppercased)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
mount(jsx(Form, {}), el)
|
|
707
|
+
expect(el.textContent).toBe('')
|
|
708
|
+
|
|
709
|
+
doType('hello')
|
|
710
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
711
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
712
|
+
expect(el.textContent).toBe('HELLO')
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
test('useReducer todo list pattern', async () => {
|
|
716
|
+
const el = container()
|
|
717
|
+
type Action = { type: 'add'; text: string } | { type: 'clear' }
|
|
718
|
+
const reducer = (state: string[], action: Action): string[] => {
|
|
719
|
+
if (action.type === 'add') return [...state, action.text]
|
|
720
|
+
if (action.type === 'clear') return []
|
|
721
|
+
return state
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
let doAdd: (text: string) => void = () => {}
|
|
725
|
+
|
|
726
|
+
const TodoList = () => {
|
|
727
|
+
const [todos, dispatch] = useReducer(reducer, [] as string[])
|
|
728
|
+
doAdd = (text: string) => dispatch({ type: 'add', text })
|
|
729
|
+
return pyreonH('span', null, `${todos.length} items`)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
mount(jsx(TodoList, {}), el)
|
|
733
|
+
expect(el.textContent).toBe('0 items')
|
|
734
|
+
|
|
735
|
+
doAdd('first')
|
|
736
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
737
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
738
|
+
expect(el.textContent).toBe('1 items')
|
|
739
|
+
|
|
740
|
+
doAdd('second')
|
|
741
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
742
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
743
|
+
expect(el.textContent).toBe('2 items')
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
test('useRef with DOM element reference', () => {
|
|
747
|
+
const runner = createHookRunner()
|
|
748
|
+
const ref = runner.run(() => useRef<HTMLDivElement>())
|
|
749
|
+
expect(ref.current).toBeNull()
|
|
750
|
+
|
|
751
|
+
// Simulate setting ref
|
|
752
|
+
const div = document.createElement('div')
|
|
753
|
+
ref.current = div
|
|
754
|
+
expect(ref.current).toBe(div)
|
|
755
|
+
|
|
756
|
+
// Ref persists across renders
|
|
757
|
+
const ref2 = runner.run(() => useRef<HTMLDivElement>())
|
|
758
|
+
expect(ref2).toBe(ref)
|
|
759
|
+
expect(ref2.current).toBe(div)
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
test('signals integration with Preact-style API', () => {
|
|
763
|
+
const count = signal(0)
|
|
764
|
+
const doubled = computed(() => count.value * 2)
|
|
765
|
+
|
|
766
|
+
let effectValue = -1
|
|
767
|
+
const dispose = signalEffect(() => {
|
|
768
|
+
effectValue = doubled.value
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
expect(effectValue).toBe(0)
|
|
772
|
+
|
|
773
|
+
batch(() => {
|
|
774
|
+
count.value = 5
|
|
775
|
+
})
|
|
776
|
+
expect(effectValue).toBe(10)
|
|
777
|
+
expect(doubled.value).toBe(10)
|
|
778
|
+
expect(doubled.peek()).toBe(10)
|
|
779
|
+
|
|
780
|
+
dispose()
|
|
781
|
+
count.value = 100
|
|
782
|
+
expect(effectValue).toBe(10) // disposed, no longer tracking
|
|
783
|
+
})
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
// ─── Class component rendering via JSX runtime ─────────────────────────────
|
|
787
|
+
|
|
788
|
+
describe('class component rendering', () => {
|
|
789
|
+
test('class component renders via jsx()', () => {
|
|
790
|
+
const el = container()
|
|
791
|
+
class Hello extends Component<{ name: string }> {
|
|
792
|
+
override render() {
|
|
793
|
+
return pyreonH('span', null, `Hello ${this.props.name}`)
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
mount(jsx(Hello as unknown as ComponentFn, { name: 'World' }), el)
|
|
797
|
+
expect(el.textContent).toBe('Hello World')
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
test('class component renders with initial state', () => {
|
|
801
|
+
const el = container()
|
|
802
|
+
class Counter extends Component<Record<string, never>, { count: number }> {
|
|
803
|
+
constructor(props: Record<string, never>) {
|
|
804
|
+
super(props)
|
|
805
|
+
this.state = { count: 42 }
|
|
806
|
+
}
|
|
807
|
+
override render() {
|
|
808
|
+
return pyreonH('span', null, String(this.state.count))
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
mount(jsx(Counter as unknown as ComponentFn, {}), el)
|
|
812
|
+
expect(el.textContent).toBe('42')
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
test('class component with children prop', () => {
|
|
816
|
+
const el = container()
|
|
817
|
+
class Wrapper extends Component<{ children?: VNodeChild }> {
|
|
818
|
+
override render() {
|
|
819
|
+
return pyreonH('div', null, this.props.children ?? '')
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
mount(jsx(Wrapper as unknown as ComponentFn, { children: 'inner' }), el)
|
|
823
|
+
expect(el.textContent).toBe('inner')
|
|
824
|
+
})
|
|
825
|
+
})
|
|
826
|
+
|
|
827
|
+
// ─── Class component setState triggers re-render ────────────────────────────
|
|
828
|
+
|
|
829
|
+
describe('class component setState re-render', () => {
|
|
830
|
+
test('setState triggers DOM update', async () => {
|
|
831
|
+
const el = container()
|
|
832
|
+
let instance: InstanceType<typeof Counter> | undefined
|
|
833
|
+
|
|
834
|
+
class Counter extends Component<Record<string, never>, { count: number }> {
|
|
835
|
+
constructor(props: Record<string, never>) {
|
|
836
|
+
super(props)
|
|
837
|
+
this.state = { count: 0 }
|
|
838
|
+
instance = this
|
|
839
|
+
}
|
|
840
|
+
override render() {
|
|
841
|
+
return pyreonH('span', null, String(this.state.count))
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
mount(jsx(Counter as unknown as ComponentFn, {}), el)
|
|
845
|
+
expect(el.textContent).toBe('0')
|
|
846
|
+
|
|
847
|
+
instance!.setState({ count: 5 })
|
|
848
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
849
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
850
|
+
expect(el.textContent).toBe('5')
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
test('forceUpdate triggers DOM update', async () => {
|
|
854
|
+
const el = container()
|
|
855
|
+
let instance: InstanceType<typeof Comp> | undefined
|
|
856
|
+
let renderCount = 0
|
|
857
|
+
|
|
858
|
+
class Comp extends Component {
|
|
859
|
+
constructor(props: Record<string, unknown>) {
|
|
860
|
+
super(props)
|
|
861
|
+
instance = this
|
|
862
|
+
}
|
|
863
|
+
override render() {
|
|
864
|
+
renderCount++
|
|
865
|
+
return pyreonH('span', null, `render-${renderCount}`)
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
mount(jsx(Comp as unknown as ComponentFn, {}), el)
|
|
869
|
+
const initialRenders = renderCount
|
|
870
|
+
|
|
871
|
+
instance!.forceUpdate()
|
|
872
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
873
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
874
|
+
expect(renderCount).toBeGreaterThan(initialRenders)
|
|
875
|
+
})
|
|
876
|
+
})
|
|
877
|
+
|
|
878
|
+
// ─── Class component lifecycle methods ──────────────────────────────────────
|
|
879
|
+
|
|
880
|
+
describe('class component lifecycle', () => {
|
|
881
|
+
test('componentDidMount fires after first render', async () => {
|
|
882
|
+
const el = container()
|
|
883
|
+
let didMount = false
|
|
884
|
+
|
|
885
|
+
class Comp extends Component {
|
|
886
|
+
override componentDidMount() {
|
|
887
|
+
didMount = true
|
|
888
|
+
}
|
|
889
|
+
override render() {
|
|
890
|
+
return pyreonH('span', null, 'mounted')
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
mount(jsx(Comp as unknown as ComponentFn, {}), el)
|
|
894
|
+
expect(didMount).toBe(false) // not yet — queued via microtask
|
|
895
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
896
|
+
expect(didMount).toBe(true)
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
test('componentDidUpdate fires after setState re-render', async () => {
|
|
900
|
+
const el = container()
|
|
901
|
+
let didUpdateCount = 0
|
|
902
|
+
let instance: InstanceType<typeof Comp> | undefined
|
|
903
|
+
|
|
904
|
+
class Comp extends Component<Record<string, never>, { x: number }> {
|
|
905
|
+
constructor(props: Record<string, never>) {
|
|
906
|
+
super(props)
|
|
907
|
+
this.state = { x: 0 }
|
|
908
|
+
instance = this
|
|
909
|
+
}
|
|
910
|
+
override componentDidUpdate() {
|
|
911
|
+
didUpdateCount++
|
|
912
|
+
}
|
|
913
|
+
override render() {
|
|
914
|
+
return pyreonH('span', null, String(this.state.x))
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
mount(jsx(Comp as unknown as ComponentFn, {}), el)
|
|
918
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
919
|
+
expect(didUpdateCount).toBe(0) // didMount, not didUpdate
|
|
920
|
+
|
|
921
|
+
instance!.setState({ x: 1 })
|
|
922
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
923
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
924
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
925
|
+
expect(didUpdateCount).toBe(1)
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
test('componentWillUnmount fires on unmount', async () => {
|
|
929
|
+
const el = container()
|
|
930
|
+
let willUnmount = false
|
|
931
|
+
|
|
932
|
+
class Comp extends Component {
|
|
933
|
+
override componentWillUnmount() {
|
|
934
|
+
willUnmount = true
|
|
935
|
+
}
|
|
936
|
+
override render() {
|
|
937
|
+
return pyreonH('span', null, 'will-unmount')
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
const dispose = mount(jsx(Comp as unknown as ComponentFn, {}), el)
|
|
941
|
+
expect(willUnmount).toBe(false)
|
|
942
|
+
|
|
943
|
+
if (typeof dispose === 'function') dispose()
|
|
944
|
+
// Unmount callback should have fired
|
|
945
|
+
expect(willUnmount).toBe(true)
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
test('shouldComponentUpdate can prevent re-render', async () => {
|
|
949
|
+
const el = container()
|
|
950
|
+
let renderCount = 0
|
|
951
|
+
let instance: InstanceType<typeof Comp> | undefined
|
|
952
|
+
|
|
953
|
+
class Comp extends Component<Record<string, never>, { x: number }> {
|
|
954
|
+
constructor(props: Record<string, never>) {
|
|
955
|
+
super(props)
|
|
956
|
+
this.state = { x: 0 }
|
|
957
|
+
instance = this
|
|
958
|
+
}
|
|
959
|
+
override shouldComponentUpdate(_nextProps: Record<string, never>, nextState: { x: number }) {
|
|
960
|
+
return nextState.x > 1 // only re-render when x > 1
|
|
961
|
+
}
|
|
962
|
+
override render() {
|
|
963
|
+
renderCount++
|
|
964
|
+
return pyreonH('span', null, String(this.state.x))
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
mount(jsx(Comp as unknown as ComponentFn, {}), el)
|
|
968
|
+
const initialRenders = renderCount
|
|
969
|
+
expect(el.textContent).toBe('0')
|
|
970
|
+
|
|
971
|
+
instance!.setState({ x: 1 })
|
|
972
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
973
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
974
|
+
// shouldComponentUpdate returns false for x=1, render skipped
|
|
975
|
+
expect(renderCount).toBe(initialRenders)
|
|
976
|
+
expect(el.textContent).toBe('0')
|
|
977
|
+
|
|
978
|
+
instance!.setState({ x: 2 })
|
|
979
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
980
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
981
|
+
// shouldComponentUpdate returns true for x=2, render runs
|
|
982
|
+
expect(renderCount).toBe(initialRenders + 1)
|
|
983
|
+
expect(el.textContent).toBe('2')
|
|
984
|
+
})
|
|
985
|
+
})
|
|
986
|
+
|
|
987
|
+
// ─── Context Provider tree scoping ──────────────────────────────────────────
|
|
988
|
+
|
|
989
|
+
describe('context provider tree scoping', () => {
|
|
990
|
+
test('Provider passes value to nested useContext', () => {
|
|
991
|
+
const el = container()
|
|
992
|
+
const Ctx = createContext('default')
|
|
993
|
+
let received = ''
|
|
994
|
+
|
|
995
|
+
const Child = () => {
|
|
996
|
+
received = useContext(Ctx)
|
|
997
|
+
return pyreonH('span', null, received)
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const App = () => {
|
|
1001
|
+
return pyreonH(Ctx.Provider as unknown as string, { value: 'provided' }, jsx(Child, {}))
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
mount(jsx(App, {}), el)
|
|
1005
|
+
expect(received).toBe('provided')
|
|
1006
|
+
})
|
|
1007
|
+
|
|
1008
|
+
test('nested Providers override parent value', () => {
|
|
1009
|
+
const el = container()
|
|
1010
|
+
const Ctx = createContext('default')
|
|
1011
|
+
let outerVal = ''
|
|
1012
|
+
let innerVal = ''
|
|
1013
|
+
|
|
1014
|
+
const OuterChild = () => {
|
|
1015
|
+
outerVal = useContext(Ctx)
|
|
1016
|
+
return pyreonH('span', null, outerVal)
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const InnerChild = () => {
|
|
1020
|
+
innerVal = useContext(Ctx)
|
|
1021
|
+
return pyreonH('span', null, innerVal)
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const App = () => {
|
|
1025
|
+
return pyreonH(
|
|
1026
|
+
Ctx.Provider as unknown as string,
|
|
1027
|
+
{ value: 'outer' },
|
|
1028
|
+
jsx(OuterChild, {}),
|
|
1029
|
+
pyreonH(Ctx.Provider as unknown as string, { value: 'inner' }, jsx(InnerChild, {})),
|
|
1030
|
+
)
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
mount(jsx(App, {}), el)
|
|
1034
|
+
expect(outerVal).toBe('outer')
|
|
1035
|
+
expect(innerVal).toBe('inner')
|
|
1036
|
+
})
|
|
1037
|
+
})
|
|
1038
|
+
|
|
1039
|
+
// ─── useTransition / useDeferredValue ───────────────────────────────────────
|
|
1040
|
+
|
|
1041
|
+
describe('useTransition', () => {
|
|
1042
|
+
test('returns [false, startTransition] where startTransition runs fn synchronously', () => {
|
|
1043
|
+
const [isPending, startTransition] = withHookCtx(() => useTransition())
|
|
1044
|
+
expect(isPending).toBe(false)
|
|
1045
|
+
let ran = false
|
|
1046
|
+
startTransition(() => {
|
|
1047
|
+
ran = true
|
|
1048
|
+
})
|
|
1049
|
+
expect(ran).toBe(true)
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
test('works inside a mounted component', async () => {
|
|
1053
|
+
const el = container()
|
|
1054
|
+
let triggerTransition: () => void = () => {}
|
|
1055
|
+
|
|
1056
|
+
const Comp = () => {
|
|
1057
|
+
const [count, setCount] = useState(0)
|
|
1058
|
+
const [, startTransition] = useTransition()
|
|
1059
|
+
triggerTransition = () => startTransition(() => setCount((c) => c + 1))
|
|
1060
|
+
return pyreonH('span', null, String(count))
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
mount(jsx(Comp, {}), el)
|
|
1064
|
+
expect(el.textContent).toBe('0')
|
|
1065
|
+
|
|
1066
|
+
triggerTransition()
|
|
1067
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
1068
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
1069
|
+
expect(el.textContent).toBe('1')
|
|
1070
|
+
})
|
|
1071
|
+
})
|
|
1072
|
+
|
|
1073
|
+
describe('useDeferredValue', () => {
|
|
1074
|
+
test('returns the value as-is', () => {
|
|
1075
|
+
const result = withHookCtx(() => useDeferredValue(42))
|
|
1076
|
+
expect(result).toBe(42)
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
test('returns objects by reference', () => {
|
|
1080
|
+
const obj = { a: 1 }
|
|
1081
|
+
const result = withHookCtx(() => useDeferredValue(obj))
|
|
1082
|
+
expect(result).toBe(obj)
|
|
1083
|
+
})
|
|
1084
|
+
})
|