@pyreon/solid-compat 0.13.0 → 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/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 function createSignal<T>(initialValue: T): [SignalGetter<T>, SignalSetter<T>] {
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
- ctx.hooks[idx] = pyreonSignal<T>(initialValue)
54
- }
55
- const s = ctx.hooks[idx] as ReturnType<typeof pyreonSignal<T>>
56
- const { scheduleRerender } = ctx
105
+ const { scheduleRerender } = ctx
57
106
 
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)
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.set(v)
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
- scheduleRerender()
131
+
132
+ ctx.hooks[idx] = { signal: null, getter, setter } as unknown as SignalHookEntry<T>
66
133
  }
67
- return [getter, setter]
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
- if (typeof v === 'function') {
75
- s.update(v as (prev: T) => T)
76
- } else {
77
- s.set(v)
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
- pyreonEffect(fn)
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
- ctx.hooks[idx] = pyreonComputed(fn)
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
- const c = pyreonComputed(fn)
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
- export { pyreonCreateContext as createContext, pyreonUseContext as useContext }
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 }