@pyreon/solid-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.
@@ -0,0 +1,172 @@
1
+ import type { ComponentFn } from "@pyreon/core"
2
+ import { ErrorBoundary as CoreEB, Show as CoreShow, Suspense as CoreSuspense } from "@pyreon/core"
3
+ import { mount } from "@pyreon/runtime-dom"
4
+ import { children, createSignal, ErrorBoundary, onCleanup, onMount, Show, Suspense } from "../index"
5
+ import { jsx } from "../jsx-runtime"
6
+
7
+ describe("DOM integration - children helper", () => {
8
+ it("children helper should render VNode children, not [object Object]", () => {
9
+ function ColoredBox(props: { color: string; children?: any }) {
10
+ const resolved = children(() => props.children)
11
+ return jsx("div", {
12
+ style: `border: 2px solid ${props.color}`,
13
+ children: resolved(),
14
+ })
15
+ }
16
+
17
+ function App() {
18
+ return jsx(ColoredBox as ComponentFn, {
19
+ color: "blue",
20
+ children: jsx("p", { children: "Hello" }),
21
+ })
22
+ }
23
+
24
+ const container = document.createElement("div")
25
+ mount(jsx(App, {}), container)
26
+
27
+ expect(container.innerHTML).not.toContain("[object Object]")
28
+ expect(container.innerHTML).toContain("<p>Hello</p>")
29
+ })
30
+
31
+ it("simple compat component renders children correctly", () => {
32
+ function Wrapper(props: { children?: any }) {
33
+ return jsx("div", { class: "wrapper", children: props.children })
34
+ }
35
+
36
+ function App() {
37
+ return jsx(Wrapper as ComponentFn, {
38
+ children: jsx("span", { children: "inner" }),
39
+ })
40
+ }
41
+
42
+ const container = document.createElement("div")
43
+ mount(jsx(App, {}), container)
44
+
45
+ expect(container.innerHTML).not.toContain("[object Object]")
46
+ expect(container.innerHTML).toContain("<span>inner</span>")
47
+ })
48
+
49
+ it("re-exported Show/Suspense/ErrorBoundary are same references as core", () => {
50
+ expect(Show).toBe(CoreShow)
51
+ expect(Suspense).toBe(CoreSuspense)
52
+ expect(ErrorBoundary).toBe(CoreEB)
53
+ })
54
+
55
+ it("Show from solid-compat works through compat jsx runtime", () => {
56
+ const [visible] = createSignal(true)
57
+
58
+ function App() {
59
+ return jsx(Show as ComponentFn, {
60
+ when: visible,
61
+ children: jsx("p", { children: "visible!" }),
62
+ })
63
+ }
64
+
65
+ const container = document.createElement("div")
66
+ mount(jsx(App, {}), container)
67
+
68
+ expect(container.innerHTML).toContain("visible!")
69
+ expect(container.innerHTML).not.toContain("[object Object]")
70
+ })
71
+
72
+ it("nested compat components with children pass-through", () => {
73
+ function Demo(props: { title: string; children?: any }) {
74
+ return jsx("section", {
75
+ children: [jsx("h2", { children: props.title }), props.children],
76
+ })
77
+ }
78
+
79
+ function ColoredBox(props: { color: string; children?: any }) {
80
+ const resolved = children(() => props.children)
81
+ return jsx("div", {
82
+ style: `border: 2px solid ${props.color}`,
83
+ children: resolved(),
84
+ })
85
+ }
86
+
87
+ function App() {
88
+ return jsx(Demo as ComponentFn, {
89
+ title: "Test",
90
+ children: jsx(ColoredBox as ComponentFn, {
91
+ color: "blue",
92
+ children: jsx("p", { children: "Hello" }),
93
+ }),
94
+ })
95
+ }
96
+
97
+ const container = document.createElement("div")
98
+ mount(jsx(App, {}), container)
99
+
100
+ expect(container.innerHTML).not.toContain("[object Object]")
101
+ expect(container.innerHTML).toContain("<p>Hello</p>")
102
+ expect(container.innerHTML).toContain("<h2>Test</h2>")
103
+ })
104
+ })
105
+
106
+ describe("child instance preservation - no infinite re-render", () => {
107
+ it("onMount in child does not cause infinite loop when writing parent state", async () => {
108
+ let mountCount = 0
109
+
110
+ function Inner(props: { onEvent: (msg: string) => void }) {
111
+ onMount(() => {
112
+ mountCount++
113
+ props.onEvent("mounted")
114
+ return undefined
115
+ })
116
+ return jsx("p", { children: "alive" })
117
+ }
118
+
119
+ function Parent() {
120
+ const [_events, setEvents] = createSignal<string[]>([])
121
+ const addEvent = (msg: string) => setEvents((prev) => [...prev, msg])
122
+
123
+ return jsx("div", {
124
+ children: jsx(Inner as ComponentFn, { onEvent: addEvent }),
125
+ })
126
+ }
127
+
128
+ const container = document.createElement("div")
129
+ mount(jsx(Parent, {}), container)
130
+
131
+ // Wait for microtasks (onMount fires via microtask, re-render via microtask)
132
+ await new Promise((r) => setTimeout(r, 100))
133
+
134
+ // onMount should fire only once, not loop infinitely
135
+ expect(mountCount).toBeLessThanOrEqual(2)
136
+ expect(container.innerHTML).toContain("alive")
137
+ })
138
+
139
+ it("onCleanup in child does not cause infinite loop when writing parent state", async () => {
140
+ let cleanupCount = 0
141
+
142
+ function Inner(props: { onEvent: (msg: string) => void }) {
143
+ onCleanup(() => {
144
+ cleanupCount++
145
+ props.onEvent("cleaned up")
146
+ })
147
+ return jsx("p", { children: "alive" })
148
+ }
149
+
150
+ function Parent() {
151
+ const [show, _setShow] = createSignal(true)
152
+ const [_events, setEvents] = createSignal<string[]>([])
153
+ const addEvent = (msg: string) => setEvents((prev) => [...prev, msg])
154
+
155
+ return jsx("div", {
156
+ children: jsx(Show as ComponentFn, {
157
+ when: show,
158
+ children: jsx(Inner as ComponentFn, { onEvent: addEvent }),
159
+ }),
160
+ })
161
+ }
162
+
163
+ const container = document.createElement("div")
164
+ mount(jsx(Parent, {}), container)
165
+
166
+ await new Promise((r) => setTimeout(r, 100))
167
+
168
+ // onCleanup should not fire during normal render
169
+ expect(cleanupCount).toBe(0)
170
+ expect(container.innerHTML).toContain("alive")
171
+ })
172
+ })
@@ -27,11 +27,30 @@ import {
27
27
  untrack,
28
28
  useContext,
29
29
  } from "../index"
