@pyreon/solid-compat 0.13.1 → 0.14.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/analysis/jsx-runtime.js.html +1 -1
- package/lib/index.js +460 -20
- package/lib/index.js.map +1 -1
- package/lib/jsx-runtime.js +5 -0
- package/lib/jsx-runtime.js.map +1 -1
- package/lib/types/index.d.ts +194 -6
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/jsx-runtime.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +741 -25
- package/src/jsx-runtime.ts +9 -0
- package/src/tests/new-apis.test.ts +1539 -0
- package/src/tests/solid-compat.test.ts +366 -0
package/src/index.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* import { createSignal, createEffect } from "solid-js" // aliased by vite plugin
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import type { ComponentFn, LazyComponent, Props, VNodeChild } from '@pyreon/core'
|
|
16
|
+
import type { ComponentFn, Context, LazyComponent, Props, VNodeChild } from '@pyreon/core'
|
|
17
17
|
import {
|
|
18
18
|
ErrorBoundary,
|
|
19
19
|
For,
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
createContext as pyreonCreateContext,
|
|
22
22
|
onMount as pyreonOnMount,
|
|
23
23
|
onUnmount as pyreonOnUnmount,
|
|
24
|
+
provide as pyreonProvide,
|
|
24
25
|
useContext as pyreonUseContext,
|
|
25
26
|
Show,
|
|
26
27
|
Suspense,
|
|
@@ -34,74 +35,162 @@ import {
|
|
|
34
35
|
computed as pyreonComputed,
|
|
35
36
|
createSelector as pyreonCreateSelector,
|
|
36
37
|
effect as pyreonEffect,
|
|
38
|
+
onCleanup as pyreonOnCleanup,
|
|
37
39
|
signal as pyreonSignal,
|
|
38
40
|
runUntracked,
|
|
39
41
|
setCurrentScope,
|
|
40
42
|
} from '@pyreon/reactivity'
|
|
41
43
|
import { getCurrentCtx, getHookIndex } from './jsx-runtime'
|
|
42
44
|
|
|
45
|
+
// ─── Type exports (Solid API surface) ───────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/** Solid-compatible read accessor type */
|
|
48
|
+
export type Accessor<T> = () => T
|
|
49
|
+
|
|
50
|
+
/** Solid-compatible setter type */
|
|
51
|
+
export type Setter<T> = (v: T | ((prev: T) => T)) => void
|
|
52
|
+
|
|
53
|
+
/** Solid-compatible signal tuple type */
|
|
54
|
+
export type Signal<T> = [Accessor<T>, Setter<T>]
|
|
55
|
+
|
|
56
|
+
/** Solid-compatible owner type */
|
|
57
|
+
export type Owner = EffectScope
|
|
58
|
+
|
|
59
|
+
/** Solid-compatible component type */
|
|
60
|
+
export type Component<P = object> = (props: P) => VNodeChild
|
|
61
|
+
|
|
62
|
+
/** Solid-compatible parent component type (includes children) */
|
|
63
|
+
export type ParentComponent<P = object> = (props: P & { children?: VNodeChild }) => VNodeChild
|
|
64
|
+
|
|
65
|
+
/** Solid-compatible flow component type */
|
|
66
|
+
export type FlowComponent<P = object> = Component<P>
|
|
67
|
+
|
|
68
|
+
/** Solid-compatible void component type (no children) */
|
|
69
|
+
export type VoidComponent<P = object> = Component<P>
|
|
70
|
+
|
|
43
71
|
// ─── createSignal ────────────────────────────────────────────────────────────
|
|
44
72
|
|
|
45
73
|
export type SignalGetter<T> = () => T
|
|
46
74
|
export type SignalSetter<T> = (v: T | ((prev: T) => T)) => void
|
|
47
75
|
|
|
48
|
-
export
|
|
76
|
+
export interface CreateSignalOptions<T> {
|
|
77
|
+
equals?: false | ((prev: T, next: T) => boolean)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Hook entry for createSignal — stores signal + stable getter/setter references */
|
|
81
|
+
interface SignalHookEntry<T> {
|
|
82
|
+
signal: ReturnType<typeof pyreonSignal<T>>
|
|
83
|
+
getter: SignalGetter<T>
|
|
84
|
+
setter: SignalSetter<T>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* When `equals: false`, Pyreon's internal `Object.is` dedup must be bypassed.
|
|
89
|
+
* We wrap values in a `{ v: T }` box so every `.set()` creates a new reference
|
|
90
|
+
* that passes the internal `Object.is` check. The getter unwraps transparently.
|
|
91
|
+
*/
|
|
92
|
+
interface Boxed<T> {
|
|
93
|
+
v: T
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createSignal<T>(
|
|
97
|
+
initialValue: T,
|
|
98
|
+
options?: CreateSignalOptions<T>,
|
|
99
|
+
): [SignalGetter<T>, SignalSetter<T>] {
|
|
100
|
+
const neverEqual = options?.equals === false
|
|
49
101
|
const ctx = getCurrentCtx()
|
|
50
102
|
if (ctx) {
|
|
51
103
|
const idx = getHookIndex()
|
|
52
104
|
if (idx >= ctx.hooks.length) {
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
const s = ctx.hooks[idx] as ReturnType<typeof pyreonSignal<T>>
|
|
56
|
-
const { scheduleRerender } = ctx
|
|
105
|
+
const { scheduleRerender } = ctx
|
|
57
106
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
107
|
+
let getter: SignalGetter<T>
|
|
108
|
+
let setter: SignalSetter<T>
|
|
109
|
+
|
|
110
|
+
if (neverEqual) {
|
|
111
|
+
// Boxed mode — bypass Pyreon's Object.is dedup
|
|
112
|
+
const s = pyreonSignal<Boxed<T>>({ v: initialValue })
|
|
113
|
+
getter = () => s().v
|
|
114
|
+
setter = (v) => {
|
|
115
|
+
const prev = s.peek().v
|
|
116
|
+
const next = typeof v === 'function' ? (v as (prev: T) => T)(prev) : v
|
|
117
|
+
s.set({ v: next }) // new object always passes Object.is
|
|
118
|
+
scheduleRerender()
|
|
119
|
+
}
|
|
62
120
|
} else {
|
|
63
|
-
s
|
|
121
|
+
const s = pyreonSignal<T>(initialValue)
|
|
122
|
+
getter = () => s()
|
|
123
|
+
setter = (v) => {
|
|
124
|
+
const prev = s.peek()
|
|
125
|
+
const next = typeof v === 'function' ? (v as (prev: T) => T)(prev) : v
|
|
126
|
+
if (shouldSkipUpdate(prev, next, options)) return
|
|
127
|
+
s.set(next)
|
|
128
|
+
scheduleRerender()
|
|
129
|
+
}
|
|
64
130
|
}
|
|
65
|
-
|
|
131
|
+
|
|
132
|
+
ctx.hooks[idx] = { signal: null, getter, setter } as unknown as SignalHookEntry<T>
|
|
66
133
|
}
|
|
67
|
-
|
|
134
|
+
const entry = ctx.hooks[idx] as SignalHookEntry<T>
|
|
135
|
+
return [entry.getter, entry.setter]
|
|
68
136
|
}
|
|
69
137
|
|
|
70
138
|
// Outside component — plain Pyreon signal
|
|
139
|
+
if (neverEqual) {
|
|
140
|
+
const s = pyreonSignal<Boxed<T>>({ v: initialValue })
|
|
141
|
+
const getter: SignalGetter<T> = () => s().v
|
|
142
|
+
const setter: SignalSetter<T> = (v) => {
|
|
143
|
+
const prev = s.peek().v
|
|
144
|
+
const next = typeof v === 'function' ? (v as (prev: T) => T)(prev) : v
|
|
145
|
+
s.set({ v: next })
|
|
146
|
+
}
|
|
147
|
+
return [getter, setter]
|
|
148
|
+
}
|
|
149
|
+
|
|
71
150
|
const s = pyreonSignal<T>(initialValue)
|
|
72
151
|
const getter: SignalGetter<T> = () => s()
|
|
73
152
|
const setter: SignalSetter<T> = (v) => {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
153
|
+
const prev = s.peek()
|
|
154
|
+
const next = typeof v === 'function' ? (v as (prev: T) => T)(prev) : v
|
|
155
|
+
if (shouldSkipUpdate(prev, next, options)) return
|
|
156
|
+
s.set(next)
|
|
79
157
|
}
|
|
80
158
|
return [getter, setter]
|
|
81
159
|
}
|
|
82
160
|
|
|
161
|
+
/** Solid default: skip update when prev === next. Custom `equals` fn for user-defined comparison. */
|
|
162
|
+
function shouldSkipUpdate<T>(prev: T, next: T, options?: CreateSignalOptions<T>): boolean {
|
|
163
|
+
if (typeof options?.equals === 'function') return options.equals(prev, next)
|
|
164
|
+
return prev === next
|
|
165
|
+
}
|
|
166
|
+
|
|
83
167
|
// ─── createEffect ────────────────────────────────────────────────────────────
|
|
84
168
|
|
|
85
169
|
/**
|
|
86
170
|
* Solid-compatible `createEffect` — creates a reactive side effect.
|
|
87
171
|
*
|
|
172
|
+
* Supports the `(prev) => next` signature with an optional initial value,
|
|
173
|
+
* matching Solid's `createEffect<T>(fn: (prev: T) => T, initialValue: T)`.
|
|
174
|
+
*
|
|
88
175
|
* In component context: hook-indexed, only created on first render. The effect
|
|
89
176
|
* uses Pyreon's native tracking so signal reads are automatically tracked.
|
|
90
177
|
* A re-entrance guard prevents infinite loops from signal writes inside
|
|
91
178
|
* the effect.
|
|
92
179
|
*/
|
|
93
|
-
export function createEffect(fn: () => void): void {
|
|
180
|
+
export function createEffect<T>(fn: ((prev?: T) => T) | (() => void), initialValue?: T): void {
|
|
94
181
|
const ctx = getCurrentCtx()
|
|
95
182
|
if (ctx) {
|
|
96
183
|
const idx = getHookIndex()
|
|
97
184
|
if (idx < ctx.hooks.length) return // Already registered on first render
|
|
98
185
|
|
|
186
|
+
let prevValue: T | undefined = initialValue
|
|
99
187
|
let running = false
|
|
100
188
|
const e = pyreonEffect(() => {
|
|
101
189
|
if (running) return
|
|
102
190
|
running = true
|
|
103
191
|
try {
|
|
104
|
-
fn()
|
|
192
|
+
const result = (fn as (prev?: T) => T)(prevValue)
|
|
193
|
+
if (result !== undefined) prevValue = result
|
|
105
194
|
} finally {
|
|
106
195
|
running = false
|
|
107
196
|
}
|
|
@@ -113,7 +202,11 @@ export function createEffect(fn: () => void): void {
|
|
|
113
202
|
}
|
|
114
203
|
|
|
115
204
|
// Outside component
|
|
116
|
-
|
|
205
|
+
let prevValue: T | undefined = initialValue
|
|
206
|
+
pyreonEffect(() => {
|
|
207
|
+
const result = (fn as (prev?: T) => T)(prevValue)
|
|
208
|
+
if (result !== undefined) prevValue = result
|
|
209
|
+
})
|
|
117
210
|
}
|
|
118
211
|
|
|
119
212
|
// ─── createRenderEffect ──────────────────────────────────────────────────────
|
|
@@ -135,22 +228,36 @@ export { createEffect as createComputed }
|
|
|
135
228
|
/**
|
|
136
229
|
* Solid-compatible `createMemo` — derives a value from reactive sources.
|
|
137
230
|
*
|
|
231
|
+
* Supports the `(prev) => next` signature with an optional initial value,
|
|
232
|
+
* matching Solid's `createMemo<T>(fn: (prev: T) => T, initialValue: T)`.
|
|
233
|
+
*
|
|
138
234
|
* In component context: hook-indexed, only created on first render.
|
|
139
235
|
* Uses Pyreon's native computed for auto-tracking.
|
|
140
236
|
*/
|
|
141
|
-
export function createMemo<T>(fn: () => T): () => T {
|
|
237
|
+
export function createMemo<T>(fn: ((prev?: T) => T) | (() => T), initialValue?: T): () => T {
|
|
142
238
|
const ctx = getCurrentCtx()
|
|
143
239
|
if (ctx) {
|
|
144
240
|
const idx = getHookIndex()
|
|
145
241
|
if (idx >= ctx.hooks.length) {
|
|
146
|
-
|
|
242
|
+
let prevValue: T | undefined = initialValue
|
|
243
|
+
const c = pyreonComputed(() => {
|
|
244
|
+
const result = (fn as (prev?: T) => T)(prevValue)
|
|
245
|
+
prevValue = result
|
|
246
|
+
return result
|
|
247
|
+
})
|
|
248
|
+
ctx.hooks[idx] = c
|
|
147
249
|
}
|
|
148
250
|
const c = ctx.hooks[idx] as ReturnType<typeof pyreonComputed<T>>
|
|
149
251
|
return () => c()
|
|
150
252
|
}
|
|
151
253
|
|
|
152
254
|
// Outside component
|
|
153
|
-
|
|
255
|
+
let prevValue: T | undefined = initialValue
|
|
256
|
+
const c = pyreonComputed(() => {
|
|
257
|
+
const result = (fn as (prev?: T) => T)(prevValue)
|
|
258
|
+
prevValue = result
|
|
259
|
+
return result
|
|
260
|
+
})
|
|
154
261
|
return () => c()
|
|
155
262
|
}
|
|
156
263
|
|
|
@@ -178,6 +285,7 @@ export function on<S extends (() => unknown) | AccessorArray, V>(
|
|
|
178
285
|
S extends () => infer R ? R : S extends readonly (() => infer R)[] ? R[] : never,
|
|
179
286
|
V
|
|
180
287
|
>,
|
|
288
|
+
options?: { defer?: boolean },
|
|
181
289
|
): () => V | undefined {
|
|
182
290
|
type D = S extends () => infer R ? R : S extends readonly (() => infer R)[] ? R[] : never
|
|
183
291
|
|
|
@@ -193,6 +301,11 @@ export function on<S extends (() => unknown) | AccessorArray, V>(
|
|
|
193
301
|
|
|
194
302
|
if (!initialized) {
|
|
195
303
|
initialized = true
|
|
304
|
+
if (options?.defer) {
|
|
305
|
+
// When defer=true, skip the first execution — just capture deps
|
|
306
|
+
prevInput = input
|
|
307
|
+
return prevValue
|
|
308
|
+
}
|
|
196
309
|
prevValue = fn(input, undefined, undefined)
|
|
197
310
|
prevInput = input
|
|
198
311
|
return prevValue
|
|
@@ -406,7 +519,64 @@ export function lazy<P extends Props>(
|
|
|
406
519
|
|
|
407
520
|
// ─── createContext / useContext ───────────────────────────────────────────────
|
|
408
521
|
|
|
409
|
-
|
|
522
|
+
const SOLID_CTX = Symbol.for('pyreon:solid-ctx')
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Solid-compatible context with a Provider component that uses Pyreon's
|
|
526
|
+
* native tree-scoped context stack for proper nesting (inner Provider
|
|
527
|
+
* overrides outer for its subtree).
|
|
528
|
+
*/
|
|
529
|
+
export interface SolidContext<T> {
|
|
530
|
+
readonly [SOLID_CTX_BRAND]: true
|
|
531
|
+
readonly id: symbol
|
|
532
|
+
readonly defaultValue: T | undefined
|
|
533
|
+
Provider: (props: Record<string, unknown>) => unknown
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const SOLID_CTX_BRAND: typeof SOLID_CTX = SOLID_CTX
|
|
537
|
+
|
|
538
|
+
// Tag the Provider so wrapCompatComponent in jsx-runtime skips it
|
|
539
|
+
const NATIVE_COMPONENT = Symbol.for('pyreon:native-compat')
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Solid-compatible `createContext` — creates a context with a `.Provider`
|
|
543
|
+
* component. Uses Pyreon's native context stack for tree-scoped nesting.
|
|
544
|
+
*/
|
|
545
|
+
export function createContext<T>(defaultValue?: T): SolidContext<T> {
|
|
546
|
+
const pyreonCtx = pyreonCreateContext<T>(defaultValue as T)
|
|
547
|
+
|
|
548
|
+
// Provider is a NATIVE Pyreon component — not compat-wrapped.
|
|
549
|
+
// It calls provide() once during setup to push onto the context stack.
|
|
550
|
+
const Provider = (props: Record<string, unknown>) => {
|
|
551
|
+
const { value, children } = props as { value: T; children?: VNodeChild }
|
|
552
|
+
pyreonProvide(pyreonCtx, value)
|
|
553
|
+
return children ?? null
|
|
554
|
+
}
|
|
555
|
+
;(Provider as unknown as Record<symbol, boolean>)[NATIVE_COMPONENT] = true
|
|
556
|
+
|
|
557
|
+
const ctx: SolidContext<T> = {
|
|
558
|
+
[SOLID_CTX_BRAND]: true as const,
|
|
559
|
+
id: pyreonCtx.id,
|
|
560
|
+
defaultValue,
|
|
561
|
+
Provider,
|
|
562
|
+
}
|
|
563
|
+
return ctx
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Solid-compatible `useContext` — reads the nearest provided value for a context.
|
|
568
|
+
* Works with both compat contexts (from this module's `createContext`) and
|
|
569
|
+
* Pyreon native contexts (from `@pyreon/core`).
|
|
570
|
+
*/
|
|
571
|
+
export function useContext<T>(context: SolidContext<T> | Context<T>): T {
|
|
572
|
+
if (SOLID_CTX in context) {
|
|
573
|
+
const solidCtx = context as SolidContext<T>
|
|
574
|
+
// Reconstruct a Pyreon context with the same id to read from the stack
|
|
575
|
+
const pyreonCtx = { id: solidCtx.id, defaultValue: solidCtx.defaultValue as T } as Context<T>
|
|
576
|
+
return pyreonUseContext(pyreonCtx)
|
|
577
|
+
}
|
|
578
|
+
return pyreonUseContext(context as Context<T>)
|
|
579
|
+
}
|
|
410
580
|
|
|
411
581
|
// ─── getOwner / runWithOwner ─────────────────────────────────────────────────
|
|
412
582
|
|
|
@@ -424,6 +594,552 @@ export function runWithOwner<T>(owner: EffectScope | null, fn: () => T): T {
|
|
|
424
594
|
}
|
|
425
595
|
}
|
|
426
596
|
|
|
597
|
+
// ─── createResource ─────────────────────────────────────────────────────────
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Solid-compatible resource — async data fetching with reactive source tracking.
|
|
601
|
+
* Returns `[resource, { mutate, refetch }]` where `resource()` is the data accessor
|
|
602
|
+
* with `.loading`, `.error`, and `.latest` reactive properties.
|
|
603
|
+
*
|
|
604
|
+
* When the resource is loading and read inside a Suspense boundary, the accessor
|
|
605
|
+
* throws the fetch promise so Suspense can catch it. It also integrates with
|
|
606
|
+
* Pyreon's `__loading` protocol so `<Suspense>` can detect it.
|
|
607
|
+
*/
|
|
608
|
+
export interface Resource<T> {
|
|
609
|
+
(): T | undefined
|
|
610
|
+
loading: boolean
|
|
611
|
+
error: Error | undefined
|
|
612
|
+
latest: T | undefined
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export type ResourceReturn<T> = [
|
|
616
|
+
Resource<T>,
|
|
617
|
+
{ mutate: (v: T | ((prev: T | undefined) => T)) => void; refetch: () => void },
|
|
618
|
+
]
|
|
619
|
+
|
|
620
|
+
export function createResource<T>(
|
|
621
|
+
fetcher: (info: { value: T | undefined }) => Promise<T> | T,
|
|
622
|
+
options?: { initialValue?: T },
|
|
623
|
+
): ResourceReturn<T>
|
|
624
|
+
export function createResource<T, S = true>(
|
|
625
|
+
source: (() => S) | true,
|
|
626
|
+
fetcher: (source: S, info: { value: T | undefined }) => Promise<T> | T,
|
|
627
|
+
options?: { initialValue?: T },
|
|
628
|
+
): ResourceReturn<T>
|
|
629
|
+
export function createResource<T, S = true>(
|
|
630
|
+
sourceOrFetcher:
|
|
631
|
+
| (() => S)
|
|
632
|
+
| true
|
|
633
|
+
| ((info: { value: T | undefined }) => Promise<T> | T),
|
|
634
|
+
maybeFetcherOrOptions?:
|
|
635
|
+
| ((source: S, info: { value: T | undefined }) => Promise<T> | T)
|
|
636
|
+
| { initialValue?: T },
|
|
637
|
+
maybeOptions?: { initialValue?: T },
|
|
638
|
+
): ResourceReturn<T> {
|
|
639
|
+
const hasSource = typeof maybeFetcherOrOptions === 'function'
|
|
640
|
+
const source = hasSource ? (sourceOrFetcher as (() => S) | true) : (() => true as S)
|
|
641
|
+
const fetcher = (
|
|
642
|
+
hasSource ? maybeFetcherOrOptions : sourceOrFetcher
|
|
643
|
+
) as (source: S, info: { value: T | undefined }) => Promise<T> | T
|
|
644
|
+
const opts = hasSource
|
|
645
|
+
? maybeOptions
|
|
646
|
+
: (typeof maybeFetcherOrOptions === 'object' ? maybeFetcherOrOptions as { initialValue?: T } : undefined)
|
|
647
|
+
const initialValue = opts?.initialValue
|
|
648
|
+
|
|
649
|
+
const [data, setData] = createSignal<T | undefined>(initialValue)
|
|
650
|
+
const [loading, setLoading] = createSignal(false)
|
|
651
|
+
const [error, setError] = createSignal<Error | undefined>(undefined)
|
|
652
|
+
|
|
653
|
+
let latestValue: T | undefined = initialValue
|
|
654
|
+
let fetchPromise: Promise<T> | null = null
|
|
655
|
+
|
|
656
|
+
const doFetch = () => {
|
|
657
|
+
const src = typeof source === 'function' ? (source as () => S)() : source
|
|
658
|
+
if (src === false || src === null || src === undefined) return
|
|
659
|
+
setLoading(true)
|
|
660
|
+
setError(undefined)
|
|
661
|
+
try {
|
|
662
|
+
const result = fetcher(src as S, { value: latestValue })
|
|
663
|
+
if (result instanceof Promise) {
|
|
664
|
+
fetchPromise = result
|
|
665
|
+
result.then(
|
|
666
|
+
(val) => {
|
|
667
|
+
latestValue = val
|
|
668
|
+
fetchPromise = null
|
|
669
|
+
setData(() => val)
|
|
670
|
+
setLoading(false)
|
|
671
|
+
},
|
|
672
|
+
(err) => {
|
|
673
|
+
fetchPromise = null
|
|
674
|
+
setError(() => (err instanceof Error ? err : new Error(String(err))))
|
|
675
|
+
setLoading(false)
|
|
676
|
+
},
|
|
677
|
+
)
|
|
678
|
+
} else {
|
|
679
|
+
latestValue = result
|
|
680
|
+
fetchPromise = null
|
|
681
|
+
setData(() => result)
|
|
682
|
+
setLoading(false)
|
|
683
|
+
}
|
|
684
|
+
} catch (err) {
|
|
685
|
+
setError(() => (err instanceof Error ? err : new Error(String(err))))
|
|
686
|
+
setLoading(false)
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Auto-fetch on source change
|
|
691
|
+
if (hasSource && typeof source === 'function') {
|
|
692
|
+
createEffect(() => {
|
|
693
|
+
;(source as () => S)() // track source
|
|
694
|
+
doFetch()
|
|
695
|
+
})
|
|
696
|
+
} else {
|
|
697
|
+
doFetch() // fetch immediately
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Build the resource accessor — throws for Suspense when loading
|
|
701
|
+
const resource = (() => {
|
|
702
|
+
const err = error()
|
|
703
|
+
if (err) throw err // ErrorBoundary catches this
|
|
704
|
+
const current = data()
|
|
705
|
+
if (loading() && fetchPromise && current === undefined) throw fetchPromise // Suspense catches this
|
|
706
|
+
return current
|
|
707
|
+
}) as Resource<T>
|
|
708
|
+
|
|
709
|
+
Object.defineProperty(resource, 'loading', {
|
|
710
|
+
get: () => loading(),
|
|
711
|
+
enumerable: true,
|
|
712
|
+
})
|
|
713
|
+
Object.defineProperty(resource, 'error', {
|
|
714
|
+
get: () => error(),
|
|
715
|
+
enumerable: true,
|
|
716
|
+
})
|
|
717
|
+
Object.defineProperty(resource, 'latest', {
|
|
718
|
+
get: () => latestValue,
|
|
719
|
+
enumerable: true,
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
const mutate = (v: T | ((prev: T | undefined) => T)) => {
|
|
723
|
+
if (typeof v === 'function') {
|
|
724
|
+
const next = (v as (prev: T | undefined) => T)(data())
|
|
725
|
+
latestValue = next
|
|
726
|
+
setData(() => next)
|
|
727
|
+
} else {
|
|
728
|
+
latestValue = v
|
|
729
|
+
setData(() => v)
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const refetch = () => doFetch()
|
|
734
|
+
|
|
735
|
+
return [resource, { mutate, refetch }]
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ─── Deep clone (structuredClone replacement) ──────────────────────────────
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Deep clones plain objects and arrays. Functions, DOM nodes, class instances,
|
|
742
|
+
* and other non-plain values are kept by reference — `structuredClone` would
|
|
743
|
+
* throw on them.
|
|
744
|
+
*/
|
|
745
|
+
function deepClone<T>(obj: T): T {
|
|
746
|
+
if (obj === null || typeof obj !== 'object') return obj
|
|
747
|
+
if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as unknown as T
|
|
748
|
+
// Don't clone DOM nodes, class instances, etc. — copy by reference
|
|
749
|
+
if (obj.constructor !== Object && obj.constructor !== Array) return obj
|
|
750
|
+
const result = {} as Record<string, unknown>
|
|
751
|
+
for (const key of Object.keys(obj as object)) {
|
|
752
|
+
result[key] = deepClone((obj as Record<string, unknown>)[key])
|
|
753
|
+
}
|
|
754
|
+
return result as T
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// ─── createStore / produce ──────────────────────────────────────────────────
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Solid-compatible `createStore` — creates a deeply reactive proxy-based store.
|
|
761
|
+
*
|
|
762
|
+
* Returns `[store, setStore]` where:
|
|
763
|
+
* - `store` is a recursive proxy that lazily creates per-path signals for fine-grained tracking
|
|
764
|
+
* - `setStore` supports Solid's path-based setter API:
|
|
765
|
+
* - `setStore('key', value)` — set a top-level key
|
|
766
|
+
* - `setStore('nested', 'key', value)` — set a nested path
|
|
767
|
+
* - `setStore('key', prev => next)` — functional update at a path
|
|
768
|
+
* - `setStore('todos', 0, 'done', true)` — numeric index into arrays
|
|
769
|
+
* - `setStore('todos', t => t.done, 'text', 'x')` — filter predicate on arrays
|
|
770
|
+
* - `setStore(fn)` — mutator function (receives a draft clone)
|
|
771
|
+
*/
|
|
772
|
+
export type SetStoreFunction<_T> = {
|
|
773
|
+
(...args: unknown[]): void
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
export function createStore<T extends object>(
|
|
777
|
+
initialValue: T,
|
|
778
|
+
): [T, SetStoreFunction<T>] {
|
|
779
|
+
const signals = new Map<string, ReturnType<typeof pyreonSignal>>()
|
|
780
|
+
let raw: T = deepClone(initialValue)
|
|
781
|
+
|
|
782
|
+
function getByPath(obj: unknown, path: string): unknown {
|
|
783
|
+
if (!path) return obj
|
|
784
|
+
return path.split('.').reduce((o, k) => (o as Record<string, unknown>)?.[k], obj)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function getSignal(path: string): ReturnType<typeof pyreonSignal> {
|
|
788
|
+
let sig = signals.get(path)
|
|
789
|
+
if (!sig) {
|
|
790
|
+
const value = getByPath(raw, path)
|
|
791
|
+
sig = pyreonSignal(value)
|
|
792
|
+
signals.set(path, sig)
|
|
793
|
+
}
|
|
794
|
+
return sig
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function resolveValue(basePath: string): unknown {
|
|
798
|
+
return basePath ? getByPath(raw, basePath) : raw
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function makeProxy(basePath: string): unknown {
|
|
802
|
+
// Use a dummy target — all reads go through `raw` via `resolveValue`
|
|
803
|
+
return new Proxy({} as object, {
|
|
804
|
+
get(_target, prop) {
|
|
805
|
+
if (typeof prop === 'symbol') return (resolveValue(basePath) as Record<symbol, unknown>)?.[prop]
|
|
806
|
+
const path = basePath ? `${basePath}.${String(prop)}` : String(prop)
|
|
807
|
+
const sig = getSignal(path)
|
|
808
|
+
sig() // track read
|
|
809
|
+
const value = getByPath(raw, path)
|
|
810
|
+
if (value !== null && typeof value === 'object') {
|
|
811
|
+
return makeProxy(path)
|
|
812
|
+
}
|
|
813
|
+
return value
|
|
814
|
+
},
|
|
815
|
+
has(_target, prop) {
|
|
816
|
+
const current = resolveValue(basePath)
|
|
817
|
+
return current !== null && typeof current === 'object' && prop in (current as object)
|
|
818
|
+
},
|
|
819
|
+
ownKeys(_target) {
|
|
820
|
+
// Track the base path so effects re-run when keys change
|
|
821
|
+
if (basePath) getSignal(basePath)()
|
|
822
|
+
else getSignal('__keys__')()
|
|
823
|
+
const current = resolveValue(basePath)
|
|
824
|
+
return current !== null && typeof current === 'object'
|
|
825
|
+
? Reflect.ownKeys(current as object)
|
|
826
|
+
: []
|
|
827
|
+
},
|
|
828
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
829
|
+
const current = resolveValue(basePath)
|
|
830
|
+
if (current !== null && typeof current === 'object') {
|
|
831
|
+
return Object.getOwnPropertyDescriptor(current, prop)
|
|
832
|
+
}
|
|
833
|
+
return undefined
|
|
834
|
+
},
|
|
835
|
+
set() {
|
|
836
|
+
// oxlint-disable-next-line no-console
|
|
837
|
+
console.warn('[Pyreon] Direct mutation on store is not supported. Use the setter function.')
|
|
838
|
+
return true
|
|
839
|
+
},
|
|
840
|
+
})
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const proxy = makeProxy('') as T
|
|
844
|
+
|
|
845
|
+
function updateRaw(newRaw: T) {
|
|
846
|
+
const oldRaw = raw
|
|
847
|
+
raw = newRaw
|
|
848
|
+
|
|
849
|
+
// Update all tracked signals whose values changed
|
|
850
|
+
for (const [path, sig] of signals) {
|
|
851
|
+
const oldVal = getByPath(oldRaw, path)
|
|
852
|
+
const newVal = getByPath(newRaw, path)
|
|
853
|
+
if (!Object.is(oldVal, newVal)) {
|
|
854
|
+
sig.set(newVal)
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Applies a value at a path, supporting numeric indices (array access)
|
|
861
|
+
* and filter predicates (functions that select matching array items).
|
|
862
|
+
*/
|
|
863
|
+
function applyAtPath(obj: unknown, path: unknown[], value: unknown): void {
|
|
864
|
+
if (path.length === 0) {
|
|
865
|
+
// Apply value to obj itself (top-level update)
|
|
866
|
+
if (typeof value === 'function') {
|
|
867
|
+
const result = (value as (prev: unknown) => unknown)(obj)
|
|
868
|
+
Object.assign(obj as object, result)
|
|
869
|
+
} else {
|
|
870
|
+
Object.assign(obj as object, value)
|
|
871
|
+
}
|
|
872
|
+
return
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const [head, ...rest] = path
|
|
876
|
+
|
|
877
|
+
if (typeof head === 'function') {
|
|
878
|
+
// Filter predicate: apply to all matching items in an array
|
|
879
|
+
if (Array.isArray(obj)) {
|
|
880
|
+
for (let i = 0; i < obj.length; i++) {
|
|
881
|
+
if ((head as (item: unknown, index: number) => boolean)(obj[i], i)) {
|
|
882
|
+
if (rest.length === 0) {
|
|
883
|
+
obj[i] = typeof value === 'function' ? (value as (prev: unknown) => unknown)(obj[i]) : value
|
|
884
|
+
} else {
|
|
885
|
+
applyAtPath(obj[i], rest, value)
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const key = head as string | number
|
|
894
|
+
if (rest.length === 0) {
|
|
895
|
+
// Last path segment — set the value
|
|
896
|
+
;(obj as Record<string | number, unknown>)[key] =
|
|
897
|
+
typeof value === 'function'
|
|
898
|
+
? (value as (prev: unknown) => unknown)((obj as Record<string | number, unknown>)[key])
|
|
899
|
+
: value
|
|
900
|
+
} else {
|
|
901
|
+
// Recurse into nested object
|
|
902
|
+
applyAtPath((obj as Record<string | number, unknown>)[key], rest, value)
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const setStore: SetStoreFunction<T> = (...args: unknown[]) => {
|
|
907
|
+
if (args.length === 1 && typeof args[0] === 'function') {
|
|
908
|
+
// Function form: setStore(state => { state.x = 1 })
|
|
909
|
+
const draft = deepClone(raw)
|
|
910
|
+
;(args[0] as (state: T) => void)(draft)
|
|
911
|
+
updateRaw(draft)
|
|
912
|
+
} else if (args.length >= 2) {
|
|
913
|
+
// Path form with support for numeric indices and filter predicates
|
|
914
|
+
const value = args[args.length - 1]
|
|
915
|
+
const pathArgs = args.slice(0, -1)
|
|
916
|
+
const draft = deepClone(raw)
|
|
917
|
+
applyAtPath(draft, pathArgs, value)
|
|
918
|
+
updateRaw(draft)
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return [proxy, setStore]
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Solid-compatible `reconcile` — replaces the entire store state with the given value.
|
|
927
|
+
* Used with setStore: `setStore(reconcile(newData))`
|
|
928
|
+
*/
|
|
929
|
+
export function reconcile<T extends object>(value: T): (state: T) => T {
|
|
930
|
+
return () => value
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Solid-compatible `unwrap` — returns a deep clone of the store's raw data,
|
|
935
|
+
* stripping the reactive proxy.
|
|
936
|
+
*/
|
|
937
|
+
export function unwrap<T>(value: T): T {
|
|
938
|
+
return deepClone(value)
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Solid-compatible `produce` — creates an Immer-like updater function for stores.
|
|
943
|
+
* Returns a function that clones the state, applies mutations, and returns the result.
|
|
944
|
+
*/
|
|
945
|
+
export function produce<T extends object>(fn: (state: T) => void): (state: T) => T {
|
|
946
|
+
return (state: T) => {
|
|
947
|
+
const draft = deepClone(state)
|
|
948
|
+
fn(draft)
|
|
949
|
+
return draft
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ─── startTransition / useTransition ────────────────────────────────────────
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Solid-compatible `startTransition` — runs a function as a transition.
|
|
957
|
+
* In Pyreon, this is a no-op wrapper that calls the function synchronously.
|
|
958
|
+
*/
|
|
959
|
+
export function startTransition(fn: () => void): void {
|
|
960
|
+
fn()
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Solid-compatible `useTransition` — returns `[isPending, startTransition]`.
|
|
965
|
+
* In Pyreon, transitions are not deferred — isPending is always false.
|
|
966
|
+
*/
|
|
967
|
+
export function useTransition(): [() => boolean, (fn: () => void) => void] {
|
|
968
|
+
return [() => false, (fn) => fn()]
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// ─── observable / from (interop) ────────────────────────────────────────────
|
|
972
|
+
|
|
973
|
+
interface Observer<T> {
|
|
974
|
+
next: (v: T) => void
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
interface Subscription {
|
|
978
|
+
unsubscribe: () => void
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
interface Observable<T> {
|
|
982
|
+
subscribe: (observer: Observer<T>) => Subscription
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Solid-compatible `observable` — converts a signal accessor to an observable.
|
|
987
|
+
* Returns an object with a `subscribe` method that tracks signal changes.
|
|
988
|
+
*/
|
|
989
|
+
export function observable<T>(input: () => T): Observable<T> {
|
|
990
|
+
return {
|
|
991
|
+
subscribe(observer: Observer<T>) {
|
|
992
|
+
const e = pyreonEffect(() => {
|
|
993
|
+
observer.next(input())
|
|
994
|
+
})
|
|
995
|
+
return { unsubscribe: () => e.dispose() }
|
|
996
|
+
},
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Solid-compatible `from` — converts an observable or producer into a signal accessor.
|
|
1002
|
+
* Accepts either a producer function `(setter) => cleanup` or an observable with `.subscribe()`.
|
|
1003
|
+
*/
|
|
1004
|
+
export function from<T>(
|
|
1005
|
+
producer:
|
|
1006
|
+
| ((setter: (v: T) => void) => () => void)
|
|
1007
|
+
| Observable<T>,
|
|
1008
|
+
): () => T | undefined {
|
|
1009
|
+
const [value, setValue] = createSignal<T | undefined>(undefined)
|
|
1010
|
+
|
|
1011
|
+
if (typeof producer === 'function') {
|
|
1012
|
+
const cleanup = producer((v) => setValue(() => v))
|
|
1013
|
+
pyreonOnCleanup(cleanup)
|
|
1014
|
+
} else {
|
|
1015
|
+
const sub = producer.subscribe({ next: (v) => setValue(() => v) })
|
|
1016
|
+
pyreonOnCleanup(() => sub.unsubscribe())
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
return value
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// ─── mapArray / indexArray ───────────────────────────────────────────────────
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Solid-compatible `mapArray` — maps a reactive list by item identity.
|
|
1026
|
+
* Each item is a static value, while the index is a reactive accessor.
|
|
1027
|
+
*/
|
|
1028
|
+
export function mapArray<T, U>(
|
|
1029
|
+
list: () => readonly T[],
|
|
1030
|
+
mapFn: (item: T, index: () => number) => U,
|
|
1031
|
+
): () => U[] {
|
|
1032
|
+
return createMemo(() => {
|
|
1033
|
+
const items = list()
|
|
1034
|
+
return items.map((item, i) => mapFn(item, () => i))
|
|
1035
|
+
})
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Solid-compatible `indexArray` — maps a reactive list by index position.
|
|
1040
|
+
* Each item is a reactive accessor, while the index is a static number.
|
|
1041
|
+
*/
|
|
1042
|
+
export function indexArray<T, U>(
|
|
1043
|
+
list: () => readonly T[],
|
|
1044
|
+
mapFn: (item: () => T, index: number) => U,
|
|
1045
|
+
): () => U[] {
|
|
1046
|
+
return createMemo(() => {
|
|
1047
|
+
const items = list()
|
|
1048
|
+
return items.map((item, i) => mapFn(() => item, i))
|
|
1049
|
+
})
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// ─── Index ──────────────────────────────────────────────────────────────────
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Solid-compatible `Index` — like `For` but keyed by index.
|
|
1056
|
+
* Items are reactive accessors, indices are static numbers.
|
|
1057
|
+
*
|
|
1058
|
+
* In Solid, `<Index>` keeps DOM nodes stable per index position.
|
|
1059
|
+
* Here we use a computed that maps items to `(item: () => T, index: number)`.
|
|
1060
|
+
*/
|
|
1061
|
+
export function Index<T>(props: {
|
|
1062
|
+
each: readonly T[] | (() => readonly T[])
|
|
1063
|
+
children: (item: () => T, index: number) => VNodeChild
|
|
1064
|
+
}): VNodeChild {
|
|
1065
|
+
const list = typeof props.each === 'function'
|
|
1066
|
+
? (props.each as () => readonly T[])
|
|
1067
|
+
: () => props.each as readonly T[]
|
|
1068
|
+
const mapped = createMemo(() => {
|
|
1069
|
+
const items = list()
|
|
1070
|
+
return items.map((item, i) => props.children(() => item, i))
|
|
1071
|
+
})
|
|
1072
|
+
return (() => mapped()) as unknown as VNodeChild
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// ─── createUniqueId ─────────────────────────────────────────────────────────
|
|
1076
|
+
|
|
1077
|
+
let _uniqueIdCounter = 0
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Solid-compatible `createUniqueId` — returns a unique string identifier.
|
|
1081
|
+
*/
|
|
1082
|
+
export function createUniqueId(): string {
|
|
1083
|
+
return `solid-${(_uniqueIdCounter++).toString(36)}`
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// ─── DEV ────────────────────────────────────────────────────────────────────
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Solid-compatible `DEV` — an object in dev mode, `undefined` in production.
|
|
1090
|
+
* Used for conditional dev-only code: `if (DEV) { ... }`
|
|
1091
|
+
*/
|
|
1092
|
+
export const DEV =
|
|
1093
|
+
(import.meta as { env?: { DEV?: boolean } }).env?.DEV === true ? {} : undefined
|
|
1094
|
+
|
|
1095
|
+
// ─── catchError ─────────────────────────────────────────────────────────────
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Solid-compatible `catchError` — wraps a function and catches synchronous errors.
|
|
1099
|
+
*/
|
|
1100
|
+
export function catchError<T>(
|
|
1101
|
+
tryFn: () => T,
|
|
1102
|
+
onError: (err: Error) => void,
|
|
1103
|
+
): T | undefined {
|
|
1104
|
+
try {
|
|
1105
|
+
return tryFn()
|
|
1106
|
+
} catch (e) {
|
|
1107
|
+
onError(e instanceof Error ? e : new Error(String(e)))
|
|
1108
|
+
return undefined
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// ─── createDeferred ─────────────────────────────────────────────────────────
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Solid-compatible `createDeferred` — creates a memo that updates on next idle frame.
|
|
1116
|
+
* In Pyreon there is no concurrent scheduling, so this behaves the same as `createMemo`.
|
|
1117
|
+
*/
|
|
1118
|
+
export function createDeferred<T>(fn: () => T): () => T {
|
|
1119
|
+
return createMemo(fn)
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// ─── createReaction ─────────────────────────────────────────────────────────
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Solid-compatible `createReaction` — manual tracking primitive.
|
|
1126
|
+
* Returns a function that accepts a tracking function. When any tracked
|
|
1127
|
+
* dependency changes, `onInvalidate` fires (but only after the first run).
|
|
1128
|
+
*/
|
|
1129
|
+
export function createReaction(onInvalidate: () => void): (tracking: () => void) => void {
|
|
1130
|
+
return (trackingFn: () => void) => {
|
|
1131
|
+
let first = true
|
|
1132
|
+
pyreonEffect(() => {
|
|
1133
|
+
trackingFn() // track dependencies
|
|
1134
|
+
if (first) {
|
|
1135
|
+
first = false
|
|
1136
|
+
return
|
|
1137
|
+
}
|
|
1138
|
+
onInvalidate()
|
|
1139
|
+
})
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
427
1143
|
// ─── Re-exports from @pyreon/core ──────────────────────────────────────────────
|
|
428
1144
|
|
|
429
1145
|
export { ErrorBoundary, For, Match, Show, Suspense, Switch }
|