@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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +132 -12
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +130 -12
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index2.d.ts +32 -4
- package/lib/types/index2.d.ts.map +1 -1
- package/package.json +14 -4
- package/src/index.ts +168 -24
- package/src/jsx-runtime.ts +300 -0
- package/src/tests/repro.test.ts +172 -0
- package/src/tests/solid-compat.test.ts +168 -21
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
490
|
+
it("lazy component uses __loading protocol before loaded (for Suspense)", () => {
|
|
373
491
|
const Lazy = lazy(() => Promise.resolve({ default: () => h("div", null, "loaded") }))
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
})
|