@pyreon/query 0.0.1

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.
@@ -0,0 +1,282 @@
1
+ import { onUnmount } from '@pyreon/core'
2
+ import { signal, effect, batch } from '@pyreon/reactivity'
3
+ import type { Signal } from '@pyreon/reactivity'
4
+ import { QueryObserver, InfiniteQueryObserver } from '@tanstack/query-core'
5
+ import type {
6
+ DefaultError,
7
+ InfiniteData,
8
+ InfiniteQueryObserverOptions,
9
+ InfiniteQueryObserverResult,
10
+ QueryKey,
11
+ QueryObserverOptions,
12
+ QueryObserverResult,
13
+ } from '@tanstack/query-core'
14
+ import type { VNodeChild, VNodeChildAtom } from '@pyreon/core'
15
+ import { useQueryClient } from './query-client'
16
+
17
+ // ─── Types ─────────────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Like `UseQueryResult` but `data` is `Signal<TData>` (never undefined).
21
+ * Only use inside a `QuerySuspense` boundary which guarantees the query has
22
+ * succeeded before children are rendered.
23
+ */
24
+ export interface UseSuspenseQueryResult<TData, TError = DefaultError> {
25
+ result: Signal<QueryObserverResult<TData, TError>>
26
+ /** Always TData — never undefined inside a QuerySuspense boundary. */
27
+ data: Signal<TData>
28
+ error: Signal<TError | null>
29
+ status: Signal<'pending' | 'error' | 'success'>
30
+ isPending: Signal<boolean>
31
+ isFetching: Signal<boolean>
32
+ isError: Signal<boolean>
33
+ isSuccess: Signal<boolean>
34
+ refetch: () => Promise<QueryObserverResult<TData, TError>>
35
+ }
36
+
37
+ export interface UseSuspenseInfiniteQueryResult<
38
+ TQueryFnData,
39
+ TError = DefaultError,
40
+ > {
41
+ result: Signal<
42
+ InfiniteQueryObserverResult<InfiniteData<TQueryFnData>, TError>
43
+ >
44
+ /** Always InfiniteData<TQueryFnData> — never undefined inside a QuerySuspense boundary. */
45
+ data: Signal<InfiniteData<TQueryFnData>>
46
+ error: Signal<TError | null>
47
+ status: Signal<'pending' | 'error' | 'success'>
48
+ isFetching: Signal<boolean>
49
+ isFetchingNextPage: Signal<boolean>
50
+ isFetchingPreviousPage: Signal<boolean>
51
+ isError: Signal<boolean>
52
+ isSuccess: Signal<boolean>
53
+ hasNextPage: Signal<boolean>
54
+ hasPreviousPage: Signal<boolean>
55
+ fetchNextPage: () => Promise<
56
+ InfiniteQueryObserverResult<InfiniteData<TQueryFnData>, TError>
57
+ >
58
+ fetchPreviousPage: () => Promise<
59
+ InfiniteQueryObserverResult<InfiniteData<TQueryFnData>, TError>
60
+ >
61
+ refetch: () => Promise<
62
+ QueryObserverResult<InfiniteData<TQueryFnData>, TError>
63
+ >
64
+ }
65
+
66
+ // ─── QuerySuspense ──────────────────────────────────────────────────────────
67
+
68
+ type AnyQueryLike = {
69
+ isPending: Signal<boolean>
70
+ isError: Signal<boolean>
71
+ error: Signal<unknown>
72
+ }
73
+
74
+ export interface QuerySuspenseProps {
75
+ /**
76
+ * A single query result (or array of them) to gate on.
77
+ * Children only render when ALL queries have succeeded.
78
+ */
79
+ query: AnyQueryLike | AnyQueryLike[]
80
+ /** Rendered while any query is pending. */
81
+ fallback?: VNodeChild
82
+ /** Rendered when any query has errored. Defaults to re-throwing to nearest ErrorBoundary. */
83
+ error?: (err: unknown) => VNodeChild
84
+ children: VNodeChild
85
+ }
86
+
87
+ /**
88
+ * Pyreon-native Suspense boundary for queries. Shows `fallback` while any query
89
+ * is pending. On error, renders the `error` fallback or re-throws to the
90
+ * nearest Pyreon `ErrorBoundary`.
91
+ *
92
+ * Pair with `useSuspenseQuery` / `useSuspenseInfiniteQuery` to get non-undefined
93
+ * `data` types inside children.
94
+ *
95
+ * @example
96
+ * const userQuery = useSuspenseQuery(() => ({ queryKey: ['user'], queryFn: fetchUser }))
97
+ *
98
+ * h(QuerySuspense, {
99
+ * query: userQuery,
100
+ * fallback: h(Spinner, null),
101
+ * error: (err) => h('p', null, `Failed: ${err}`),
102
+ * }, () => h(UserProfile, { user: userQuery.data() }))
103
+ */
104
+ export function QuerySuspense(props: QuerySuspenseProps): VNodeChild {
105
+ return (): VNodeChildAtom => {
106
+ const queries = Array.isArray(props.query) ? props.query : [props.query]
107
+
108
+ // Error state — use provided error fallback or re-throw to ErrorBoundary
109
+ for (const q of queries) {
110
+ if (q.isError()) {
111
+ const err = q.error()
112
+ if (props.error) {
113
+ return props.error(err) as VNodeChildAtom
114
+ }
115
+ throw err
116
+ }
117
+ }
118
+
119
+ // Pending state — show fallback
120
+ if (queries.some((q) => q.isPending())) {
121
+ const fb = props.fallback
122
+ return (
123
+ typeof fb === 'function' ? (fb as () => VNodeChildAtom)() : (fb ?? null)
124
+ ) as VNodeChildAtom
125
+ }
126
+
127
+ // All success — render children
128
+ const ch = props.children
129
+ return (
130
+ typeof ch === 'function' ? (ch as () => VNodeChildAtom)() : ch
131
+ ) as VNodeChildAtom
132
+ }
133
+ }
134
+
135
+ // ─── useSuspenseQuery ───────────────────────────────────────────────────────
136
+
137
+ /**
138
+ * Like `useQuery` but `data` is typed as `Signal<TData>` (never undefined).
139
+ * Designed for use inside a `QuerySuspense` boundary, which guarantees
140
+ * children only render after the query succeeds.
141
+ *
142
+ * @example
143
+ * const user = useSuspenseQuery(() => ({ queryKey: ['user', id()], queryFn: fetchUser }))
144
+ *
145
+ * h(QuerySuspense, { query: user, fallback: h(Spinner, null) },
146
+ * () => h(UserCard, { name: user.data().name }),
147
+ * )
148
+ */
149
+ export function useSuspenseQuery<
150
+ TData = unknown,
151
+ TError = DefaultError,
152
+ TKey extends QueryKey = QueryKey,
153
+ >(
154
+ options: () => QueryObserverOptions<TData, TError, TData, TData, TKey>,
155
+ ): UseSuspenseQueryResult<TData, TError> {
156
+ const client = useQueryClient()
157
+ const observer = new QueryObserver<TData, TError, TData, TData, TKey>(
158
+ client,
159
+ options(),
160
+ )
161
+ const initial = observer.getCurrentResult()
162
+
163
+ const resultSig = signal<QueryObserverResult<TData, TError>>(initial)
164
+ const dataSig = signal<TData>(initial.data as TData)
165
+ const errorSig = signal<TError | null>(initial.error ?? null)
166
+ const statusSig = signal<'pending' | 'error' | 'success'>(initial.status)
167
+ const isPending = signal(initial.isPending)
168
+ const isFetching = signal(initial.isFetching)
169
+ const isError = signal(initial.isError)
170
+ const isSuccess = signal(initial.isSuccess)
171
+
172
+ const unsub = observer.subscribe((r) => {
173
+ batch(() => {
174
+ resultSig.set(r)
175
+ if (r.data !== undefined) dataSig.set(r.data as TData)
176
+ errorSig.set(r.error ?? null)
177
+ statusSig.set(r.status)
178
+ isPending.set(r.isPending)
179
+ isFetching.set(r.isFetching)
180
+ isError.set(r.isError)
181
+ isSuccess.set(r.isSuccess)
182
+ })
183
+ })
184
+
185
+ effect(() => {
186
+ observer.setOptions(options())
187
+ })
188
+ onUnmount(() => unsub())
189
+
190
+ return {
191
+ result: resultSig,
192
+ data: dataSig,
193
+ error: errorSig,
194
+ status: statusSig,
195
+ isPending,
196
+ isFetching,
197
+ isError,
198
+ isSuccess,
199
+ refetch: () => observer.refetch(),
200
+ }
201
+ }
202
+
203
+ // ─── useSuspenseInfiniteQuery ───────────────────────────────────────────────
204
+
205
+ /**
206
+ * Like `useInfiniteQuery` but `data` is typed as `Signal<InfiniteData<TData>>`
207
+ * (never undefined). Use inside a `QuerySuspense` boundary.
208
+ */
209
+ export function useSuspenseInfiniteQuery<
210
+ TQueryFnData = unknown,
211
+ TError = DefaultError,
212
+ TQueryKey extends QueryKey = QueryKey,
213
+ TPageParam = unknown,
214
+ >(
215
+ options: () => InfiniteQueryObserverOptions<
216
+ TQueryFnData,
217
+ TError,
218
+ InfiniteData<TQueryFnData>,
219
+ TQueryKey,
220
+ TPageParam
221
+ >,
222
+ ): UseSuspenseInfiniteQueryResult<TQueryFnData, TError> {
223
+ const client = useQueryClient()
224
+ const observer = new InfiniteQueryObserver<
225
+ TQueryFnData,
226
+ TError,
227
+ InfiniteData<TQueryFnData>,
228
+ TQueryKey,
229
+ TPageParam
230
+ >(client, options())
231
+ const initial = observer.getCurrentResult()
232
+
233
+ const resultSig = signal(initial)
234
+ const dataSig = signal(initial.data as InfiniteData<TQueryFnData>)
235
+ const errorSig = signal<TError | null>(initial.error ?? null)
236
+ const statusSig = signal(initial.status)
237
+ const isFetching = signal(initial.isFetching)
238
+ const isFetchingNextPage = signal(initial.isFetchingNextPage)
239
+ const isFetchingPreviousPage = signal(initial.isFetchingPreviousPage)
240
+ const isError = signal(initial.isError)
241
+ const isSuccess = signal(initial.isSuccess)
242
+ const hasNextPage = signal(initial.hasNextPage)
243
+ const hasPreviousPage = signal(initial.hasPreviousPage)
244
+
245
+ const unsub = observer.subscribe((r) => {
246
+ batch(() => {
247
+ resultSig.set(r)
248
+ if (r.data !== undefined) dataSig.set(r.data)
249
+ errorSig.set(r.error ?? null)
250
+ statusSig.set(r.status)
251
+ isFetching.set(r.isFetching)
252
+ isFetchingNextPage.set(r.isFetchingNextPage)
253
+ isFetchingPreviousPage.set(r.isFetchingPreviousPage)
254
+ isError.set(r.isError)
255
+ isSuccess.set(r.isSuccess)
256
+ hasNextPage.set(r.hasNextPage)
257
+ hasPreviousPage.set(r.hasPreviousPage)
258
+ })
259
+ })
260
+
261
+ effect(() => {
262
+ observer.setOptions(options())
263
+ })
264
+ onUnmount(() => unsub())
265
+
266
+ return {
267
+ result: resultSig,
268
+ data: dataSig,
269
+ error: errorSig,
270
+ status: statusSig,
271
+ isFetching,
272
+ isFetchingNextPage,
273
+ isFetchingPreviousPage,
274
+ isError,
275
+ isSuccess,
276
+ hasNextPage,
277
+ hasPreviousPage,
278
+ fetchNextPage: () => observer.fetchNextPage(),
279
+ fetchPreviousPage: () => observer.fetchPreviousPage(),
280
+ refetch: () => observer.refetch(),
281
+ }
282
+ }