@pyreon/preact-compat 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -4
- package/src/hooks.ts +176 -58
- package/src/jsx-runtime.ts +162 -0
- package/src/tests/preact-compat.test.ts +549 -141
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { VNodeChild } from "@pyreon/core"
|
|
2
2
|
import { h as pyreonH } from "@pyreon/core"
|
|
3
|
-
import { signal as pyreonSignal } from "@pyreon/reactivity"
|
|
3
|
+
import { effect, signal as pyreonSignal } from "@pyreon/reactivity"
|
|
4
4
|
import { mount } from "@pyreon/runtime-dom"
|
|
5
5
|
import {
|
|
6
|
+
memo,
|
|
6
7
|
useCallback,
|
|
7
8
|
useEffect,
|
|
8
9
|
useErrorBoundary,
|
|
@@ -28,7 +29,9 @@ import {
|
|
|
28
29
|
toChildArray,
|
|
29
30
|
useContext,
|
|
30
31
|
} from "../index"
|
|
31
|
-
import {
|
|
32
|
+
import type { RenderContext } from "../jsx-runtime"
|
|
33
|
+
import { beginRender, endRender, jsx } from "../jsx-runtime"
|
|
34
|
+
import { batch, computed, signal, effect as signalEffect } from "../signals"
|
|
32
35
|
|
|
33
36
|
function container(): HTMLElement {
|
|
34
37
|
const el = document.createElement("div")
|
|
@@ -36,6 +39,41 @@ function container(): HTMLElement {
|
|
|
36
39
|
return el
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
/** Helper: creates a RenderContext for testing hooks outside of full render cycle */
|
|
43
|
+
function withHookCtx<T>(fn: () => T): T {
|
|
44
|
+
const ctx: RenderContext = {
|
|
45
|
+
hooks: [],
|
|
46
|
+
scheduleRerender: () => {},
|
|
47
|
+
pendingEffects: [],
|
|
48
|
+
pendingLayoutEffects: [],
|
|
49
|
+
unmounted: false,
|
|
50
|
+
}
|
|
51
|
+
beginRender(ctx)
|
|
52
|
+
const result = fn()
|
|
53
|
+
endRender()
|
|
54
|
+
return result
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Re-render helper: calls fn with the same ctx to simulate re-render */
|
|
58
|
+
function createHookRunner() {
|
|
59
|
+
const ctx: RenderContext = {
|
|
60
|
+
hooks: [],
|
|
61
|
+
scheduleRerender: () => {},
|
|
62
|
+
pendingEffects: [],
|
|
63
|
+
pendingLayoutEffects: [],
|
|
64
|
+
unmounted: false,
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
ctx,
|
|
68
|
+
run<T>(fn: () => T): T {
|
|
69
|
+
beginRender(ctx)
|
|
70
|
+
const result = fn()
|
|
71
|
+
endRender()
|
|
72
|
+
return result
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
39
77
|
describe("@pyreon/preact-compat", () => {
|
|
40
78
|
// ─── Core API ────────────────────────────────────────────────────────────
|
|
41
79
|
|
|
@@ -63,7 +101,6 @@ describe("@pyreon/preact-compat", () => {
|
|
|
63
101
|
test("hydrate() calls hydrateRoot", () => {
|
|
64
102
|
const el = container()
|
|
65
103
|
el.innerHTML = "<span>hydrated</span>"
|
|
66
|
-
// hydrate should not throw; it calls hydrateRoot internally
|
|
67
104
|
hydrate(h("span", null, "hydrated"), el)
|
|
68
105
|
expect(el.innerHTML).toContain("hydrated")
|
|
69
106
|
})
|
|
@@ -148,7 +185,6 @@ describe("@pyreon/preact-compat", () => {
|
|
|
148
185
|
|
|
149
186
|
test("createContext/useContext work", () => {
|
|
150
187
|
const Ctx = createContext("default")
|
|
151
|
-
// Without a provider, useContext returns the default value
|
|
152
188
|
expect(useContext(Ctx)).toBe("default")
|
|
153
189
|
})
|
|
154
190
|
|
|
@@ -202,203 +238,434 @@ describe("@pyreon/preact-compat", () => {
|
|
|
202
238
|
}
|
|
203
239
|
}
|
|
204
240
|
const c = new MyComp({})
|
|
205
|
-
// forceUpdate should not throw and should update internal signal
|
|
206
241
|
c.forceUpdate()
|
|
207
242
|
expect(c.state.value).toBe(42)
|
|
208
243
|
})
|
|
244
|
+
})
|
|
209
245
|
|
|
210
|
-
|
|
246
|
+
// ─── useState ─────────────────────────────────────────────────────────────────
|
|
211
247
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
248
|
+
describe("useState", () => {
|
|
249
|
+
test("returns [value, setter] — value is the initial value", () => {
|
|
250
|
+
const [count] = withHookCtx(() => useState(0))
|
|
251
|
+
expect(count).toBe(0)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test("setter updates value on re-render", () => {
|
|
255
|
+
const runner = createHookRunner()
|
|
256
|
+
const [, setCount] = runner.run(() => useState(0))
|
|
215
257
|
setCount(5)
|
|
216
|
-
|
|
258
|
+
const [count2] = runner.run(() => useState(0))
|
|
259
|
+
expect(count2).toBe(5)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test("setter with function updater", () => {
|
|
263
|
+
const runner = createHookRunner()
|
|
264
|
+
const [, setCount] = runner.run(() => useState(10))
|
|
217
265
|
setCount((prev) => prev + 1)
|
|
218
|
-
|
|
266
|
+
const [count2] = runner.run(() => useState(10))
|
|
267
|
+
expect(count2).toBe(11)
|
|
219
268
|
})
|
|
220
269
|
|
|
221
|
-
test("
|
|
270
|
+
test("initializer function is called once", () => {
|
|
222
271
|
let calls = 0
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
272
|
+
const runner = createHookRunner()
|
|
273
|
+
runner.run(() =>
|
|
274
|
+
useState(() => {
|
|
275
|
+
calls++
|
|
276
|
+
return 42
|
|
277
|
+
}),
|
|
278
|
+
)
|
|
279
|
+
expect(calls).toBe(1)
|
|
280
|
+
runner.run(() =>
|
|
281
|
+
useState(() => {
|
|
282
|
+
calls++
|
|
283
|
+
return 42
|
|
284
|
+
}),
|
|
285
|
+
)
|
|
228
286
|
expect(calls).toBe(1)
|
|
229
287
|
})
|
|
230
288
|
|
|
231
|
-
test("
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
289
|
+
test("setter does nothing when value is the same (Object.is)", () => {
|
|
290
|
+
const runner = createHookRunner()
|
|
291
|
+
let rerenders = 0
|
|
292
|
+
runner.ctx.scheduleRerender = () => {
|
|
293
|
+
rerenders++
|
|
294
|
+
}
|
|
295
|
+
const [, setCount] = runner.run(() => useState(0))
|
|
296
|
+
setCount(0)
|
|
297
|
+
expect(rerenders).toBe(0)
|
|
298
|
+
setCount(1)
|
|
299
|
+
expect(rerenders).toBe(1)
|
|
237
300
|
})
|
|
238
301
|
|
|
239
|
-
test("
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
302
|
+
test("re-render in a component via compat JSX runtime", async () => {
|
|
303
|
+
const el = container()
|
|
304
|
+
let renderCount = 0
|
|
305
|
+
let triggerSet: (v: number | ((p: number) => number)) => void = () => {}
|
|
306
|
+
|
|
307
|
+
const Counter = () => {
|
|
308
|
+
const [count, setCount] = useState(0)
|
|
309
|
+
renderCount++
|
|
310
|
+
triggerSet = setCount
|
|
311
|
+
return pyreonH("span", null, String(count))
|
|
312
|
+
}
|
|
243
313
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
expect(
|
|
247
|
-
|
|
314
|
+
const vnode = jsx(Counter, {})
|
|
315
|
+
mount(vnode, el)
|
|
316
|
+
expect(el.textContent).toBe("0")
|
|
317
|
+
const initialRenders = renderCount
|
|
248
318
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
319
|
+
triggerSet(1)
|
|
320
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
321
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
322
|
+
expect(el.textContent).toBe("1")
|
|
323
|
+
expect(renderCount).toBe(initialRenders + 1)
|
|
252
324
|
})
|
|
325
|
+
})
|
|
253
326
|
|
|
254
|
-
|
|
255
|
-
const emptyRef = useRef()
|
|
256
|
-
expect(emptyRef.current).toBe(null)
|
|
257
|
-
})
|
|
327
|
+
// ─── useReducer ───────────────────────────────────────────────────────────────
|
|
258
328
|
|
|
259
|
-
|
|
329
|
+
describe("useReducer", () => {
|
|
330
|
+
test("dispatch applies reducer", () => {
|
|
331
|
+
const runner = createHookRunner()
|
|
260
332
|
type Action = { type: "inc" } | { type: "dec" }
|
|
261
|
-
const reducer = (
|
|
262
|
-
|
|
263
|
-
|
|
333
|
+
const reducer = (state: number, action: Action) =>
|
|
334
|
+
action.type === "inc" ? state + 1 : state - 1
|
|
335
|
+
|
|
336
|
+
const [state0, dispatch] = runner.run(() => useReducer(reducer, 0))
|
|
337
|
+
expect(state0).toBe(0)
|
|
338
|
+
|
|
264
339
|
dispatch({ type: "inc" })
|
|
265
|
-
|
|
340
|
+
const [state1] = runner.run(() => useReducer(reducer, 0))
|
|
341
|
+
expect(state1).toBe(1)
|
|
342
|
+
|
|
266
343
|
dispatch({ type: "dec" })
|
|
267
|
-
|
|
344
|
+
const [state2] = runner.run(() => useReducer(reducer, 0))
|
|
345
|
+
expect(state2).toBe(0)
|
|
268
346
|
})
|
|
269
347
|
|
|
270
|
-
test("
|
|
348
|
+
test("initializer function is called once", () => {
|
|
271
349
|
let calls = 0
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
(
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
350
|
+
const runner = createHookRunner()
|
|
351
|
+
const [state] = runner.run(() =>
|
|
352
|
+
useReducer(
|
|
353
|
+
(s: number) => s,
|
|
354
|
+
() => {
|
|
355
|
+
calls++
|
|
356
|
+
return 99
|
|
357
|
+
},
|
|
358
|
+
),
|
|
359
|
+
)
|
|
360
|
+
expect(state).toBe(99)
|
|
361
|
+
expect(calls).toBe(1)
|
|
362
|
+
runner.run(() =>
|
|
363
|
+
useReducer(
|
|
364
|
+
(s: number) => s,
|
|
365
|
+
() => {
|
|
366
|
+
calls++
|
|
367
|
+
return 99
|
|
368
|
+
},
|
|
369
|
+
),
|
|
278
370
|
)
|
|
279
|
-
expect(state()).toBe(99)
|
|
280
371
|
expect(calls).toBe(1)
|
|
281
372
|
})
|
|
282
373
|
|
|
283
|
-
test("
|
|
284
|
-
|
|
374
|
+
test("dispatch does nothing when reducer returns same state", () => {
|
|
375
|
+
const runner = createHookRunner()
|
|
376
|
+
let rerenders = 0
|
|
377
|
+
runner.ctx.scheduleRerender = () => {
|
|
378
|
+
rerenders++
|
|
379
|
+
}
|
|
380
|
+
const [, dispatch] = runner.run(() => useReducer((_s: number, _a: string) => 5, 5))
|
|
381
|
+
dispatch("anything")
|
|
382
|
+
expect(rerenders).toBe(0)
|
|
285
383
|
})
|
|
384
|
+
})
|
|
286
385
|
|
|
287
|
-
|
|
386
|
+
// ─── useEffect ────────────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
describe("useEffect", () => {
|
|
389
|
+
test("effect runs after render via compat JSX runtime", async () => {
|
|
288
390
|
const el = container()
|
|
289
|
-
|
|
290
|
-
let runs = 0
|
|
391
|
+
let effectRuns = 0
|
|
291
392
|
|
|
292
393
|
const Comp = () => {
|
|
293
394
|
useEffect(() => {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}, [])
|
|
395
|
+
effectRuns++
|
|
396
|
+
})
|
|
297
397
|
return pyreonH("div", null, "test")
|
|
298
398
|
}
|
|
299
399
|
|
|
300
|
-
mount(
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
expect(runs).toBe(1) // should not re-run
|
|
400
|
+
mount(jsx(Comp, {}), el)
|
|
401
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
402
|
+
expect(effectRuns).toBeGreaterThanOrEqual(1)
|
|
304
403
|
})
|
|
305
404
|
|
|
306
|
-
test("
|
|
405
|
+
test("effect with empty deps runs once", async () => {
|
|
307
406
|
const el = container()
|
|
308
|
-
let
|
|
407
|
+
let effectRuns = 0
|
|
408
|
+
let triggerSet: (v: number) => void = () => {}
|
|
309
409
|
|
|
310
410
|
const Comp = () => {
|
|
411
|
+
const [count, setCount] = useState(0)
|
|
412
|
+
triggerSet = setCount
|
|
311
413
|
useEffect(() => {
|
|
312
|
-
|
|
313
|
-
cleaned = true
|
|
314
|
-
}
|
|
414
|
+
effectRuns++
|
|
315
415
|
}, [])
|
|
316
|
-
return pyreonH("div", null,
|
|
416
|
+
return pyreonH("div", null, String(count))
|
|
317
417
|
}
|
|
318
418
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
419
|
+
mount(jsx(Comp, {}), el)
|
|
420
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
421
|
+
expect(effectRuns).toBe(1)
|
|
422
|
+
|
|
423
|
+
triggerSet(1)
|
|
424
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
425
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
426
|
+
expect(effectRuns).toBe(1)
|
|
325
427
|
})
|
|
326
428
|
|
|
327
|
-
test("
|
|
429
|
+
test("effect with deps re-runs when deps change", async () => {
|
|
328
430
|
const el = container()
|
|
329
|
-
|
|
330
|
-
let
|
|
431
|
+
let effectRuns = 0
|
|
432
|
+
let triggerSet: (v: number | ((p: number) => number)) => void = () => {}
|
|
331
433
|
|
|
332
434
|
const Comp = () => {
|
|
435
|
+
const [count, setCount] = useState(0)
|
|
436
|
+
triggerSet = setCount
|
|
333
437
|
useEffect(() => {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
return pyreonH("div", null, "reactive")
|
|
438
|
+
effectRuns++
|
|
439
|
+
}, [count])
|
|
440
|
+
return pyreonH("div", null, String(count))
|
|
338
441
|
}
|
|
339
442
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
443
|
+
mount(jsx(Comp, {}), el)
|
|
444
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
445
|
+
expect(effectRuns).toBe(1)
|
|
446
|
+
|
|
447
|
+
triggerSet((p) => p + 1)
|
|
448
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
449
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
450
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
451
|
+
expect(effectRuns).toBe(2)
|
|
347
452
|
})
|
|
348
453
|
|
|
349
|
-
test("
|
|
454
|
+
test("effect cleanup runs before re-execution", async () => {
|
|
350
455
|
const el = container()
|
|
351
|
-
|
|
352
|
-
let
|
|
456
|
+
let cleanups = 0
|
|
457
|
+
let triggerSet: (v: number | ((p: number) => number)) => void = () => {}
|
|
353
458
|
|
|
354
459
|
const Comp = () => {
|
|
460
|
+
const [count, setCount] = useState(0)
|
|
461
|
+
triggerSet = setCount
|
|
355
462
|
useEffect(() => {
|
|
356
|
-
s()
|
|
357
463
|
return () => {
|
|
358
|
-
|
|
464
|
+
cleanups++
|
|
359
465
|
}
|
|
360
|
-
})
|
|
361
|
-
return pyreonH("div", null,
|
|
466
|
+
}, [count])
|
|
467
|
+
return pyreonH("div", null, String(count))
|
|
362
468
|
}
|
|
363
469
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
470
|
+
mount(jsx(Comp, {}), el)
|
|
471
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
472
|
+
expect(cleanups).toBe(0)
|
|
473
|
+
|
|
474
|
+
triggerSet((p) => p + 1)
|
|
475
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
476
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
477
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
478
|
+
expect(cleanups).toBe(1)
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
test("pendingEffects populated during render", () => {
|
|
482
|
+
const runner = createHookRunner()
|
|
483
|
+
runner.run(() => {
|
|
484
|
+
useEffect(() => {})
|
|
485
|
+
})
|
|
486
|
+
expect(runner.ctx.pendingEffects).toHaveLength(1)
|
|
369
487
|
})
|
|
370
488
|
|
|
371
|
-
test("
|
|
489
|
+
test("effect with same deps does not re-queue", () => {
|
|
490
|
+
const runner = createHookRunner()
|
|
491
|
+
runner.run(() => {
|
|
492
|
+
useEffect(() => {}, [1, 2])
|
|
493
|
+
})
|
|
494
|
+
expect(runner.ctx.pendingEffects).toHaveLength(1)
|
|
495
|
+
|
|
496
|
+
runner.run(() => {
|
|
497
|
+
useEffect(() => {}, [1, 2])
|
|
498
|
+
})
|
|
499
|
+
expect(runner.ctx.pendingEffects).toHaveLength(0)
|
|
500
|
+
})
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
// ─── useLayoutEffect ─────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
describe("useLayoutEffect", () => {
|
|
506
|
+
test("layout effect runs synchronously during render in compat runtime", () => {
|
|
372
507
|
const el = container()
|
|
373
|
-
|
|
374
|
-
let runs = 0
|
|
508
|
+
let effectRuns = 0
|
|
375
509
|
|
|
376
510
|
const Comp = () => {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
runs++
|
|
380
|
-
// no return
|
|
511
|
+
useLayoutEffect(() => {
|
|
512
|
+
effectRuns++
|
|
381
513
|
})
|
|
382
|
-
return pyreonH("div", null, "
|
|
514
|
+
return pyreonH("div", null, "layout")
|
|
383
515
|
}
|
|
384
516
|
|
|
385
|
-
|
|
386
|
-
expect(
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
517
|
+
mount(jsx(Comp, {}), el)
|
|
518
|
+
expect(effectRuns).toBeGreaterThanOrEqual(1)
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
test("pendingLayoutEffects populated during render", () => {
|
|
522
|
+
const runner = createHookRunner()
|
|
523
|
+
runner.run(() => {
|
|
524
|
+
useLayoutEffect(() => {})
|
|
525
|
+
})
|
|
526
|
+
expect(runner.ctx.pendingLayoutEffects).toHaveLength(1)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
test("layout effect with same deps does not re-queue", () => {
|
|
530
|
+
const runner = createHookRunner()
|
|
531
|
+
runner.run(() => {
|
|
532
|
+
useLayoutEffect(() => {}, [1])
|
|
533
|
+
})
|
|
534
|
+
expect(runner.ctx.pendingLayoutEffects).toHaveLength(1)
|
|
535
|
+
|
|
536
|
+
runner.run(() => {
|
|
537
|
+
useLayoutEffect(() => {}, [1])
|
|
538
|
+
})
|
|
539
|
+
expect(runner.ctx.pendingLayoutEffects).toHaveLength(0)
|
|
540
|
+
})
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
// ─── useMemo ──────────────────────────────────────────────────────────────────
|
|
544
|
+
|
|
545
|
+
describe("useMemo", () => {
|
|
546
|
+
test("returns computed value", () => {
|
|
547
|
+
const value = withHookCtx(() => useMemo(() => 3 * 2, []))
|
|
548
|
+
expect(value).toBe(6)
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
test("recomputes when deps change", () => {
|
|
552
|
+
const runner = createHookRunner()
|
|
553
|
+
const v1 = runner.run(() => useMemo(() => 10, [1]))
|
|
554
|
+
expect(v1).toBe(10)
|
|
555
|
+
|
|
556
|
+
const v2 = runner.run(() => useMemo(() => 20, [1]))
|
|
557
|
+
expect(v2).toBe(10)
|
|
558
|
+
|
|
559
|
+
const v3 = runner.run(() => useMemo(() => 30, [2]))
|
|
560
|
+
expect(v3).toBe(30)
|
|
390
561
|
})
|
|
562
|
+
})
|
|
391
563
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
564
|
+
// ─── useCallback ──────────────────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
describe("useCallback", () => {
|
|
567
|
+
test("returns the same function when deps unchanged", () => {
|
|
568
|
+
const runner = createHookRunner()
|
|
569
|
+
const fn1 = () => 42
|
|
570
|
+
const fn2 = () => 99
|
|
571
|
+
const result1 = runner.run(() => useCallback(fn1, [1]))
|
|
572
|
+
const result2 = runner.run(() => useCallback(fn2, [1]))
|
|
573
|
+
expect(result1).toBe(result2)
|
|
574
|
+
expect(result1()).toBe(42)
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
test("returns new function when deps change", () => {
|
|
578
|
+
const runner = createHookRunner()
|
|
579
|
+
const fn1 = () => 42
|
|
580
|
+
const fn2 = () => 99
|
|
581
|
+
const result1 = runner.run(() => useCallback(fn1, [1]))
|
|
582
|
+
const result2 = runner.run(() => useCallback(fn2, [2]))
|
|
583
|
+
expect(result2).toBe(fn2)
|
|
584
|
+
expect(result2()).toBe(99)
|
|
585
|
+
expect(result1).not.toBe(result2)
|
|
399
586
|
})
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
// ─── useRef ───────────────────────────────────────────────────────────────────
|
|
400
590
|
|
|
401
|
-
|
|
591
|
+
describe("useRef", () => {
|
|
592
|
+
test("returns { current } with null default", () => {
|
|
593
|
+
const ref = withHookCtx(() => useRef<HTMLDivElement>())
|
|
594
|
+
expect(ref.current).toBeNull()
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
test("returns { current } with initial value", () => {
|
|
598
|
+
const ref = withHookCtx(() => useRef(42))
|
|
599
|
+
expect(ref.current).toBe(42)
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
test("current is mutable", () => {
|
|
603
|
+
const ref = withHookCtx(() => useRef(0))
|
|
604
|
+
ref.current = 10
|
|
605
|
+
expect(ref.current).toBe(10)
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
test("same ref object persists across re-renders", () => {
|
|
609
|
+
const runner = createHookRunner()
|
|
610
|
+
const ref1 = runner.run(() => useRef(0))
|
|
611
|
+
ref1.current = 99
|
|
612
|
+
const ref2 = runner.run(() => useRef(0))
|
|
613
|
+
expect(ref1).toBe(ref2)
|
|
614
|
+
expect(ref2.current).toBe(99)
|
|
615
|
+
})
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
// ─── memo ─────────────────────────────────────────────────────────────────────
|
|
619
|
+
|
|
620
|
+
describe("memo", () => {
|
|
621
|
+
test("skips re-render when props are shallowly equal", () => {
|
|
622
|
+
let renderCount = 0
|
|
623
|
+
const MyComp = (props: { name: string }) => {
|
|
624
|
+
renderCount++
|
|
625
|
+
return pyreonH("span", null, props.name)
|
|
626
|
+
}
|
|
627
|
+
const Memoized = memo(MyComp)
|
|
628
|
+
Memoized({ name: "a" })
|
|
629
|
+
expect(renderCount).toBe(1)
|
|
630
|
+
Memoized({ name: "a" })
|
|
631
|
+
expect(renderCount).toBe(1)
|
|
632
|
+
Memoized({ name: "b" })
|
|
633
|
+
expect(renderCount).toBe(2)
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
test("custom areEqual function", () => {
|
|
637
|
+
let renderCount = 0
|
|
638
|
+
const MyComp = (props: { x: number; y: number }) => {
|
|
639
|
+
renderCount++
|
|
640
|
+
return pyreonH("span", null, String(props.x))
|
|
641
|
+
}
|
|
642
|
+
const Memoized = memo(MyComp, (prev, next) => prev.x === next.x)
|
|
643
|
+
Memoized({ x: 1, y: 1 })
|
|
644
|
+
expect(renderCount).toBe(1)
|
|
645
|
+
Memoized({ x: 1, y: 999 })
|
|
646
|
+
expect(renderCount).toBe(1)
|
|
647
|
+
Memoized({ x: 2, y: 999 })
|
|
648
|
+
expect(renderCount).toBe(2)
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
test("different number of keys triggers re-render", () => {
|
|
652
|
+
let renderCount = 0
|
|
653
|
+
const MyComp = (_props: Record<string, unknown>) => {
|
|
654
|
+
renderCount++
|
|
655
|
+
return pyreonH("span", null, "x")
|
|
656
|
+
}
|
|
657
|
+
const Memoized = memo(MyComp)
|
|
658
|
+
Memoized({ a: 1 })
|
|
659
|
+
expect(renderCount).toBe(1)
|
|
660
|
+
Memoized({ a: 1, b: 2 })
|
|
661
|
+
expect(renderCount).toBe(2)
|
|
662
|
+
})
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
// ─── useId ────────────────────────────────────────────────────────────────────
|
|
666
|
+
|
|
667
|
+
describe("useId", () => {
|
|
668
|
+
test("returns a unique string within a component", () => {
|
|
402
669
|
const el = container()
|
|
403
670
|
const ids: string[] = []
|
|
404
671
|
|
|
@@ -408,19 +675,51 @@ describe("@pyreon/preact-compat", () => {
|
|
|
408
675
|
return pyreonH("div", null, "id-test")
|
|
409
676
|
}
|
|
410
677
|
|
|
411
|
-
|
|
412
|
-
expect(ids).
|
|
413
|
-
expect(ids[0]).toBe(
|
|
414
|
-
expect(ids[
|
|
415
|
-
|
|
678
|
+
mount(jsx(Comp, {}), el)
|
|
679
|
+
expect(ids.length).toBeGreaterThanOrEqual(2)
|
|
680
|
+
expect(ids[0]).not.toBe(ids[1])
|
|
681
|
+
expect(typeof ids[0]).toBe("string")
|
|
682
|
+
expect(ids[0]?.startsWith(":r")).toBe(true)
|
|
416
683
|
})
|
|
417
684
|
|
|
418
|
-
test("
|
|
685
|
+
test("IDs are stable across re-renders", async () => {
|
|
686
|
+
const el = container()
|
|
687
|
+
const idHistory: string[] = []
|
|
688
|
+
let triggerSet: (v: number) => void = () => {}
|
|
689
|
+
|
|
690
|
+
const Comp = () => {
|
|
691
|
+
const [count, setCount] = useState(0)
|
|
692
|
+
triggerSet = setCount
|
|
693
|
+
const id = useId()
|
|
694
|
+
idHistory.push(id)
|
|
695
|
+
return pyreonH("div", null, `${id}-${count}`)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
mount(jsx(Comp, {}), el)
|
|
699
|
+
const initialCount = idHistory.length
|
|
700
|
+
const firstId = idHistory[0]
|
|
701
|
+
|
|
702
|
+
triggerSet(1)
|
|
703
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
704
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
705
|
+
expect(idHistory.length).toBeGreaterThan(initialCount)
|
|
706
|
+
for (const id of idHistory) {
|
|
707
|
+
expect(id).toBe(firstId)
|
|
708
|
+
}
|
|
709
|
+
})
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
// ─── useErrorBoundary ────────────────────────────────────────────────────────
|
|
713
|
+
|
|
714
|
+
describe("useErrorBoundary", () => {
|
|
715
|
+
test("is exported as a function", () => {
|
|
419
716
|
expect(typeof useErrorBoundary).toBe("function")
|
|
420
717
|
})
|
|
718
|
+
})
|
|
421
719
|
|
|
422
|
-
|
|
720
|
+
// ─── Signals ─────────────────────────────────────────────────────────────────
|
|
423
721
|
|
|
722
|
+
describe("signals", () => {
|
|
424
723
|
test("signal() has .value accessor", () => {
|
|
425
724
|
const count = signal(0)
|
|
426
725
|
expect(count.value).toBe(0)
|
|
@@ -447,7 +746,7 @@ describe("@pyreon/preact-compat", () => {
|
|
|
447
746
|
test("effect() tracks signal reads", () => {
|
|
448
747
|
const count = signal(0)
|
|
449
748
|
let observed = -1
|
|
450
|
-
const dispose =
|
|
749
|
+
const dispose = signalEffect(() => {
|
|
451
750
|
observed = count.value
|
|
452
751
|
})
|
|
453
752
|
expect(observed).toBe(0)
|
|
@@ -455,13 +754,13 @@ describe("@pyreon/preact-compat", () => {
|
|
|
455
754
|
expect(observed).toBe(7)
|
|
456
755
|
dispose()
|
|
457
756
|
count.value = 99
|
|
458
|
-
expect(observed).toBe(7)
|
|
757
|
+
expect(observed).toBe(7)
|
|
459
758
|
})
|
|
460
759
|
|
|
461
760
|
test("effect() with cleanup function", () => {
|
|
462
761
|
const count = signal(0)
|
|
463
762
|
let cleanups = 0
|
|
464
|
-
const dispose =
|
|
763
|
+
const dispose = signalEffect(() => {
|
|
465
764
|
void count.value
|
|
466
765
|
return () => {
|
|
467
766
|
cleanups++
|
|
@@ -469,20 +768,17 @@ describe("@pyreon/preact-compat", () => {
|
|
|
469
768
|
})
|
|
470
769
|
expect(cleanups).toBe(0)
|
|
471
770
|
count.value = 1
|
|
472
|
-
// Cleanup runs before re-run
|
|
473
771
|
expect(cleanups).toBe(1)
|
|
474
772
|
dispose()
|
|
475
|
-
// Cleanup runs on dispose
|
|
476
773
|
expect(cleanups).toBe(2)
|
|
477
774
|
})
|
|
478
775
|
|
|
479
776
|
test("effect() with non-function return (no cleanup)", () => {
|
|
480
777
|
const count = signal(0)
|
|
481
778
|
let runs = 0
|
|
482
|
-
const dispose =
|
|
779
|
+
const dispose = signalEffect(() => {
|
|
483
780
|
void count.value
|
|
484
781
|
runs++
|
|
485
|
-
// no return
|
|
486
782
|
})
|
|
487
783
|
expect(runs).toBe(1)
|
|
488
784
|
count.value = 1
|
|
@@ -494,7 +790,7 @@ describe("@pyreon/preact-compat", () => {
|
|
|
494
790
|
const a = signal(1)
|
|
495
791
|
const b = signal(2)
|
|
496
792
|
let runs = 0
|
|
497
|
-
|
|
793
|
+
signalEffect(() => {
|
|
498
794
|
void a.value
|
|
499
795
|
void b.value
|
|
500
796
|
runs++
|
|
@@ -504,19 +800,131 @@ describe("@pyreon/preact-compat", () => {
|
|
|
504
800
|
a.value = 10
|
|
505
801
|
b.value = 20
|
|
506
802
|
})
|
|
507
|
-
expect(runs).toBe(2)
|
|
803
|
+
expect(runs).toBe(2)
|
|
508
804
|
})
|
|
509
805
|
|
|
510
806
|
test("signal peek() reads without tracking", () => {
|
|
511
807
|
const count = signal(0)
|
|
512
808
|
let observed = -1
|
|
513
|
-
const dispose =
|
|
809
|
+
const dispose = signalEffect(() => {
|
|
514
810
|
observed = count.peek()
|
|
515
811
|
})
|
|
516
812
|
expect(observed).toBe(0)
|
|
517
813
|
count.value = 5
|
|
518
|
-
// peek() is untracked, so effect should NOT re-run
|
|
519
814
|
expect(observed).toBe(0)
|
|
520
815
|
dispose()
|
|
521
816
|
})
|
|
522
817
|
})
|
|
818
|
+
|
|
819
|
+
// ─── jsx-runtime ──────────────────────────────────────────────────────────────
|
|
820
|
+
|
|
821
|
+
describe("jsx-runtime", () => {
|
|
822
|
+
test("jsx with string type creates element VNode", () => {
|
|
823
|
+
const vnode = jsx("div", { children: "hello" })
|
|
824
|
+
expect(vnode.type).toBe("div")
|
|
825
|
+
expect(vnode.children).toContain("hello")
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
test("jsx with key prop", () => {
|
|
829
|
+
const vnode = jsx("div", { children: "x" }, "my-key")
|
|
830
|
+
expect(vnode.props.key).toBe("my-key")
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
test("jsx with component wraps for re-render", () => {
|
|
834
|
+
const MyComp = () => pyreonH("span", null, "hi")
|
|
835
|
+
const vnode = jsx(MyComp, {})
|
|
836
|
+
expect(vnode.type).not.toBe(MyComp)
|
|
837
|
+
expect(typeof vnode.type).toBe("function")
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
test("jsx with Fragment", () => {
|
|
841
|
+
const vnode = jsx(Fragment, {
|
|
842
|
+
children: [pyreonH("span", null, "a"), pyreonH("span", null, "b")],
|
|
843
|
+
})
|
|
844
|
+
expect(vnode.type).toBe(Fragment)
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
test("jsx with single child (not array)", () => {
|
|
848
|
+
const vnode = jsx("div", { children: "text" })
|
|
849
|
+
expect(vnode.children).toHaveLength(1)
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
test("jsx with no children", () => {
|
|
853
|
+
const vnode = jsx("div", {})
|
|
854
|
+
expect(vnode.children).toHaveLength(0)
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
test("jsx component with children in props", () => {
|
|
858
|
+
const MyComp = (props: { children?: string }) => pyreonH("div", null, props.children ?? "")
|
|
859
|
+
const vnode = jsx(MyComp, { children: "child-text" })
|
|
860
|
+
expect(typeof vnode.type).toBe("function")
|
|
861
|
+
})
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
// ─── Hooks outside component ─────────────────────────────────────────────────
|
|
865
|
+
|
|
866
|
+
describe("hooks outside component", () => {
|
|
867
|
+
test("useState throws when called outside render", () => {
|
|
868
|
+
expect(() => useState(0)).toThrow("Hook called outside")
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
test("useEffect throws when called outside render", () => {
|
|
872
|
+
expect(() => useEffect(() => {})).toThrow("Hook called outside")
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
test("useRef throws when called outside render", () => {
|
|
876
|
+
expect(() => useRef(0)).toThrow("Hook called outside")
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
test("useMemo throws when called outside render", () => {
|
|
880
|
+
expect(() => useMemo(() => 0, [])).toThrow("Hook called outside")
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
test("useId throws when called outside render", () => {
|
|
884
|
+
expect(() => useId()).toThrow("Hook called outside")
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
test("useReducer throws when called outside render", () => {
|
|
888
|
+
expect(() => useReducer((s: number) => s, 0)).toThrow("Hook called outside")
|
|
889
|
+
})
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
// ─── Edge cases ──────────────────────────────────────────────────────────────
|
|
893
|
+
|
|
894
|
+
describe("edge cases", () => {
|
|
895
|
+
test("useState with string initial", () => {
|
|
896
|
+
const [val] = withHookCtx(() => useState("hello"))
|
|
897
|
+
expect(val).toBe("hello")
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
test("useReducer with non-function initial", () => {
|
|
901
|
+
const [state] = withHookCtx(() => useReducer((s: string, a: string) => s + a, "start"))
|
|
902
|
+
expect(state).toBe("start")
|
|
903
|
+
})
|
|
904
|
+
|
|
905
|
+
test("depsChanged handles different length arrays", () => {
|
|
906
|
+
const runner = createHookRunner()
|
|
907
|
+
runner.run(() => {
|
|
908
|
+
useEffect(() => {}, [1, 2])
|
|
909
|
+
})
|
|
910
|
+
expect(runner.ctx.pendingEffects).toHaveLength(1)
|
|
911
|
+
|
|
912
|
+
runner.run(() => {
|
|
913
|
+
useEffect(() => {}, [1, 2, 3])
|
|
914
|
+
})
|
|
915
|
+
expect(runner.ctx.pendingEffects).toHaveLength(1)
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
test("depsChanged with undefined deps always re-runs", () => {
|
|
919
|
+
const runner = createHookRunner()
|
|
920
|
+
runner.run(() => {
|
|
921
|
+
useEffect(() => {})
|
|
922
|
+
})
|
|
923
|
+
expect(runner.ctx.pendingEffects).toHaveLength(1)
|
|
924
|
+
|
|
925
|
+
runner.run(() => {
|
|
926
|
+
useEffect(() => {})
|
|
927
|
+
})
|
|
928
|
+
expect(runner.ctx.pendingEffects).toHaveLength(1)
|
|
929
|
+
})
|
|
930
|
+
})
|