30
-
31
- function _container(): HTMLElement {
32
- const el = document.createElement("div")
33
- document.body.appendChild(el)
34
- return el
30
+ import type { RenderContext } from "../jsx-runtime"
31
+ import { beginRender, endRender } from "../jsx-runtime"
32
+
33
+ // ─── Test helpers ─────────────────────────────────────────────────────────────
34
+
35
+ /** Re-render helper: calls fn with the same ctx to simulate re-render */
36
+ function createHookRunner() {
37
+ const ctx: RenderContext = {
38
+ hooks: [],
39
+ scheduleRerender: () => {},
40
+ pendingEffects: [],
41
+ pendingLayoutEffects: [],
42
+ unmounted: false,
43
+ unmountCallbacks: [],
44
+ }
45
+ return {
46
+ ctx,
47
+ run<T>(fn: () => T): T {
48
+ beginRender(ctx)
49
+ const result = fn()
50
+ endRender()
51
+ return result
52
+ },
53
+ }
35
54
  }
36
55
 
37
56
  describe("@pyreon/solid-compat", () => {
@@ -60,6 +79,36 @@ describe("@pyreon/solid-compat", () => {
60
79
  expect(count()).toBe(15)
61
80
  })
62
81
 
82
+ // ─── createSignal in component context ─────────────────────────────────
83
+
84
+ it("createSignal in component context stores in hooks", () => {
85
+ const runner = createHookRunner()
86
+ const [count] = runner.run(() => createSignal(42))
87
+ expect(count()).toBe(42)
88
+ // Re-render returns same signal from hooks
89
+ const [count2] = runner.run(() => createSignal(42))
90
+ expect(count2()).toBe(42)
91
+ })
92
+
93
+ it("createSignal setter in component context triggers scheduleRerender", () => {
94
+ const runner = createHookRunner()
95
+ let rerenders = 0
96
+ runner.ctx.scheduleRerender = () => {
97
+ rerenders++
98
+ }
99
+ const [, setCount] = runner.run(() => createSignal(0))
100
+ setCount(5)
101
+ expect(rerenders).toBe(1)
102
+ })
103
+
104
+ it("createSignal setter persists across re-renders", () => {
105
+ const runner = createHookRunner()
106
+ const [, setCount] = runner.run(() => createSignal(0))
107
+ setCount(99)
108
+ const [count2] = runner.run(() => createSignal(0))
109
+ expect(count2()).toBe(99)
110
+ })
111
+
63
112
  // ─── createEffect ─────────────────────────────────────────────────────
64
113
 
65
114
  it("createEffect tracks signal reads", () => {
@@ -76,6 +125,45 @@ describe("@pyreon/solid-compat", () => {
76
125
  })
77
126
  })
78
127
 
128
+ it("createEffect in component context is hook-indexed", () => {
129
+ const runner = createHookRunner()
130
+ let effectRuns = 0
131
+ runner.run(() => {
132
+ const [count] = createSignal(0)
133
+ createEffect(() => {
134
+ count() // track
135
+ effectRuns++
136
+ })
137
+ })
138
+ expect(effectRuns).toBe(1)
139
+ // Re-render — effect should NOT be created again
140
+ runner.run(() => {
141
+ createSignal(0) // consume hook index
142
+ createEffect(() => {
143
+ effectRuns += 100 // should never run
144
+ })
145
+ })
146
+ expect(effectRuns).toBe(1) // still 1, not re-created
147
+ })
148
+
149
+ it("createEffect in component context is disposed on unmount", () => {
150
+ const runner = createHookRunner()
151
+ let effectRuns = 0
152
+ const [, setCount] = runner.run(() => {
153
+ const sig = createSignal(0)
154
+ createEffect(() => {
155
+ sig[0]()
156
+ effectRuns++
157
+ })
158
+ return sig
159
+ })
160
+ expect(effectRuns).toBe(1)
161
+ // Simulate unmount
162
+ for (const cb of runner.ctx.unmountCallbacks) cb()
163
+ setCount(5)
164
+ expect(effectRuns).toBe(1) // effect was disposed
165
+ })
166
+
79
167
  // ─── createRenderEffect ────────────────────────────────────────────────
80
168
 
81
169
  it("createRenderEffect tracks signal reads like createEffect", () => {
@@ -130,6 +218,21 @@ describe("@pyreon/solid-compat", () => {
130
218
  })
131
219
  })
132
220
 
221
+ it("createMemo in component context is hook-indexed", () => {
222
+ const runner = createHookRunner()
223
+ const doubled = runner.run(() => {
224
+ const [count] = createSignal(5)
225
+ return createMemo(() => count() * 2)
226
+ })
227
+ expect(doubled()).toBe(10)
228
+ // Re-render returns same computed from hooks
229
+ const doubled2 = runner.run(() => {
230
+ createSignal(5) // consume hook index
231
+ return createMemo(() => 999) // fn ignored on re-render
232
+ })
233
+ expect(doubled2()).toBe(10) // still uses original computed
234
+ })
235
+
133
236
  // ─── createRoot ───────────────────────────────────────────────────────
134
237
 
135
238
  it("createRoot provides cleanup", () => {
@@ -291,6 +394,21 @@ describe("@pyreon/solid-compat", () => {
291
394
  })
292
395
  })
293
396
 
397
+ it("createSelector in component context is hook-indexed", () => {
398
+ const runner = createHookRunner()
399
+ const isSelected = runner.run(() => {
400
+ const [selected] = createSignal(1)
401
+ return createSelector(selected)
402
+ })
403
+ expect(isSelected(1)).toBe(true)
404
+ // Re-render returns same selector
405
+ const isSelected2 = runner.run(() => {
406
+ createSignal(1) // consume hook index
407
+ return createSelector(() => 999) // fn ignored on re-render
408
+ })
409
+ expect(isSelected2).toBe(isSelected) // same instance
410
+ })
411
+
294
412
  // ─── mergeProps ───────────────────────────────────────────────────────
295
413
 
296
414
  it("mergeProps combines objects", () => {
@@ -369,30 +487,24 @@ describe("@pyreon/solid-compat", () => {
369
487
  expect(typeof Lazy.preload).toBe("function")
370
488
  })
371
489
 
372
- it("lazy component throws promise before loaded (for Suspense)", () => {
490
+ it("lazy component uses __loading protocol before loaded (for Suspense)", () => {
373
491
  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)
492
+ // Before resolved, __loading returns true and component returns null
493
+ expect(Lazy.__loading()).toBe(true)
494
+ const result = Lazy({})
495
+ expect(result).toBeNull()
381
496
  })
382
497
 
383
498
  it("lazy component renders after loading", async () => {
384
499
  const MyComp = () => h("div", null, "loaded")
385
500
  const Lazy = lazy(() => Promise.resolve({ default: MyComp }))
386
501
 
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
502
+ // Trigger load
503
+ Lazy({})
504
+ // Wait for promise to resolve
505
+ await Lazy.preload()
395
506
 
507
+ expect(Lazy.__loading()).toBe(false)
396
508
  const result = Lazy({})
397
509
  expect(result).not.toBeNull()
398
510
  })
@@ -475,6 +587,32 @@ describe("@pyreon/solid-compat", () => {
475
587
  expect(typeof onCleanup).toBe("function")
476
588
  })
477
589
 
590
+ it("onMount in component context only runs on first render", () => {
591
+ const runner = createHookRunner()
592
+ runner.run(() => {
593
+ onMount(() => undefined)
594
+ })
595
+ expect(runner.ctx.pendingEffects).toHaveLength(1)
596
+ // Re-render — should NOT add another effect
597
+ runner.run(() => {
598
+ onMount(() => undefined)
599
+ })
600
+ expect(runner.ctx.pendingEffects).toHaveLength(0) // cleared by beginRender
601
+ })
602
+
603
+ it("onCleanup in component context registers unmount callback", () => {
604
+ const runner = createHookRunner()
605
+ let cleaned = false
606
+ runner.run(() => {
607
+ onCleanup(() => {
608
+ cleaned = true
609
+ })
610
+ })
611
+ expect(cleaned).toBe(false)
612
+ for (const cb of runner.ctx.unmountCallbacks) cb()
613
+ expect(cleaned).toBe(true)
614
+ })
615
+
478
616
  // ─── createContext / useContext ────────────────────────────────────────
479
617
 
480
618
  it("createContext creates context with default value", () => {
@@ -644,4 +782,13 @@ describe("@pyreon/solid-compat", () => {
644
782
  const result = runWithOwner(null, () => "hello")
645
783
  expect(result).toBe("hello")
646
784
  })
785
+
786
+ // ─── JSX runtime ───────────────────────────────────────────────────────
787
+
788
+ it("jsx-runtime exports are available", async () => {
789
+ const jsxRuntime = await import("../jsx-runtime")
790
+ expect(typeof jsxRuntime.jsx).toBe("function")
791
+ expect(typeof jsxRuntime.jsxs).toBe("function")
792
+ expect(typeof jsxRuntime.Fragment).toBe("symbol")
793
+ })
647
794
  })