@pyreon/solid-compat 0.2.1 → 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/src/index.ts CHANGED
@@ -1,6 +1,19 @@
1
- // @pyreon/solid-compat — SolidJS-compatible API shims running on Pyreon's reactive engine
2
-
3
- import type { ComponentFn, Props, VNodeChild } from "@pyreon/core"
1
+ /**
2
+ * @pyreon/solid-compat
3
+ *
4
+ * Fully SolidJS-compatible API powered by Pyreon's reactive engine.
5
+ *
6
+ * Components re-render on state change via the compat JSX runtime wrapper.
7
+ * Signals use Pyreon's native signal system internally (enabling auto-tracking
8
+ * for createEffect/createMemo), while the component body runs inside
9
+ * `runUntracked` to prevent signal reads from being tracked by the reactive
10
+ * accessor. Only the version signal triggers re-renders.
11
+ *
12
+ * USAGE:
13
+ * import { createSignal, createEffect } from "solid-js" // aliased by vite plugin
14
+ */
15
+
16
+ import type { ComponentFn, LazyComponent, Props, VNodeChild } from "@pyreon/core"
4
17
  import {
5
18
  ErrorBoundary,
6
19
  For,
@@ -25,6 +38,7 @@ import {
25
38
  runUntracked,
26
39
  setCurrentScope,
27
40
  } from "@pyreon/reactivity"
41
+ import { getCurrentCtx, getHookIndex } from "./jsx-runtime"
28
42
 
29
43
  // ─── createSignal ────────────────────────────────────────────────────────────
30
44
 
@@ -32,10 +46,30 @@ export type SignalGetter<T> = () => T
32
46
  export type SignalSetter<T> = (v: T | ((prev: T) => T)) => void
33
47
 
34
48
  export function createSignal<T>(initialValue: T): [SignalGetter<T>, SignalSetter<T>] {
35
- const s = pyreonSignal<T>(initialValue)
49
+ const ctx = getCurrentCtx()
50
+ if (ctx) {
51
+ const idx = getHookIndex()
52
+ if (idx >= ctx.hooks.length) {
53
+ ctx.hooks[idx] = pyreonSignal<T>(initialValue)
54
+ }
55
+ const s = ctx.hooks[idx] as ReturnType<typeof pyreonSignal<T>>
56
+ const { scheduleRerender } = ctx
36
57
 
37
- const getter: SignalGetter<T> = () => s()
58
+ const getter: SignalGetter<T> = () => s()
59
+ const setter: SignalSetter<T> = (v) => {
60
+ if (typeof v === "function") {
61
+ s.update(v as (prev: T) => T)
62
+ } else {
63
+ s.set(v)
64
+ }
65
+ scheduleRerender()
66
+ }
67
+ return [getter, setter]
68
+ }
38
69
 
70
+ // Outside component — plain Pyreon signal
71
+ const s = pyreonSignal<T>(initialValue)
72
+ const getter: SignalGetter<T> = () => s()
39
73
  const setter: SignalSetter<T> = (v) => {
40
74
  if (typeof v === "function") {
41
75
  s.update(v as (prev: T) => T)
@@ -43,20 +77,53 @@ export function createSignal<T>(initialValue: T): [SignalGetter<T>, SignalSetter
43
77
  s.set(v)
44
78
  }
45
79
  }
46
-
47
80
  return [getter, setter]
48
81
  }
49
82
 
50
83
  // ─── createEffect ────────────────────────────────────────────────────────────
51
84
 
85
+ /**
86
+ * Solid-compatible `createEffect` — creates a reactive side effect.
87
+ *
88
+ * In component context: hook-indexed, only created on first render. The effect
89
+ * uses Pyreon's native tracking so signal reads are automatically tracked.
90
+ * A re-entrance guard prevents infinite loops from signal writes inside
91
+ * the effect.
92
+ */
52
93
  export function createEffect(fn: () => void): void {
94
+ const ctx = getCurrentCtx()
95
+ if (ctx) {
96
+ const idx = getHookIndex()
97
+ if (idx < ctx.hooks.length) return // Already registered on first render
98
+
99
+ let running = false
100
+ const e = pyreonEffect(() => {
101
+ if (running) return
102
+ running = true
103
+ try {
104
+ fn()
105
+ } finally {
106
+ running = false
107
+ }
108
+ })
109
+ const stop = () => e.dispose()
110
+ ctx.hooks[idx] = stop
111
+ ctx.unmountCallbacks.push(stop)
112
+ return
113
+ }
114
+
115
+ // Outside component
53
116
  pyreonEffect(fn)
54
117
  }
55
118
 
56
119
  // ─── createRenderEffect ──────────────────────────────────────────────────────
57
120
 
121
+ /**
122
+ * Solid-compatible `createRenderEffect` — same as createEffect.
123
+ * In Solid, this runs during the render phase; here it runs as a Pyreon effect.
124
+ */
58
125
  export function createRenderEffect(fn: () => void): void {
59
- pyreonEffect(fn)
126
+ createEffect(fn)
60
127
  }
61
128
 
62
129
  // ─── createComputed (legacy Solid API) ───────────────────────────────────────
@@ -65,7 +132,24 @@ export { createEffect as createComputed }
65
132
 
66
133
  // ─── createMemo ──────────────────────────────────────────────────────────────
67
134
 
135
+ /**
136
+ * Solid-compatible `createMemo` — derives a value from reactive sources.
137
+ *
138
+ * In component context: hook-indexed, only created on first render.
139
+ * Uses Pyreon's native computed for auto-tracking.
140
+ */
68
141
  export function createMemo<T>(fn: () => T): () => T {
142
+ const ctx = getCurrentCtx()
143
+ if (ctx) {
144
+ const idx = getHookIndex()
145
+ if (idx >= ctx.hooks.length) {
146
+ ctx.hooks[idx] = pyreonComputed(fn)
147
+ }
148
+ const c = ctx.hooks[idx] as ReturnType<typeof pyreonComputed<T>>
149
+ return () => c()
150
+ }
151
+
152
+ // Outside component
69
153
  const c = pyreonComputed(fn)
70
154
  return () => c()
71
155
  }
@@ -131,11 +215,64 @@ export { runUntracked as untrack }
131
215
 
132
216
  // ─── onMount / onCleanup ─────────────────────────────────────────────────────
133
217
 
134
- export { pyreonOnMount as onMount, pyreonOnUnmount as onCleanup }
218
+ /**
219
+ * Solid-compatible `onMount` — runs once after the component's first render.
220
+ */
221
+ type CleanupFn = () => void
222
+ export function onMount(fn: () => CleanupFn | undefined): void {
223
+ const ctx = getCurrentCtx()
224
+ if (ctx) {
225
+ const idx = getHookIndex()
226
+ if (idx >= ctx.hooks.length) {
227
+ ctx.hooks[idx] = true
228
+ ctx.pendingEffects.push({
229
+ fn: () => {
230
+ fn()
231
+ return undefined
232
+ },
233
+ deps: undefined,
234
+ cleanup: undefined,
235
+ })
236
+ }
237
+ return
238
+ }
239
+
240
+ // Outside component
241
+ pyreonOnMount(fn)
242
+ }
243
+
244
+ /**
245
+ * Solid-compatible `onCleanup` — registers a callback to run when the component unmounts.
246
+ */
247
+ export function onCleanup(fn: () => void): void {
248
+ const ctx = getCurrentCtx()
249
+ if (ctx) {
250
+ const idx = getHookIndex()
251
+ if (idx >= ctx.hooks.length) {
252
+ ctx.hooks[idx] = true
253
+ ctx.unmountCallbacks.push(fn)
254
+ }
255
+ return
256
+ }
257
+
258
+ // Outside component
259
+ pyreonOnUnmount(fn)
260
+ }
135
261
 
136
262
  // ─── createSelector ──────────────────────────────────────────────────────────
137
263
 
138
- export { pyreonCreateSelector as createSelector }
264
+ export function createSelector<T>(source: () => T): (key: T) => boolean {
265
+ const ctx = getCurrentCtx()
266
+ if (ctx) {
267
+ const idx = getHookIndex()
268
+ if (idx >= ctx.hooks.length) {
269
+ ctx.hooks[idx] = pyreonCreateSelector(source)
270
+ }
271
+ return ctx.hooks[idx] as (key: T) => boolean
272
+ }
273
+
274
+ return pyreonCreateSelector(source)
275
+ }
139
276
 
140
277
  // ─── mergeProps ──────────────────────────────────────────────────────────────
141
278
 
@@ -224,37 +361,44 @@ export function children(fn: () => VNodeChild): () => VNodeChild {
224
361
 
225
362
  export function lazy<P extends Props>(
226
363
  loader: () => Promise<{ default: ComponentFn<P> }>,
227
- ): ComponentFn<P> & { preload: () => Promise<{ default: ComponentFn<P> }> } {
228
- let resolved: ComponentFn<P> | null = null
229
- let error: Error | null = null
364
+ ): LazyComponent<P> & { preload: () => Promise<{ default: ComponentFn<P> }> } {
365
+ const loaded = pyreonSignal<ComponentFn<P> | null>(null)
366
+ const error = pyreonSignal<Error | null>(null)
230
367
  let promise: Promise<{ default: ComponentFn<P> }> | null = null
231
368
 
232
369
  const load = () => {
233
370
  if (!promise) {
234
371
  promise = loader()
235
372
  .then((mod) => {
236
- resolved = mod.default
373
+ loaded.set(mod.default)
237
374
  return mod
238
375
  })
239
376
  .catch((err) => {
240
- error = err instanceof Error ? err : new Error(String(err))
241
- // Allow retry on next render by resetting the promise
377
+ const e = err instanceof Error ? err : new Error(String(err))
378
+ error.set(e)
242
379
  promise = null
243
- throw error
380
+ throw e
244
381
  })
245
382
  }
246
383
  return promise
247
384
  }
248
385
 
386
+ // Uses Pyreon's __loading protocol — Suspense checks this to show fallback.
387
+ // __loading() triggers load() on first call so loading starts when Suspense
388
+ // first encounters the component (not at module load time, not on first render).
249
389
  const LazyComponent = ((props: P) => {
250
- if (error) throw error
251
- if (!resolved) {
252
- // Throw the promise so Suspense can catch it
253
- throw load()
254
- }
255
- return resolved(props)
256
- }) as ComponentFn<P> & { preload: () => Promise<{ default: ComponentFn<P> }> }
257
-
390
+ const err = error()
391
+ if (err) throw err
392
+ const comp = loaded()
393
+ if (!comp) return null
394
+ return comp(props)
395
+ }) as LazyComponent<P> & { preload: () => Promise<{ default: ComponentFn<P> }> }
396
+
397
+ LazyComponent.__loading = () => {
398
+ const isLoading = loaded() === null && error() === null
399
+ if (isLoading) load()
400
+ return isLoading
401
+ }
258
402
  LazyComponent.preload = load
259
403
 
260
404
  return LazyComponent
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Compat JSX runtime for SolidJS compatibility mode.
3
+ *
4
+ * When `jsxImportSource` is redirected to `@pyreon/solid-compat` (via the vite
5
+ * plugin's `compat: "solid"` option), OXC rewrites JSX to import from this file.
6
+ *
7
+ * For component VNodes, we wrap the component function so it returns a reactive
8
+ * accessor — enabling Solid-style re-renders on state change while Pyreon's
9
+ * existing renderer handles all DOM work.
10
+ *
11
+ * The component body runs inside `runUntracked` to prevent signal reads (from
12
+ * createSignal getters) from being tracked by the reactive accessor. Only the
13
+ * version signal triggers re-renders.
14
+ *
15
+ * ## Child instance preservation
16
+ *
17
+ * When a parent component re-renders, mountReactive does a full teardown+rebuild
18
+ * of the DOM tree. Without preservation, child components get brand new
19
+ * RenderContexts with empty hooks arrays — causing `onMount` and `onCleanup`
20
+ * to fire again, which can trigger infinite re-render loops.
21
+ *
22
+ * To fix this, we store child RenderContexts in the parent's hooks array (indexed
23
+ * by the parent's hook counter). When the child wrapper is called again after a
24
+ * parent re-render, it reuses the existing ctx (preserving hooks state), so
25
+ * hook-indexed guards like `if (idx >= ctx.hooks.length) return` work correctly
26
+ * and lifecycle hooks don't re-fire.
27
+ */
28
+
29
+ import type { ComponentFn, Props, VNode, VNodeChild } from "@pyreon/core"
30
+ import {
31
+ ErrorBoundary,
32
+ For,
33
+ Fragment,
34
+ h,
35
+ Match,
36
+ onUnmount,
37
+ Show,
38
+ Suspense,
39
+ Switch,
40
+ } from "@pyreon/core"
41
+ import { runUntracked, signal } from "@pyreon/reactivity"
42
+
43
+ export { Fragment }
44
+
45
+ // ─── Render context (used by hooks) ──────────────────────────────────────────
46
+
47
+ export interface RenderContext {
48
+ hooks: unknown[]
49
+ scheduleRerender: () => void
50
+ /** Effect entries pending execution after render */
51
+ pendingEffects: EffectEntry[]
52
+ /** Layout effect entries pending execution after render */
53
+ pendingLayoutEffects: EffectEntry[]
54
+ /** Set to true when the component is unmounted */
55
+ unmounted: boolean
56
+ /** Callbacks to run on unmount (lifecycle + effect cleanups) */
57
+ unmountCallbacks: (() => void)[]
58
+ }
59
+
60
+ export interface EffectEntry {
61
+ // biome-ignore lint/suspicious/noConfusingVoidType: matches Solid's effect signature
62
+ fn: () => (() => void) | void
63
+ deps: unknown[] | undefined
64
+ cleanup: (() => void) | undefined
65
+ }
66
+
67
+ let _currentCtx: RenderContext | null = null
68
+ let _hookIndex = 0
69
+
70
+ export function getCurrentCtx(): RenderContext | null {
71
+ return _currentCtx
72
+ }
73
+
74
+ export function getHookIndex(): number {
75
+ return _hookIndex++
76
+ }
77
+
78
+ export function beginRender(ctx: RenderContext): void {
79
+ _currentCtx = ctx
80
+ _hookIndex = 0
81
+ ctx.pendingEffects = []
82
+ ctx.pendingLayoutEffects = []
83
+ }
84
+
85
+ export function endRender(): void {
86
+ _currentCtx = null
87
+ _hookIndex = 0
88
+ }
89
+
90
+ // ─── Effect runners ──────────────────────────────────────────────────────────
91
+
92
+ function runLayoutEffects(entries: EffectEntry[]): void {
93
+ for (const entry of entries) {
94
+ if (entry.cleanup) entry.cleanup()
95
+ const cleanup = entry.fn()
96
+ entry.cleanup = typeof cleanup === "function" ? cleanup : undefined
97
+ }
98
+ }
99
+
100
+ function scheduleEffects(ctx: RenderContext, entries: EffectEntry[]): void {
101
+ if (entries.length === 0) return
102
+ queueMicrotask(() => {
103
+ for (const entry of entries) {
104
+ if (ctx.unmounted) return
105
+ if (entry.cleanup) entry.cleanup()
106
+ const cleanup = entry.fn()
107
+ entry.cleanup = typeof cleanup === "function" ? cleanup : undefined
108
+ }
109
+ })
110
+ }
111
+
112
+ // ─── Child instance preservation ─────────────────────────────────────────────
113
+
114
+ /** Stored in the parent's hooks array to preserve child state across re-renders */
115
+ interface ChildInstance {
116
+ ctx: RenderContext
117
+ version: ReturnType<typeof signal<number>>
118
+ updateScheduled: boolean
119
+ }
120
+
121
+ // Internal prop keys for passing parent context info to child wrappers
122
+ const _CHILD_INSTANCE = Symbol.for("pyreon.childInstance")
123
+ const noop = () => {
124
+ /* noop */
125
+ }
126
+
127
+ // ─── Component wrapping ──────────────────────────────────────────────────────
128
+
129
+ // biome-ignore lint/complexity/noBannedTypes: Function is needed for generic component wrapping
130
+ const _wrapperCache = new WeakMap<Function, ComponentFn>()
131
+
132
+ // Pyreon core components that must NOT be wrapped — they rely on internal reactivity
133
+ // biome-ignore lint/complexity/noBannedTypes: Function is needed for generic component set
134
+ const _nativeComponents: Set<Function> = new Set([
135
+ Show,
136
+ For,
137
+ Switch,
138
+ Match,
139
+ Suspense,
140
+ ErrorBoundary,
141
+ ])
142
+
143
+ // biome-ignore lint/complexity/noBannedTypes: Function is needed for generic component wrapping
144
+ function wrapCompatComponent(solidComponent: Function): ComponentFn {
145
+ if (_nativeComponents.has(solidComponent)) return solidComponent as ComponentFn
146
+
147
+ let wrapped = _wrapperCache.get(solidComponent)
148
+ if (wrapped) return wrapped
149
+
150
+ // The wrapper returns a reactive accessor (() => VNodeChild) which Pyreon's
151
+ // mountChild treats as a reactive expression via mountReactive.
152
+ wrapped = ((props: Props) => {
153
+ // Check for a preserved child instance from the parent's hooks
154
+ const existing = (props as Record<symbol, unknown>)[_CHILD_INSTANCE] as
155
+ | ChildInstance
156
+ | undefined
157
+
158
+ const ctx: RenderContext = existing?.ctx ?? {
159
+ hooks: [],
160
+ scheduleRerender: () => {
161
+ // Will be replaced below after version signal is created
162
+ },
163
+ pendingEffects: [],
164
+ pendingLayoutEffects: [],
165
+ unmounted: false,
166
+ unmountCallbacks: [],
167
+ }
168
+
169
+ // When reusing an existing ctx after parent re-render, reset unmounted flag
170
+ // and clear stale unmount callbacks (they belong to the previous mount cycle)
171
+ if (existing) {
172
+ ctx.unmounted = false
173
+ ctx.unmountCallbacks = []
174
+ }
175
+
176
+ const version = existing?.version ?? signal(0)
177
+
178
+ // Use a shared updateScheduled flag (preserved across parent re-renders)
179
+ let updateScheduled = existing?.updateScheduled ?? false
180
+
181
+ ctx.scheduleRerender = () => {
182
+ if (ctx.unmounted || updateScheduled) return
183
+ updateScheduled = true
184
+ queueMicrotask(() => {
185
+ updateScheduled = false
186
+ if (!ctx.unmounted) version.set(version.peek() + 1)
187
+ })
188
+ }
189
+
190
+ // Register cleanup when component unmounts
191
+ onUnmount(() => {
192
+ ctx.unmounted = true
193
+ for (const cb of ctx.unmountCallbacks) cb()
194
+ })
195
+
196
+ // Strip the internal prop before passing to the component
197
+ const { [_CHILD_INSTANCE]: _stripped, ...cleanProps } = props as Record<
198
+ string | symbol,
199
+ unknown
200
+ >
201
+
202
+ // Return reactive accessor — Pyreon's mountChild calls mountReactive
203
+ return () => {
204
+ version() // tracked read — triggers re-execution when state changes
205
+ beginRender(ctx)
206
+ // runUntracked prevents signal reads (from createSignal getters) from
207
+ // being tracked by this accessor — only the version signal should trigger re-renders
208
+ const result = runUntracked(() => (solidComponent as ComponentFn)(cleanProps as Props))
209
+ const layoutEffects = ctx.pendingLayoutEffects
210
+ const effects = ctx.pendingEffects
211
+ endRender()
212
+
213
+ runLayoutEffects(layoutEffects)
214
+ scheduleEffects(ctx, effects)
215
+
216
+ return result
217
+ }
218
+ }) as unknown as ComponentFn
219
+
220
+ // Forward __loading from lazy components so Pyreon's Suspense can detect them
221
+ if ("__loading" in solidComponent) {
222
+ ;(wrapped as unknown as Record<string, unknown>).__loading = (
223
+ solidComponent as unknown as Record<string, unknown>
224
+ ).__loading
225
+ }
226
+
227
+ _wrapperCache.set(solidComponent, wrapped)
228
+ return wrapped
229
+ }
230
+
231
+ // ─── Child instance lookup ───────────────────────────────────────────────────
232
+
233
+ function createChildInstance(): ChildInstance {
234
+ return {
235
+ ctx: {
236
+ hooks: [],
237
+ scheduleRerender: noop,
238
+ pendingEffects: [],
239
+ pendingLayoutEffects: [],
240
+ unmounted: false,
241
+ unmountCallbacks: [],
242
+ },
243
+ version: signal(0),
244
+ updateScheduled: false,
245
+ }
246
+ }
247
+
248
+ /**
249
+ * During a parent component render, get or create the child instance at the
250
+ * current hook index. Returns undefined when called outside a component render.
251
+ */
252
+ function resolveChildInstance(): ChildInstance | undefined {
253
+ const parentCtx = _currentCtx
254
+ if (!parentCtx) return undefined
255
+
256
+ const idx = _hookIndex++
257
+ if (idx < parentCtx.hooks.length) {
258
+ return parentCtx.hooks[idx] as ChildInstance
259
+ }
260
+ const instance = createChildInstance()
261
+ parentCtx.hooks[idx] = instance
262
+ return instance
263
+ }
264
+
265
+ // ─── JSX functions ───────────────────────────────────────────────────────────
266
+
267
+ export function jsx(
268
+ type: string | ComponentFn | symbol,
269
+ props: Props & { children?: VNodeChild | VNodeChild[] },
270
+ key?: string | number | null,
271
+ ): VNode {
272
+ const { children, ...rest } = props
273
+ const propsWithKey = (key != null ? { ...rest, key } : rest) as Props
274
+
275
+ if (typeof type === "function") {
276
+ if (_nativeComponents.has(type)) {
277
+ const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
278
+ return h(type as ComponentFn, componentProps)
279
+ }
280
+
281
+ const wrapped = wrapCompatComponent(type)
282
+ const componentProps =
283
+ children !== undefined ? { ...propsWithKey, children } : { ...propsWithKey }
284
+
285
+ const childInstance = resolveChildInstance()
286
+ if (childInstance) {
287
+ ;(componentProps as Record<symbol, unknown>)[_CHILD_INSTANCE] = childInstance
288
+ }
289
+
290
+ return h(wrapped, componentProps)
291
+ }
292
+
293
+ // DOM element or symbol (Fragment): children go in vnode.children
294
+ const childArray = children === undefined ? [] : Array.isArray(children) ? children : [children]
295
+
296
+ return h(type, propsWithKey, ...(childArray as VNodeChild[]))
297
+ }
298
+
299
+ export const jsxs = jsx
300
+ export const jsxDEV = jsx