@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.
- package/LICENSE +21 -0
- package/README.md +236 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +489 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +497 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +298 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +55 -0
- package/src/index.ts +69 -0
- package/src/query-client.ts +59 -0
- package/src/tests/query.test.ts +1768 -0
- package/src/use-infinite-query.ts +138 -0
- package/src/use-is-fetching.ts +44 -0
- package/src/use-mutation.ts +117 -0
- package/src/use-queries.ts +61 -0
- package/src/use-query-error-reset-boundary.ts +95 -0
- package/src/use-query.ts +106 -0
- package/src/use-suspense-query.ts +282 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { onUnmount } from '@pyreon/core'
|
|
2
|
+
import { signal, effect, batch } from '@pyreon/reactivity'
|
|
3
|
+
import type { Signal } from '@pyreon/reactivity'
|
|
4
|
+
import { InfiniteQueryObserver } from '@tanstack/query-core'
|
|
5
|
+
import type {
|
|
6
|
+
DefaultError,
|
|
7
|
+
InfiniteData,
|
|
8
|
+
InfiniteQueryObserverOptions,
|
|
9
|
+
InfiniteQueryObserverResult,
|
|
10
|
+
QueryKey,
|
|
11
|
+
QueryObserverResult,
|
|
12
|
+
} from '@tanstack/query-core'
|
|
13
|
+
import { useQueryClient } from './query-client'
|
|
14
|
+
|
|
15
|
+
export interface UseInfiniteQueryResult<TQueryFnData, TError = DefaultError> {
|
|
16
|
+
/** Raw signal — full observer result. */
|
|
17
|
+
result: Signal<
|
|
18
|
+
InfiniteQueryObserverResult<InfiniteData<TQueryFnData>, TError>
|
|
19
|
+
>
|
|
20
|
+
data: Signal<InfiniteData<TQueryFnData> | undefined>
|
|
21
|
+
error: Signal<TError | null>
|
|
22
|
+
status: Signal<'pending' | 'error' | 'success'>
|
|
23
|
+
isPending: Signal<boolean>
|
|
24
|
+
isLoading: Signal<boolean>
|
|
25
|
+
isFetching: Signal<boolean>
|
|
26
|
+
isFetchingNextPage: Signal<boolean>
|
|
27
|
+
isFetchingPreviousPage: Signal<boolean>
|
|
28
|
+
isError: Signal<boolean>
|
|
29
|
+
isSuccess: Signal<boolean>
|
|
30
|
+
hasNextPage: Signal<boolean>
|
|
31
|
+
hasPreviousPage: Signal<boolean>
|
|
32
|
+
fetchNextPage: () => Promise<
|
|
33
|
+
InfiniteQueryObserverResult<InfiniteData<TQueryFnData>, TError>
|
|
34
|
+
>
|
|
35
|
+
fetchPreviousPage: () => Promise<
|
|
36
|
+
InfiniteQueryObserverResult<InfiniteData<TQueryFnData>, TError>
|
|
37
|
+
>
|
|
38
|
+
refetch: () => Promise<
|
|
39
|
+
QueryObserverResult<InfiniteData<TQueryFnData>, TError>
|
|
40
|
+
>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Subscribe to a paginated / infinite-scroll query.
|
|
45
|
+
* Returns fine-grained reactive signals plus `fetchNextPage`, `fetchPreviousPage`,
|
|
46
|
+
* `hasNextPage` and `hasPreviousPage`.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* const query = useInfiniteQuery(() => ({
|
|
50
|
+
* queryKey: ['posts'],
|
|
51
|
+
* queryFn: ({ pageParam }) => fetchPosts(pageParam as number),
|
|
52
|
+
* initialPageParam: 0,
|
|
53
|
+
* getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
54
|
+
* }))
|
|
55
|
+
* // query.data()?.pages — array of pages
|
|
56
|
+
* // h('button', { onClick: () => query.fetchNextPage() }, 'Load more')
|
|
57
|
+
*/
|
|
58
|
+
export function useInfiniteQuery<
|
|
59
|
+
TQueryFnData = unknown,
|
|
60
|
+
TError = DefaultError,
|
|
61
|
+
TQueryKey extends QueryKey = QueryKey,
|
|
62
|
+
TPageParam = unknown,
|
|
63
|
+
>(
|
|
64
|
+
options: () => InfiniteQueryObserverOptions<
|
|
65
|
+
TQueryFnData,
|
|
66
|
+
TError,
|
|
67
|
+
InfiniteData<TQueryFnData>,
|
|
68
|
+
TQueryKey,
|
|
69
|
+
TPageParam
|
|
70
|
+
>,
|
|
71
|
+
): UseInfiniteQueryResult<TQueryFnData, TError> {
|
|
72
|
+
const client = useQueryClient()
|
|
73
|
+
const observer = new InfiniteQueryObserver<
|
|
74
|
+
TQueryFnData,
|
|
75
|
+
TError,
|
|
76
|
+
InfiniteData<TQueryFnData>,
|
|
77
|
+
TQueryKey,
|
|
78
|
+
TPageParam
|
|
79
|
+
>(client, options())
|
|
80
|
+
const initial = observer.getCurrentResult()
|
|
81
|
+
|
|
82
|
+
const resultSig = signal(initial)
|
|
83
|
+
const dataSig = signal<InfiniteData<TQueryFnData> | undefined>(initial.data)
|
|
84
|
+
const errorSig = signal<TError | null>(initial.error ?? null)
|
|
85
|
+
const statusSig = signal(initial.status)
|
|
86
|
+
const isPending = signal(initial.isPending)
|
|
87
|
+
const isLoading = signal(initial.isLoading)
|
|
88
|
+
const isFetching = signal(initial.isFetching)
|
|
89
|
+
const isFetchingNextPage = signal(initial.isFetchingNextPage)
|
|
90
|
+
const isFetchingPreviousPage = signal(initial.isFetchingPreviousPage)
|
|
91
|
+
const isError = signal(initial.isError)
|
|
92
|
+
const isSuccess = signal(initial.isSuccess)
|
|
93
|
+
const hasNextPage = signal(initial.hasNextPage)
|
|
94
|
+
const hasPreviousPage = signal(initial.hasPreviousPage)
|
|
95
|
+
|
|
96
|
+
const unsub = observer.subscribe((r) => {
|
|
97
|
+
batch(() => {
|
|
98
|
+
resultSig.set(r)
|
|
99
|
+
dataSig.set(r.data)
|
|
100
|
+
errorSig.set(r.error ?? null)
|
|
101
|
+
statusSig.set(r.status)
|
|
102
|
+
isPending.set(r.isPending)
|
|
103
|
+
isLoading.set(r.isLoading)
|
|
104
|
+
isFetching.set(r.isFetching)
|
|
105
|
+
isFetchingNextPage.set(r.isFetchingNextPage)
|
|
106
|
+
isFetchingPreviousPage.set(r.isFetchingPreviousPage)
|
|
107
|
+
isError.set(r.isError)
|
|
108
|
+
isSuccess.set(r.isSuccess)
|
|
109
|
+
hasNextPage.set(r.hasNextPage)
|
|
110
|
+
hasPreviousPage.set(r.hasPreviousPage)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
effect(() => {
|
|
115
|
+
observer.setOptions(options())
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
onUnmount(() => unsub())
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
result: resultSig,
|
|
122
|
+
data: dataSig,
|
|
123
|
+
error: errorSig,
|
|
124
|
+
status: statusSig,
|
|
125
|
+
isPending,
|
|
126
|
+
isLoading,
|
|
127
|
+
isFetching,
|
|
128
|
+
isFetchingNextPage,
|
|
129
|
+
isFetchingPreviousPage,
|
|
130
|
+
isError,
|
|
131
|
+
isSuccess,
|
|
132
|
+
hasNextPage,
|
|
133
|
+
hasPreviousPage,
|
|
134
|
+
fetchNextPage: () => observer.fetchNextPage(),
|
|
135
|
+
fetchPreviousPage: () => observer.fetchPreviousPage(),
|
|
136
|
+
refetch: () => observer.refetch(),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { onUnmount } from '@pyreon/core'
|
|
2
|
+
import { signal } from '@pyreon/reactivity'
|
|
3
|
+
import type { Signal } from '@pyreon/reactivity'
|
|
4
|
+
import type { MutationFilters, QueryFilters } from '@tanstack/query-core'
|
|
5
|
+
import { useQueryClient } from './query-client'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns a signal that tracks how many queries are currently in-flight.
|
|
9
|
+
* Useful for global loading indicators.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const fetching = useIsFetching()
|
|
13
|
+
* // h('span', null, () => fetching() > 0 ? 'Loading…' : '')
|
|
14
|
+
*/
|
|
15
|
+
export function useIsFetching(filters?: QueryFilters): Signal<number> {
|
|
16
|
+
const client = useQueryClient()
|
|
17
|
+
const count = signal(client.isFetching(filters))
|
|
18
|
+
|
|
19
|
+
const unsub = client.getQueryCache().subscribe(() => {
|
|
20
|
+
count.set(client.isFetching(filters))
|
|
21
|
+
})
|
|
22
|
+
onUnmount(() => unsub())
|
|
23
|
+
|
|
24
|
+
return count
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns a signal that tracks how many mutations are currently in-flight.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* const mutating = useIsMutating()
|
|
32
|
+
* // h('span', null, () => mutating() > 0 ? 'Saving…' : '')
|
|
33
|
+
*/
|
|
34
|
+
export function useIsMutating(filters?: MutationFilters): Signal<number> {
|
|
35
|
+
const client = useQueryClient()
|
|
36
|
+
const count = signal(client.isMutating(filters))
|
|
37
|
+
|
|
38
|
+
const unsub = client.getMutationCache().subscribe(() => {
|
|
39
|
+
count.set(client.isMutating(filters))
|
|
40
|
+
})
|
|
41
|
+
onUnmount(() => unsub())
|
|
42
|
+
|
|
43
|
+
return count
|
|
44
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { onUnmount } from '@pyreon/core'
|
|
2
|
+
import { signal, batch } from '@pyreon/reactivity'
|
|
3
|
+
import type { Signal } from '@pyreon/reactivity'
|
|
4
|
+
import { MutationObserver } from '@tanstack/query-core'
|
|
5
|
+
import type {
|
|
6
|
+
DefaultError,
|
|
7
|
+
MutateFunction,
|
|
8
|
+
MutationObserverOptions,
|
|
9
|
+
MutationObserverResult,
|
|
10
|
+
} from '@tanstack/query-core'
|
|
11
|
+
import { useQueryClient } from './query-client'
|
|
12
|
+
|
|
13
|
+
export interface UseMutationResult<
|
|
14
|
+
TData,
|
|
15
|
+
TError = DefaultError,
|
|
16
|
+
TVariables = void,
|
|
17
|
+
TContext = unknown,
|
|
18
|
+
> {
|
|
19
|
+
/** Raw signal — full observer result. Fine-grained accessors below are preferred. */
|
|
20
|
+
result: Signal<MutationObserverResult<TData, TError, TVariables, TContext>>
|
|
21
|
+
data: Signal<TData | undefined>
|
|
22
|
+
error: Signal<TError | null>
|
|
23
|
+
status: Signal<'idle' | 'pending' | 'success' | 'error'>
|
|
24
|
+
isPending: Signal<boolean>
|
|
25
|
+
isSuccess: Signal<boolean>
|
|
26
|
+
isError: Signal<boolean>
|
|
27
|
+
isIdle: Signal<boolean>
|
|
28
|
+
/** Fire the mutation (fire-and-forget). Errors are captured in the error signal. */
|
|
29
|
+
mutate: (
|
|
30
|
+
variables: TVariables,
|
|
31
|
+
options?: Parameters<
|
|
32
|
+
MutateFunction<TData, TError, TVariables, TContext>
|
|
33
|
+
>[1],
|
|
34
|
+
) => void
|
|
35
|
+
/** Like mutate but returns a promise — use for try/catch error handling. */
|
|
36
|
+
mutateAsync: MutateFunction<TData, TError, TVariables, TContext>
|
|
37
|
+
/** Reset the mutation state back to idle. */
|
|
38
|
+
reset: () => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run a mutation (create / update / delete). Returns reactive signals for
|
|
43
|
+
* pending / success / error state plus `mutate` and `mutateAsync` functions.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* const mutation = useMutation({
|
|
47
|
+
* mutationFn: (data: CreatePostInput) =>
|
|
48
|
+
* fetch('/api/posts', { method: 'POST', body: JSON.stringify(data) }).then(r => r.json()),
|
|
49
|
+
* onSuccess: () => client.invalidateQueries({ queryKey: ['posts'] }),
|
|
50
|
+
* })
|
|
51
|
+
* // h('button', { onClick: () => mutation.mutate({ title: 'New' }) }, 'Create')
|
|
52
|
+
*/
|
|
53
|
+
export function useMutation<
|
|
54
|
+
TData = unknown,
|
|
55
|
+
TError = DefaultError,
|
|
56
|
+
TVariables = void,
|
|
57
|
+
TContext = unknown,
|
|
58
|
+
>(
|
|
59
|
+
options: MutationObserverOptions<TData, TError, TVariables, TContext>,
|
|
60
|
+
): UseMutationResult<TData, TError, TVariables, TContext> {
|
|
61
|
+
const client = useQueryClient()
|
|
62
|
+
const observer = new MutationObserver<TData, TError, TVariables, TContext>(
|
|
63
|
+
client,
|
|
64
|
+
options,
|
|
65
|
+
)
|
|
66
|
+
const initial = observer.getCurrentResult()
|
|
67
|
+
|
|
68
|
+
// Fine-grained signals: each field is independent so only effects that read
|
|
69
|
+
// e.g. `mutation.isPending()` re-run when isPending changes, not on every update.
|
|
70
|
+
const resultSig =
|
|
71
|
+
signal<MutationObserverResult<TData, TError, TVariables, TContext>>(initial)
|
|
72
|
+
const dataSig = signal<TData | undefined>(initial.data)
|
|
73
|
+
const errorSig = signal<TError | null>(initial.error ?? null)
|
|
74
|
+
const statusSig = signal<'idle' | 'pending' | 'success' | 'error'>(
|
|
75
|
+
initial.status,
|
|
76
|
+
)
|
|
77
|
+
const isPending = signal(initial.isPending)
|
|
78
|
+
const isSuccess = signal(initial.isSuccess)
|
|
79
|
+
const isError = signal(initial.isError)
|
|
80
|
+
const isIdle = signal(initial.isIdle)
|
|
81
|
+
|
|
82
|
+
// batch() coalesces all signal updates into one notification flush.
|
|
83
|
+
const unsub = observer.subscribe((r) => {
|
|
84
|
+
batch(() => {
|
|
85
|
+
resultSig.set(r)
|
|
86
|
+
dataSig.set(r.data)
|
|
87
|
+
errorSig.set(r.error ?? null)
|
|
88
|
+
statusSig.set(r.status)
|
|
89
|
+
isPending.set(r.isPending)
|
|
90
|
+
isSuccess.set(r.isSuccess)
|
|
91
|
+
isError.set(r.isError)
|
|
92
|
+
isIdle.set(r.isIdle)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
onUnmount(() => unsub())
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
result: resultSig,
|
|
100
|
+
data: dataSig,
|
|
101
|
+
error: errorSig,
|
|
102
|
+
status: statusSig,
|
|
103
|
+
isPending,
|
|
104
|
+
isSuccess,
|
|
105
|
+
isError,
|
|
106
|
+
isIdle,
|
|
107
|
+
mutate: (vars, callbackOptions) => {
|
|
108
|
+
observer.mutate(vars, callbackOptions).catch(() => {
|
|
109
|
+
// Error is already captured in the error signal via the observer subscription.
|
|
110
|
+
// This catch prevents an unhandled promise rejection for fire-and-forget callers.
|
|
111
|
+
})
|
|
112
|
+
},
|
|
113
|
+
mutateAsync: (vars, callbackOptions) =>
|
|
114
|
+
observer.mutate(vars, callbackOptions),
|
|
115
|
+
reset: () => observer.reset(),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { onUnmount } from '@pyreon/core'
|
|
2
|
+
import { signal, effect } from '@pyreon/reactivity'
|
|
3
|
+
import type { Signal } from '@pyreon/reactivity'
|
|
4
|
+
import { QueriesObserver } from '@tanstack/query-core'
|
|
5
|
+
import type {
|
|
6
|
+
DefaultError,
|
|
7
|
+
QueryKey,
|
|
8
|
+
QueryObserverOptions,
|
|
9
|
+
QueryObserverResult,
|
|
10
|
+
} from '@tanstack/query-core'
|
|
11
|
+
import { useQueryClient } from './query-client'
|
|
12
|
+
|
|
13
|
+
export type UseQueriesOptions<TQueryKey extends QueryKey = QueryKey> =
|
|
14
|
+
QueryObserverOptions<unknown, DefaultError, unknown, unknown, TQueryKey>
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Subscribe to multiple queries in parallel. Returns a single signal containing
|
|
18
|
+
* the array of results — index-aligned with the `queries` array.
|
|
19
|
+
*
|
|
20
|
+
* `queries` is a reactive function so signal-based keys trigger re-evaluation
|
|
21
|
+
* automatically.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* const userIds = signal([1, 2, 3])
|
|
25
|
+
* const results = useQueries(() =>
|
|
26
|
+
* userIds().map(id => ({
|
|
27
|
+
* queryKey: ['user', id],
|
|
28
|
+
* queryFn: () => fetchUser(id),
|
|
29
|
+
* }))
|
|
30
|
+
* )
|
|
31
|
+
* // results() — QueryObserverResult[]
|
|
32
|
+
* // results()[0].data — first user
|
|
33
|
+
*/
|
|
34
|
+
export function useQueries(
|
|
35
|
+
queries: () => UseQueriesOptions[],
|
|
36
|
+
): Signal<QueryObserverResult[]> {
|
|
37
|
+
const client = useQueryClient()
|
|
38
|
+
const observer = new QueriesObserver(client, queries())
|
|
39
|
+
|
|
40
|
+
const resultSig = signal(
|
|
41
|
+
observer.getCurrentResult() as readonly QueryObserverResult[],
|
|
42
|
+
) as Signal<QueryObserverResult[]>
|
|
43
|
+
|
|
44
|
+
const unsub = observer.subscribe(
|
|
45
|
+
(results: readonly QueryObserverResult[]) => {
|
|
46
|
+
resultSig.set(results as QueryObserverResult[])
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// When signals inside queries() change, update the observer.
|
|
51
|
+
effect(() => {
|
|
52
|
+
observer.setQueries(queries())
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
onUnmount(() => {
|
|
56
|
+
unsub()
|
|
57
|
+
observer.destroy()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
return resultSig
|
|
61
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
pushContext,
|
|
4
|
+
popContext,
|
|
5
|
+
onUnmount,
|
|
6
|
+
useContext,
|
|
7
|
+
} from '@pyreon/core'
|
|
8
|
+
import type { VNodeChild, VNode } from '@pyreon/core'
|
|
9
|
+
import type { Props } from '@pyreon/core'
|
|
10
|
+
import { useQueryClient } from './query-client'
|
|
11
|
+
|
|
12
|
+
// ─── Context ────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
interface ErrorResetBoundaryValue {
|
|
15
|
+
reset: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const QueryErrorResetBoundaryContext =
|
|
19
|
+
createContext<ErrorResetBoundaryValue | null>(null)
|
|
20
|
+
|
|
21
|
+
// ─── QueryErrorResetBoundary ─────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export interface QueryErrorResetBoundaryProps extends Props {
|
|
24
|
+
children?: VNodeChild
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Wraps a subtree so that `useQueryErrorResetBoundary()` descendants can reset
|
|
29
|
+
* all errored queries within this boundary.
|
|
30
|
+
*
|
|
31
|
+
* Pair with Pyreon's `ErrorBoundary` to retry failed queries when the user
|
|
32
|
+
* dismisses the error fallback:
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* h(QueryErrorResetBoundary, null,
|
|
36
|
+
* h(ErrorBoundary, {
|
|
37
|
+
* fallback: (err, boundaryReset) => {
|
|
38
|
+
* const { reset } = useQueryErrorResetBoundary()
|
|
39
|
+
* return h('button', {
|
|
40
|
+
* onClick: () => { reset(); boundaryReset() },
|
|
41
|
+
* }, 'Retry')
|
|
42
|
+
* },
|
|
43
|
+
* }, h(MyComponent, null)),
|
|
44
|
+
* )
|
|
45
|
+
*/
|
|
46
|
+
export function QueryErrorResetBoundary(
|
|
47
|
+
props: QueryErrorResetBoundaryProps,
|
|
48
|
+
): VNode {
|
|
49
|
+
const client = useQueryClient()
|
|
50
|
+
|
|
51
|
+
const value: ErrorResetBoundaryValue = {
|
|
52
|
+
reset: () => {
|
|
53
|
+
// Reset all active queries that are in error state so they refetch.
|
|
54
|
+
client.refetchQueries({
|
|
55
|
+
predicate: (query) => query.state.status === 'error',
|
|
56
|
+
})
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const frame = new Map([[QueryErrorResetBoundaryContext.id, value]])
|
|
61
|
+
pushContext(frame)
|
|
62
|
+
onUnmount(() => popContext())
|
|
63
|
+
|
|
64
|
+
const ch = props.children
|
|
65
|
+
return (typeof ch === 'function' ? (ch as () => VNodeChild)() : ch) as VNode
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── useQueryErrorResetBoundary ──────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Returns the `reset` function provided by the nearest `QueryErrorResetBoundary`.
|
|
72
|
+
* If called outside a boundary, falls back to resetting all errored queries
|
|
73
|
+
* on the current `QueryClient`.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* // Inside an ErrorBoundary fallback:
|
|
77
|
+
* const { reset } = useQueryErrorResetBoundary()
|
|
78
|
+
* h('button', { onClick: () => { reset(); boundaryReset() } }, 'Retry')
|
|
79
|
+
*/
|
|
80
|
+
export function useQueryErrorResetBoundary(): ErrorResetBoundaryValue {
|
|
81
|
+
const boundary = useContext(QueryErrorResetBoundaryContext)
|
|
82
|
+
// Always call useQueryClient to respect hook ordering rules
|
|
83
|
+
const client = useQueryClient()
|
|
84
|
+
|
|
85
|
+
if (boundary) return boundary
|
|
86
|
+
|
|
87
|
+
// Fallback: no explicit boundary — use the QueryClient directly.
|
|
88
|
+
return {
|
|
89
|
+
reset: () => {
|
|
90
|
+
client.refetchQueries({
|
|
91
|
+
predicate: (query) => query.state.status === 'error',
|
|
92
|
+
})
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/use-query.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { onUnmount } from '@pyreon/core'
|
|
2
|
+
import { signal, effect, batch } from '@pyreon/reactivity'
|
|
3
|
+
import type { Signal } from '@pyreon/reactivity'
|
|
4
|
+
import { QueryObserver } from '@tanstack/query-core'
|
|
5
|
+
import type {
|
|
6
|
+
DefaultError,
|
|
7
|
+
QueryKey,
|
|
8
|
+
QueryObserverOptions,
|
|
9
|
+
QueryObserverResult,
|
|
10
|
+
} from '@tanstack/query-core'
|
|
11
|
+
import { useQueryClient } from './query-client'
|
|
12
|
+
|
|
13
|
+
export interface UseQueryResult<TData, TError = DefaultError> {
|
|
14
|
+
/** Raw signal — the full observer result. Fine-grained accessors below are preferred. */
|
|
15
|
+
result: Signal<QueryObserverResult<TData, TError>>
|
|
16
|
+
data: Signal<TData | undefined>
|
|
17
|
+
error: Signal<TError | null>
|
|
18
|
+
status: Signal<'pending' | 'error' | 'success'>
|
|
19
|
+
isPending: Signal<boolean>
|
|
20
|
+
isLoading: Signal<boolean>
|
|
21
|
+
isFetching: Signal<boolean>
|
|
22
|
+
isError: Signal<boolean>
|
|
23
|
+
isSuccess: Signal<boolean>
|
|
24
|
+
/** Manually trigger a refetch. */
|
|
25
|
+
refetch: () => Promise<QueryObserverResult<TData, TError>>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Subscribe to a query. Returns fine-grained reactive signals for data,
|
|
30
|
+
* error and status — each signal only notifies effects that depend on it.
|
|
31
|
+
*
|
|
32
|
+
* `options` is a function so it can read Pyreon signals — when a signal changes
|
|
33
|
+
* (e.g. a reactive query key), the observer is updated and refetches automatically.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* const userId = signal(1)
|
|
37
|
+
* const query = useQuery(() => ({
|
|
38
|
+
* queryKey: ['user', userId()],
|
|
39
|
+
* queryFn: () => fetch(`/api/users/${userId()}`).then(r => r.json()),
|
|
40
|
+
* }))
|
|
41
|
+
* // In template: () => query.data()?.name
|
|
42
|
+
*/
|
|
43
|
+
export function useQuery<
|
|
44
|
+
TData = unknown,
|
|
45
|
+
TError = DefaultError,
|
|
46
|
+
TKey extends QueryKey = QueryKey,
|
|
47
|
+
>(
|
|
48
|
+
options: () => QueryObserverOptions<TData, TError, TData, TData, TKey>,
|
|
49
|
+
): UseQueryResult<TData, TError> {
|
|
50
|
+
const client = useQueryClient()
|
|
51
|
+
const observer = new QueryObserver<TData, TError, TData, TData, TKey>(
|
|
52
|
+
client,
|
|
53
|
+
options(),
|
|
54
|
+
)
|
|
55
|
+
const initial = observer.getCurrentResult()
|
|
56
|
+
|
|
57
|
+
// Fine-grained signals: each field is independent so only effects that read
|
|
58
|
+
// e.g. `query.data()` re-run when data changes, not when isFetching flips.
|
|
59
|
+
const resultSig = signal<QueryObserverResult<TData, TError>>(initial)
|
|
60
|
+
const dataSig = signal<TData | undefined>(initial.data)
|
|
61
|
+
const errorSig = signal<TError | null>(initial.error ?? null)
|
|
62
|
+
const statusSig = signal<'pending' | 'error' | 'success'>(initial.status)
|
|
63
|
+
const isPending = signal(initial.isPending)
|
|
64
|
+
const isLoading = signal(initial.isLoading)
|
|
65
|
+
const isFetching = signal(initial.isFetching)
|
|
66
|
+
const isError = signal(initial.isError)
|
|
67
|
+
const isSuccess = signal(initial.isSuccess)
|
|
68
|
+
|
|
69
|
+
// Subscribe synchronously — data flows before mount (correct for SSR pre-population).
|
|
70
|
+
// batch() coalesces all signal updates into one notification flush.
|
|
71
|
+
const unsub = observer.subscribe((r) => {
|
|
72
|
+
batch(() => {
|
|
73
|
+
resultSig.set(r)
|
|
74
|
+
dataSig.set(r.data)
|
|
75
|
+
errorSig.set(r.error ?? null)
|
|
76
|
+
statusSig.set(r.status)
|
|
77
|
+
isPending.set(r.isPending)
|
|
78
|
+
isLoading.set(r.isLoading)
|
|
79
|
+
isFetching.set(r.isFetching)
|
|
80
|
+
isError.set(r.isError)
|
|
81
|
+
isSuccess.set(r.isSuccess)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Track reactive options: when signals inside options() change, update the observer.
|
|
86
|
+
// effect() is auto-registered in the component's EffectScope → auto-disposed on unmount.
|
|
87
|
+
effect(() => {
|
|
88
|
+
observer.setOptions(options())
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Unsubscribe the observer on unmount (effect disposal is handled by EffectScope).
|
|
92
|
+
onUnmount(() => unsub())
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
result: resultSig,
|
|
96
|
+
data: dataSig,
|
|
97
|
+
error: errorSig,
|
|
98
|
+
status: statusSig,
|
|
99
|
+
isPending,
|
|
100
|
+
isLoading,
|
|
101
|
+
isFetching,
|
|
102
|
+
isError,
|
|
103
|
+
isSuccess,
|
|
104
|
+
refetch: () => observer.refetch(),
|
|
105
|
+
}
|
|
106
|
+
}
|