@pyreon/solid-compat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ import { GlobalRegistrator } from "@happy-dom/global-registrator"
2
+
3
+ GlobalRegistrator.register()
@@ -0,0 +1,647 @@
1
+ import { h } from "@pyreon/core"
2
+ import {
3
+ batch,
4
+ children,
5
+ createComputed,
6
+ createContext,
7
+ createEffect,
8
+ createMemo,
9
+ createRenderEffect,
10
+ createRoot,
11
+ createSelector,
12
+ createSignal,
13
+ ErrorBoundary,
14
+ For,
15
+ getOwner,
16
+ lazy,
17
+ Match,
18
+ mergeProps,
19
+ on,
20
+ onCleanup,
21
+ onMount,
22
+ runWithOwner,
23
+ Show,
24
+ Suspense,
25
+ Switch,
26
+ splitProps,
27
+ untrack,
28
+ useContext,
29
+ } from "../index"
30
+
31
+ function _container(): HTMLElement {
32
+ const el = document.createElement("div")
33
+ document.body.appendChild(el)
34
+ return el
35
+ }
36
+
37
+ describe("@pyreon/solid-compat", () => {
38
+ // ─── createSignal ─────────────────────────────────────────────────────
39
+
40
+ it("createSignal returns [getter, setter] tuple", () => {
41
+ const [count, setCount] = createSignal(0)
42
+ expect(typeof count).toBe("function")
43
+ expect(typeof setCount).toBe("function")
44
+ })
45
+
46
+ it("getter returns current value", () => {
47
+ const [count] = createSignal(42)
48
+ expect(count()).toBe(42)
49
+ })
50
+
51
+ it("setter updates value", () => {
52
+ const [count, setCount] = createSignal(0)
53
+ setCount(5)
54
+ expect(count()).toBe(5)
55
+ })
56
+
57
+ it("setter with updater function", () => {
58
+ const [count, setCount] = createSignal(10)
59
+ setCount((prev) => prev + 5)
60
+ expect(count()).toBe(15)
61
+ })
62
+
63
+ // ─── createEffect ─────────────────────────────────────────────────────
64
+
65
+ it("createEffect tracks signal reads", () => {
66
+ let effectValue = 0
67
+ createRoot((dispose) => {
68
+ const [count, setCount] = createSignal(0)
69
+ createEffect(() => {
70
+ effectValue = count()
71
+ })
72
+ expect(effectValue).toBe(0)
73
+ setCount(7)
74
+ expect(effectValue).toBe(7)
75
+ dispose()
76
+ })
77
+ })
78
+
79
+ // ─── createRenderEffect ────────────────────────────────────────────────
80
+
81
+ it("createRenderEffect tracks signal reads like createEffect", () => {
82
+ let effectValue = 0
83
+ createRoot((dispose) => {
84
+ const [count, setCount] = createSignal(0)
85
+ createRenderEffect(() => {
86
+ effectValue = count()
87
+ })
88
+ expect(effectValue).toBe(0)
89
+ setCount(3)
90
+ expect(effectValue).toBe(3)
91
+ dispose()
92
+ })
93
+ })
94
+
95
+ // ─── createComputed (alias) ────────────────────────────────────────────
96
+
97
+ it("createComputed is an alias for createEffect", () => {
98
+ let effectValue = 0
99
+ createRoot((dispose) => {
100
+ const [count, setCount] = createSignal(0)
101
+ createComputed(() => {
102
+ effectValue = count()
103
+ })
104
+ expect(effectValue).toBe(0)
105
+ setCount(5)
106
+ expect(effectValue).toBe(5)
107
+ dispose()
108
+ })
109
+ })
110
+
111
+ // ─── createMemo ───────────────────────────────────────────────────────
112
+
113
+ it("createMemo derives computed value", () => {
114
+ createRoot((dispose) => {
115
+ const [count] = createSignal(3)
116
+ const doubled = createMemo(() => count() * 2)
117
+ expect(doubled()).toBe(6)
118
+ dispose()
119
+ })
120
+ })
121
+
122
+ it("createMemo updates when dependency changes", () => {
123
+ createRoot((dispose) => {
124
+ const [count, setCount] = createSignal(3)
125
+ const doubled = createMemo(() => count() * 2)
126
+ expect(doubled()).toBe(6)
127
+ setCount(10)
128
+ expect(doubled()).toBe(20)
129
+ dispose()
130
+ })
131
+ })
132
+
133
+ // ─── createRoot ───────────────────────────────────────────────────────
134
+
135
+ it("createRoot provides cleanup", () => {
136
+ let effectRan = false
137
+ let disposed = false
138
+ createRoot((dispose) => {
139
+ const [count, setCount] = createSignal(0)
140
+ createEffect(() => {
141
+ count()
142
+ effectRan = true
143
+ })
144
+ expect(effectRan).toBe(true)
145
+ effectRan = false
146
+ dispose()
147
+ disposed = true
148
+ setCount(1)
149
+ expect(effectRan).toBe(false)
150
+ })
151
+ expect(disposed).toBe(true)
152
+ })
153
+
154
+ it("createRoot returns value from fn", () => {
155
+ const result = createRoot((dispose) => {
156
+ dispose()
157
+ return 42
158
+ })
159
+ expect(result).toBe(42)
160
+ })
161
+
162
+ // ─── batch ────────────────────────────────────────────────────────────
163
+
164
+ it("batch coalesces updates", () => {
165
+ let runs = 0
166
+ createRoot((dispose) => {
167
+ const [a, setA] = createSignal(1)
168
+ const [b, setB] = createSignal(2)
169
+ createEffect(() => {
170
+ a()
171
+ b()
172
+ runs++
173
+ })
174
+ expect(runs).toBe(1)
175
+ batch(() => {
176
+ setA(10)
177
+ setB(20)
178
+ })
179
+ expect(runs).toBe(2)
180
+ dispose()
181
+ })
182
+ })
183
+
184
+ // ─── untrack ──────────────────────────────────────────────────────────
185
+
186
+ it("untrack prevents tracking", () => {
187
+ let runs = 0
188
+ createRoot((dispose) => {
189
+ const [count, setCount] = createSignal(0)
190
+ const [other, setOther] = createSignal(0)
191
+ createEffect(() => {
192
+ count()
193
+ untrack(() => other())
194
+ runs++
195
+ })
196
+ expect(runs).toBe(1)
197
+ setOther(5)
198
+ expect(runs).toBe(1)
199
+ setCount(1)
200
+ expect(runs).toBe(2)
201
+ dispose()
202
+ })
203
+ })
204
+
205
+ // ─── on ───────────────────────────────────────────────────────────────
206
+
207
+ it("on() tracks specific single dependency", () => {
208
+ createRoot((dispose) => {
209
+ const [count, setCount] = createSignal(0)
210
+ const values: number[] = []
211
+
212
+ const tracker = on(count, (input) => {
213
+ values.push(input as number)
214
+ return input
215
+ })
216
+
217
+ createEffect(() => {
218
+ tracker()
219
+ })
220
+
221
+ expect(values).toEqual([0])
222
+ setCount(5)
223
+ expect(values).toEqual([0, 5])
224
+ dispose()
225
+ })
226
+ })
227
+
228
+ it("on() tracks array of dependencies", () => {
229
+ createRoot((dispose) => {
230
+ const [a, setA] = createSignal(1)
231
+ const [b, _setB] = createSignal(2)
232
+ const results: unknown[] = []
233
+
234
+ const tracker = on([a, b] as const, (input, prevInput, prevValue) => {
235
+ results.push({ input, prevInput, prevValue })
236
+ return input
237
+ })
238
+
239
+ createEffect(() => {
240
+ tracker()
241
+ })
242
+
243
+ expect(results).toHaveLength(1)
244
+ expect((results[0] as Record<string, unknown>).input).toEqual([1, 2])
245
+ expect((results[0] as Record<string, unknown>).prevInput).toBeUndefined()
246
+
247
+ setA(10)
248
+ expect(results).toHaveLength(2)
249
+ expect((results[1] as Record<string, unknown>).input).toEqual([10, 2])
250
+ expect((results[1] as Record<string, unknown>).prevInput).toEqual([1, 2])
251
+
252
+ dispose()
253
+ })
254
+ })
255
+
256
+ it("on() provides prevValue on subsequent calls", () => {
257
+ createRoot((dispose) => {
258
+ const [count, setCount] = createSignal(0)
259
+ const prevValues: unknown[] = []
260
+
261
+ const tracker = on(count, (input, _prevInput, prevValue) => {
262
+ prevValues.push(prevValue)
263
+ return (input as number) * 10
264
+ })
265
+
266
+ createEffect(() => {
267
+ tracker()
268
+ })
269
+
270
+ expect(prevValues).toEqual([undefined]) // first call
271
+ setCount(5)
272
+ expect(prevValues).toEqual([undefined, 0]) // prev value was 0*10 = 0
273
+ dispose()
274
+ })
275
+ })
276
+
277
+ // ─── createSelector ───────────────────────────────────────────────────
278
+
279
+ it("createSelector returns equality checker", () => {
280
+ createRoot((dispose) => {
281
+ const [selected, setSelected] = createSignal(1)
282
+ const isSelected = createSelector(selected)
283
+
284
+ expect(isSelected(1)).toBe(true)
285
+ expect(isSelected(2)).toBe(false)
286
+
287
+ setSelected(2)
288
+ expect(isSelected(1)).toBe(false)
289
+ expect(isSelected(2)).toBe(true)
290
+ dispose()
291
+ })
292
+ })
293
+
294
+ // ─── mergeProps ───────────────────────────────────────────────────────
295
+
296
+ it("mergeProps combines objects", () => {
297
+ const defaults = { color: "red", size: 10 }
298
+ const overrides = { size: 20, weight: "bold" }
299
+ const merged = mergeProps(defaults, overrides)
300
+ expect((merged as Record<string, unknown>).color).toBe("red")
301
+ expect((merged as Record<string, unknown>).size).toBe(20)
302
+ expect((merged as Record<string, unknown>).weight).toBe("bold")
303
+ })
304
+
305
+ it("mergeProps preserves getters for reactivity", () => {
306
+ const [count, setCount] = createSignal(0)
307
+ const props = {}
308
+ Object.defineProperty(props, "count", {
309
+ get: count,
310
+ enumerable: true,
311
+ configurable: true,
312
+ })
313
+ const merged = mergeProps(props)
314
+ expect((merged as Record<string, unknown>).count).toBe(0)
315
+ setCount(5)
316
+ expect((merged as Record<string, unknown>).count).toBe(5)
317
+ })
318
+
319
+ // ─── splitProps ───────────────────────────────────────────────────────
320
+
321
+ it("splitProps separates props", () => {
322
+ const props = { name: "hello", class: "btn", onClick: () => {} }
323
+ const [local, rest] = splitProps(props, "name")
324
+ expect((local as Record<string, unknown>).name).toBe("hello")
325
+ expect((local as Record<string, unknown>).class).toBeUndefined()
326
+ expect((rest as Record<string, unknown>).class).toBe("btn")
327
+ expect((rest as Record<string, unknown>).onClick).toBeDefined()
328
+ })
329
+
330
+ it("splitProps preserves getters", () => {
331
+ const [count, setCount] = createSignal(0)
332
+ const props = {} as Record<string, unknown>
333
+ Object.defineProperty(props, "count", {
334
+ get: count,
335
+ enumerable: true,
336
+ configurable: true,
337
+ })
338
+ Object.defineProperty(props, "label", {
339
+ value: "test",
340
+ writable: true,
341
+ enumerable: true,
342
+ configurable: true,
343
+ })
344
+
345
+ const [local, rest] = splitProps(props as { count: number; label: string }, "count")
346
+ expect((local as Record<string, unknown>).count).toBe(0)
347
+ setCount(10)
348
+ expect((local as Record<string, unknown>).count).toBe(10)
349
+ expect((rest as Record<string, unknown>).label).toBe("test")
350
+ })
351
+
352
+ // ─── children ─────────────────────────────────────────────────────────
353
+
354
+ it("children resolves static values", () => {
355
+ const resolved = children(() => "hello")
356
+ expect(resolved()).toBe("hello")
357
+ })
358
+
359
+ it("children resolves function children (reactive getters)", () => {
360
+ const resolved = children(() => (() => "dynamic") as unknown as ReturnType<typeof h>)
361
+ expect(resolved()).toBe("dynamic")
362
+ })
363
+
364
+ // ─── lazy ─────────────────────────────────────────────────────────────
365
+
366
+ it("lazy returns a component with preload", () => {
367
+ const Lazy = lazy(() => Promise.resolve({ default: () => h("div", null, "loaded") }))
368
+ expect(typeof Lazy).toBe("function")
369
+ expect(typeof Lazy.preload).toBe("function")
370
+ })
371
+
372
+ it("lazy component throws promise before loaded (for Suspense)", () => {
373
+ const Lazy = lazy(() => Promise.resolve({ default: () => h("div", null, "loaded") }))
374
+ let thrown: unknown
375
+ try {
376
+ Lazy({})
377
+ } catch (e) {
378
+ thrown = e
379
+ }
380
+ expect(thrown).toBeInstanceOf(Promise)
381
+ })
382
+
383
+ it("lazy component renders after loading", async () => {
384
+ const MyComp = () => h("div", null, "loaded")
385
+ const Lazy = lazy(() => Promise.resolve({ default: MyComp }))
386
+
387
+ // Trigger load by catching the thrown promise, then await it
388
+ let thrown: unknown
389
+ try {
390
+ Lazy({})
391
+ } catch (e) {
392
+ thrown = e
393
+ }
394
+ await thrown
395
+
396
+ const result = Lazy({})
397
+ expect(result).not.toBeNull()
398
+ })
399
+
400
+ it("lazy preload triggers loading", async () => {
401
+ const MyComp = () => h("div", null, "loaded")
402
+ const Lazy = lazy(() => Promise.resolve({ default: MyComp }))
403
+
404
+ const promise = Lazy.preload()
405
+ expect(promise).toBeInstanceOf(Promise)
406
+
407
+ await promise
408
+ const result = Lazy({})
409
+ expect(result).not.toBeNull()
410
+ })
411
+
412
+ it("lazy preload only loads once", async () => {
413
+ let loadCount = 0
414
+ const MyComp = () => h("div", null, "loaded")
415
+ const Lazy = lazy(() => {
416
+ loadCount++
417
+ return Promise.resolve({ default: MyComp })
418
+ })
419
+
420
+ const p1 = Lazy.preload()
421
+ const p2 = Lazy.preload()
422
+ expect(p1).toBe(p2) // same promise
423
+ await p1
424
+ expect(loadCount).toBe(1)
425
+ })
426
+
427
+ // ─── getOwner / runWithOwner ──────────────────────────────────────────
428
+
429
+ it("getOwner returns current scope or null", () => {
430
+ // Outside any scope, may return null
431
+ const _outerOwner = getOwner()
432
+
433
+ createRoot((dispose) => {
434
+ const owner = getOwner()
435
+ expect(owner).not.toBeNull()
436
+ dispose()
437
+ })
438
+ })
439
+
440
+ it("runWithOwner runs fn within the given scope", () => {
441
+ createRoot((dispose) => {
442
+ const owner = getOwner()
443
+ let ranInScope = false
444
+
445
+ runWithOwner(owner, () => {
446
+ ranInScope = true
447
+ return undefined
448
+ })
449
+
450
+ expect(ranInScope).toBe(true)
451
+ dispose()
452
+ })
453
+ })
454
+
455
+ it("runWithOwner with null owner", () => {
456
+ const result = runWithOwner(null, () => 42)
457
+ expect(result).toBe(42)
458
+ })
459
+
460
+ it("runWithOwner restores previous scope even on error", () => {
461
+ createRoot((dispose) => {
462
+ expect(() => {
463
+ runWithOwner(null, () => {
464
+ throw new Error("test error")
465
+ })
466
+ }).toThrow("test error")
467
+ dispose()
468
+ })
469
+ })
470
+
471
+ // ─── onMount / onCleanup ──────────────────────────────────────────────
472
+
473
+ it("onMount and onCleanup are functions", () => {
474
+ expect(typeof onMount).toBe("function")
475
+ expect(typeof onCleanup).toBe("function")
476
+ })
477
+
478
+ // ─── createContext / useContext ────────────────────────────────────────
479
+
480
+ it("createContext creates context with default value", () => {
481
+ const Ctx = createContext("default-value")
482
+ expect(useContext(Ctx)).toBe("default-value")
483
+ })
484
+
485
+ // ─── Re-exports ───────────────────────────────────────────────────────
486
+
487
+ it("Show is exported", () => {
488
+ expect(typeof Show).toBe("function")
489
+ })
490
+
491
+ it("Switch is exported", () => {
492
+ expect(typeof Switch).toBe("function")
493
+ })
494
+
495
+ it("Match is exported", () => {
496
+ expect(typeof Match).toBe("function")
497
+ })
498
+
499
+ it("For is exported", () => {
500
+ expect(typeof For).toBe("function")
501
+ })
502
+
503
+ it("Suspense is exported", () => {
504
+ expect(typeof Suspense).toBe("function")
505
+ })
506
+
507
+ it("ErrorBoundary is exported", () => {
508
+ expect(typeof ErrorBoundary).toBe("function")
509
+ })
510
+
511
+ // ─── on() edge cases ──────────────────────────────────────────────────
512
+
513
+ it("on() with single accessor (non-array) tracks correctly", () => {
514
+ createRoot((dispose) => {
515
+ const [count, setCount] = createSignal(10)
516
+ const results: unknown[] = []
517
+
518
+ const tracker = on(count, (input, prevInput, prevValue) => {
519
+ results.push({ input, prevInput, prevValue })
520
+ return (input as number) * 2
521
+ })
522
+
523
+ createEffect(() => {
524
+ tracker()
525
+ })
526
+
527
+ // First call: initialized
528
+ expect(results).toHaveLength(1)
529
+ expect((results[0] as Record<string, unknown>).input).toBe(10)
530
+ expect((results[0] as Record<string, unknown>).prevInput).toBeUndefined()
531
+ expect((results[0] as Record<string, unknown>).prevValue).toBeUndefined()
532
+
533
+ setCount(20)
534
+ expect(results).toHaveLength(2)
535
+ expect((results[1] as Record<string, unknown>).input).toBe(20)
536
+ expect((results[1] as Record<string, unknown>).prevInput).toBe(10)
537
+ expect((results[1] as Record<string, unknown>).prevValue).toBe(20) // 10*2
538
+
539
+ dispose()
540
+ })
541
+ })
542
+
543
+ // ─── mergeProps — getter preservation ──────────────────────────────────
544
+
545
+ it("mergeProps preserves getters for reactivity", () => {
546
+ const source = {} as Record<string, unknown>
547
+ Object.defineProperty(source, "x", { get: () => 42, enumerable: true, configurable: true })
548
+ const merged = mergeProps(source) as Record<string, unknown>
549
+ expect(merged.x).toBe(42)
550
+ })
551
+
552
+ // ─── mergeProps edge cases ─────────────────────────────────────────────
553
+
554
+ it("mergeProps with multiple sources overrides in order", () => {
555
+ const a = { x: 1, y: 2 }
556
+ const b = { y: 3, z: 4 }
557
+ const c = { z: 5 }
558
+ const merged = mergeProps(a, b, c) as Record<string, number>
559
+ expect(merged.x).toBe(1)
560
+ expect(merged.y).toBe(3)
561
+ expect(merged.z).toBe(5)
562
+ })
563
+
564
+ it("mergeProps with empty source", () => {
565
+ const merged = mergeProps({}, { a: 1 }) as Record<string, number>
566
+ expect(merged.a).toBe(1)
567
+ })
568
+
569
+ // ─── splitProps — getter preservation ──────────────────────────────────
570
+
571
+ it("splitProps preserves getters in picked set", () => {
572
+ const source = {} as Record<string, unknown>
573
+ Object.defineProperty(source, "a", { get: () => 99, enumerable: true, configurable: true })
574
+ source.b = 2
575
+ const [local, rest] = splitProps(source as { a: number; b: number }, "a")
576
+ expect((local as Record<string, unknown>).a).toBe(99)
577
+ expect((rest as Record<string, unknown>).b).toBe(2)
578
+ })
579
+
580
+ // ─── splitProps edge cases ─────────────────────────────────────────────
581
+
582
+ it("splitProps with getter in rest", () => {
583
+ const [count, setCount] = createSignal(0)
584
+ const props = {} as Record<string, unknown>
585
+ Object.defineProperty(props, "count", {
586
+ get: count,
587
+ enumerable: true,
588
+ configurable: true,
589
+ })
590
+ Object.defineProperty(props, "name", {
591
+ value: "test",
592
+ writable: true,
593
+ enumerable: true,
594
+ configurable: true,
595
+ })
596
+
597
+ const [local, rest] = splitProps(props as { count: number; name: string }, "name")
598
+ expect((local as Record<string, unknown>).name).toBe("test")
599
+ expect((rest as Record<string, unknown>).count).toBe(0)
600
+ setCount(42)
601
+ expect((rest as Record<string, unknown>).count).toBe(42)
602
+ })
603
+
604
+ // ─── children edge cases ───────────────────────────────────────────────
605
+
606
+ it("children resolves non-function values as-is", () => {
607
+ const resolved = children(() => 42 as unknown as ReturnType<typeof h>)
608
+ expect(resolved()).toBe(42)
609
+ })
610
+
611
+ it("children resolves null", () => {
612
+ const resolved = children(() => null)
613
+ expect(resolved()).toBeNull()
614
+ })
615
+
616
+ // ─── lazy edge: preload called multiple times ──────────────────────────
617
+
618
+ it("lazy component called after preload resolves renders correctly", async () => {
619
+ const MyComp = (props: { msg: string }) => h("span", null, props.msg)
620
+ const Lazy = lazy(() => Promise.resolve({ default: MyComp }))
621
+
622
+ await Lazy.preload()
623
+ const result = Lazy({ msg: "loaded" })
624
+ expect(result).not.toBeNull()
625
+ })
626
+
627
+ // ─── createRoot restores scope ─────────────────────────────────────────
628
+
629
+ it("createRoot restores previous scope after fn completes", () => {
630
+ const outerOwner = getOwner()
631
+ createRoot((dispose) => {
632
+ const innerOwner = getOwner()
633
+ expect(innerOwner).not.toBeNull()
634
+ dispose()
635
+ })
636
+ // After createRoot, scope should be restored to outer
637
+ const afterOwner = getOwner()
638
+ expect(afterOwner).toBe(outerOwner)
639
+ })
640
+
641
+ // ─── runWithOwner restores scope ───────────────────────────────────────
642
+
643
+ it("runWithOwner returns value from fn", () => {
644
+ const result = runWithOwner(null, () => "hello")
645
+ expect(result).toBe("hello")
646
+ })
647
+ })