1import { useEffect, useCallback, useMemo } from 'preact/hooks';
2
3import type { Source } from 'wonka';
4import {
5 pipe,
6 share,
7 takeWhile,
8 concat,
9 fromValue,
10 switchMap,
11 map,
12 scan,
13} from 'wonka';
14
15import type {
16 Client,
17 GraphQLRequestParams,
18 AnyVariables,
19 CombinedError,
20 OperationContext,
21 RequestPolicy,
22 OperationResult,
23 Operation,
24} from '@urql/core';
25
26import { useClient } from '../context';
27import { useSource } from './useSource';
28import { useRequest } from './useRequest';
29import { initialState } from './constants';
30
31/** Input arguments for the {@link useQuery} hook.
32 *
33 * @param query - The GraphQL query that `useQuery` executes.
34 * @param variables - The variables for the GraphQL query that `useQuery` executes.
35 */
36export type UseQueryArgs<
37 Variables extends AnyVariables = AnyVariables,
38 Data = any,
39> = {
40 /** Updates the {@link RequestPolicy} for the executed GraphQL query operation.
41 *
42 * @remarks
43 * `requestPolicy` modifies the {@link RequestPolicy} of the GraphQL query operation
44 * that `useQuery` executes, and indicates a caching strategy for cache exchanges.
45 *
46 * For example, when set to `'cache-and-network'`, {@link useQuery} will
47 * receive a cached result with `stale: true` and an API request will be
48 * sent in the background.
49 *
50 * @see {@link OperationContext.requestPolicy} for where this value is set.
51 */
52 requestPolicy?: RequestPolicy;
53 /** Updates the {@link OperationContext} for the executed GraphQL query operation.
54 *
55 * @remarks
56 * `context` may be passed to {@link useQuery}, to update the {@link OperationContext}
57 * of a query operation. This may be used to update the `context` that exchanges
58 * will receive for a single hook.
59 *
60 * Hint: This should be wrapped in a `useMemo` hook, to make sure that your
61 * component doesn’t infinitely update.
62 *
63 * @example
64 * ```ts
65 * const [result, reexecute] = useQuery({
66 * query,
67 * context: useMemo(() => ({
68 * additionalTypenames: ['Item'],
69 * }), [])
70 * });
71 * ```
72 */
73 context?: Partial<OperationContext>;
74 /** Prevents {@link useQuery} from automatically executing GraphQL query operations.
75 *
76 * @remarks
77 * `pause` may be set to `true` to stop {@link useQuery} from executing
78 * automatically. The hook will stop receiving updates from the {@link Client}
79 * and won’t execute the query operation, until either it’s set to `false`
80 * or the {@link UseQueryExecute} function is called.
81 *
82 * @see {@link https://urql.dev/goto/docs/basics/react-preact/#pausing-usequery} for
83 * documentation on the `pause` option.
84 */
85 pause?: boolean;
86} & GraphQLRequestParams<Data, Variables>;
87
88/** State of the current query, your {@link useQuery} hook is executing.
89 *
90 * @remarks
91 * `UseQueryState` is returned (in a tuple) by {@link useQuery} and
92 * gives you the updating {@link OperationResult} of GraphQL queries.
93 *
94 * Even when the query and variables passed to {@link useQuery} change,
95 * this state preserves the prior state and sets the `fetching` flag to
96 * `true`.
97 * This allows you to display the previous state, while implementing
98 * a separate loading indicator separately.
99 */
100export interface UseQueryState<
101 Data = any,
102 Variables extends AnyVariables = AnyVariables,
103> {
104 /** Indicates whether `useQuery` is waiting for a new result.
105 *
106 * @remarks
107 * When `useQuery` is passed a new query and/or variables, it will
108 * start executing the new query operation and `fetching` is set to
109 * `true` until a result arrives.
110 *
111 * Hint: This is subtly different than whether the query is actually
112 * fetching, and doesn’t indicate whether a query is being re-executed
113 * in the background. For this, see {@link UseQueryState.stale}.
114 */
115 fetching: boolean;
116 /** Indicates that the state is not fresh and a new result will follow.
117 *
118 * @remarks
119 * The `stale` flag is set to `true` when a new result for the query
120 * is expected and `useQuery` is waiting for it. This may indicate that
121 * a new request is being requested in the background.
122 *
123 * @see {@link OperationResult.stale} for the source of this value.
124 */
125 stale: boolean;
126 /** The {@link OperationResult.data} for the executed query. */
127 data?: Data;
128 /** The {@link OperationResult.error} for the executed query. */
129 error?: CombinedError;
130 /** The {@link OperationResult.extensions} for the executed query. */
131 extensions?: Record<string, any>;
132 /** The {@link Operation} that the current state is for.
133 *
134 * @remarks
135 * This is the {@link Operation} that is currently being executed.
136 * When {@link UseQueryState.fetching} is `true`, this is the
137 * last `Operation` that the current state was for.
138 */
139 operation?: Operation<Data, Variables>;
140 /** The {@link OperationResult.hasNext} for the executed query. */
141 hasNext: boolean;
142}
143
144/** Triggers {@link useQuery} to execute a new GraphQL query operation.
145 *
146 * @remarks
147 * When called, {@link useQuery} will re-execute the GraphQL query operation
148 * it currently holds, even if {@link UseQueryArgs.pause} is set to `true`.
149 *
150 * This is useful for executing a paused query or re-executing a query
151 * and get a new network result, by passing a new request policy.
152 *
153 * ```ts
154 * const [result, reexecuteQuery] = useQuery({ query });
155 *
156 * const refresh = () => {
157 * // Re-execute the query with a network-only policy, skipping the cache
158 * reexecuteQuery({ requestPolicy: 'network-only' });
159 * };
160 * ```
161 */
162export type UseQueryExecute = (opts?: Partial<OperationContext>) => void;
163
164/** Result tuple returned by the {@link useQuery} hook.
165 *
166 * @remarks
167 * Similarly to a `useState` hook’s return value,
168 * the first element is the {@link useQuery}’s result and state,
169 * a {@link UseQueryState} object,
170 * and the second is used to imperatively re-execute the query
171 * via a {@link UseQueryExecute} function.
172 */
173export type UseQueryResponse<
174 Data = any,
175 Variables extends AnyVariables = AnyVariables,
176> = [UseQueryState<Data, Variables>, UseQueryExecute];
177
178/** Convert the Source to a React Suspense source on demand
179 * @internal
180 */
181function toSuspenseSource<T>(source: Source<T>): Source<T> {
182 const shared = share(source);
183 let cache: T | void;
184 let resolve: (value: T) => void;
185
186 return sink => {
187 let hasSuspended = false;
188
189 pipe(
190 shared,
191 takeWhile(result => {
192 // The first result that is received will resolve the suspense
193 // promise after waiting for a microtick
194 if (cache === undefined) Promise.resolve(result).then(resolve);
195 cache = result;
196 return !hasSuspended;
197 })
198 )(sink);
199
200 // If we haven't got a previous result then start suspending
201 // otherwise issue the last known result immediately
202 if (cache !== undefined) {
203 const signal = [cache] as [T] & { tag: 1 };
204 signal.tag = 1;
205 sink(signal);
206 } else {
207 hasSuspended = true;
208 sink(0 /* End */);
209 throw new Promise<T>(_resolve => {
210 resolve = _resolve;
211 });
212 }
213 };
214}
215
216const isSuspense = (client: Client, context?: Partial<OperationContext>) =>
217 context && context.suspense !== undefined
218 ? !!context.suspense
219 : client.suspense;
220
221const sources = new Map<number, Source<OperationResult>>();
222
223/** Hook to run a GraphQL query and get updated GraphQL results.
224 *
225 * @param args - a {@link UseQueryArgs} object, to pass a `query`, `variables`, and options.
226 * @returns a {@link UseQueryResponse} tuple of a {@link UseQueryState} result, and re-execute function.
227 *
228 * @remarks
229 * `useQuery` allows GraphQL queries to be defined and executed.
230 * Given {@link UseQueryArgs.query}, it executes the GraphQL query with the
231 * context’s {@link Client}.
232 *
233 * The returned result updates when the `Client` has new results
234 * for the query, and changes when your input `args` change.
235 *
236 * Additionally, if the `suspense` option is enabled on the `Client`,
237 * the `useQuery` hook will suspend instead of indicating that it’s
238 * waiting for a result via {@link UseQueryState.fetching}.
239 *
240 * @see {@link https://urql.dev/goto/docs/basics/react-preact/#queries} for `useQuery` docs.
241 *
242 * @example
243 * ```ts
244 * import { gql, useQuery } from '@urql/preact';
245 *
246 * const TodosQuery = gql`
247 * query { todos { id, title } }
248 * `;
249 *
250 * const Todos = () => {
251 * const [result, reexecuteQuery] = useQuery({
252 * query: TodosQuery,
253 * variables: {},
254 * });
255 * // ...
256 * };
257 * ```
258 */
259export function useQuery<
260 Data = any,
261 Variables extends AnyVariables = AnyVariables,
262>(args: UseQueryArgs<Variables, Data>): UseQueryResponse<Data, Variables> {
263 const client = useClient();
264 // This creates a request which will keep a stable reference
265 // if request.key doesn't change
266 const request = useRequest(args.query, args.variables as Variables);
267
268 // Create a new query-source from client.executeQuery
269 const makeQuery$ = useCallback(
270 (opts?: Partial<OperationContext>) => {
271 // Determine whether suspense is enabled for the given operation
272 const suspense = isSuspense(client, args.context);
273 let source: Source<OperationResult> | void = suspense
274 ? sources.get(request.key)
275 : undefined;
276
277 if (!source) {
278 source = client.executeQuery(request, {
279 requestPolicy: args.requestPolicy,
280 ...args.context,
281 ...opts,
282 });
283
284 // Create a suspense source and cache it for the given request
285 if (suspense) {
286 source = toSuspenseSource(source);
287 if (typeof window !== 'undefined') {
288 sources.set(request.key, source);
289 }
290 }
291 }
292
293 return source;
294 },
295 [client, request, args.requestPolicy, args.context]
296 );
297
298 const query$ = useMemo(() => {
299 return args.pause ? null : makeQuery$();
300 }, [args.pause, makeQuery$]);
301
302 const [state, update] = useSource(
303 query$,
304 useCallback((query$$, prevState?: UseQueryState<Data, Variables>) => {
305 return pipe(
306 query$$,
307 switchMap(query$ => {
308 if (!query$)
309 return fromValue({ fetching: false, stale: false, hasNext: false });
310
311 return concat([
312 // Initially set fetching to true
313 fromValue({ fetching: true, stale: false }),
314 pipe(
315 query$,
316 map(({ stale, data, error, extensions, operation, hasNext }) => ({
317 fetching: false,
318 stale: !!stale,
319 hasNext,
320 data,
321 error,
322 operation,
323 extensions,
324 }))
325 ),
326 // When the source proactively closes, fetching is set to false
327 fromValue({ fetching: false, stale: false, hasNext: false }),
328 ]);
329 }),
330 // The individual partial results are merged into each previous result
331 scan(
332 (result: UseQueryState<Data, Variables>, partial) => ({
333 ...result,
334 ...partial,
335 }),
336 prevState || initialState
337 )
338 );
339 }, [])
340 );
341
342 // This is the imperative execute function passed to the user
343 const executeQuery = useCallback(
344 (opts?: Partial<OperationContext>) => {
345 update(makeQuery$({ suspense: false, ...opts }));
346 },
347 [update, makeQuery$]
348 );
349
350 useEffect(() => {
351 sources.delete(request.key); // Delete any cached suspense source
352 if (!isSuspense(client, args.context)) update(query$);
353 }, [update, client, query$, request, args.context]);
354
355 if (isSuspense(client, args.context)) {
356 update(query$);
357 }
358
359 return [state, executeQuery];
360}