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