1import { useEffect, useCallback, useRef, useMemo } from 'preact/hooks';
2import { pipe, concat, fromValue, switchMap, map, scan } from 'wonka';
3
4import type {
5 AnyVariables,
6 GraphQLRequestParams,
7 CombinedError,
8 OperationContext,
9 Operation,
10} from '@urql/core';
11
12import { useClient } from '../context';
13import { useSource } from './useSource';
14import { useRequest } from './useRequest';
15import { initialState } from './constants';
16
17/** Input arguments for the {@link useSubscription} hook.
18 *
19 * @param query - The GraphQL subscription document that `useSubscription` executes.
20 * @param variables - The variables for the GraphQL subscription that `useSubscription` executes.
21 */
22export type UseSubscriptionArgs<
23 Variables extends AnyVariables = AnyVariables,
24 Data = any,
25> = {
26 /** Prevents {@link useSubscription} from automatically starting GraphQL subscriptions.
27 *
28 * @remarks
29 * `pause` may be set to `true` to stop {@link useSubscription} from starting its subscription
30 * automatically. The hook will stop receiving updates from the {@link Client}
31 * and won’t start the subscription operation, until either it’s set to `false`
32 * or the {@link UseSubscriptionExecute} function is called.
33 */
34 pause?: boolean;
35 /** Updates the {@link OperationContext} for the executed GraphQL subscription operation.
36 *
37 * @remarks
38 * `context` may be passed to {@link useSubscription}, to update the {@link OperationContext}
39 * of a subscription operation. This may be used to update the `context` that exchanges
40 * will receive for a single hook.
41 *
42 * Hint: This should be wrapped in a `useMemo` hook, to make sure that your
43 * component doesn’t infinitely update.
44 *
45 * @example
46 * ```ts
47 * const [result, reexecute] = useSubscription({
48 * query,
49 * context: useMemo(() => ({
50 * additionalTypenames: ['Item'],
51 * }), [])
52 * });
53 * ```
54 */
55 context?: Partial<OperationContext>;
56} & GraphQLRequestParams<Data, Variables>;
57
58/** Combines previous data with an incoming subscription result’s data.
59 *
60 * @remarks
61 * A `SubscriptionHandler` may be passed to {@link useSubscription} to
62 * aggregate subscription results into a combined {@link UseSubscriptionState.data}
63 * value.
64 *
65 * This is useful when a subscription event delivers a single item, while
66 * you’d like to display a list of events.
67 *
68 * @example
69 * ```ts
70 * const NotificationsSubscription = gql`
71 * subscription { newNotification { id, text } }
72 * `;
73 *
74 * const combineNotifications = (notifications = [], data) => {
75 * return [...notifications, data.newNotification];
76 * };
77 *
78 * const [result, executeSubscription] = useSubscription(
79 * { query: NotificationsSubscription },
80 * combineNotifications,
81 * );
82 * ```
83 */
84export type SubscriptionHandler<T, R> = (prev: R | undefined, data: T) => R;
85
86/** State of the current subscription, your {@link useSubscription} hook is executing.
87 *
88 * @remarks
89 * `UseSubscriptionState` is returned (in a tuple) by {@link useSubscription} and
90 * gives you the updating {@link OperationResult} of GraphQL subscriptions.
91 *
92 * If a {@link SubscriptionHandler} has been passed to `useSubscription` then
93 * {@link UseSubscriptionState.data} is instead the updated data as returned
94 * by the handler, otherwise it’s the latest result’s data.
95 *
96 * Hint: Even when the query and variables passed to {@link useSubscription} change,
97 * this state preserves the prior state.
98 */
99export interface UseSubscriptionState<
100 Data = any,
101 Variables extends AnyVariables = AnyVariables,
102> {
103 /** Indicates whether `useSubscription`’s subscription is active.
104 *
105 * @remarks
106 * When `useSubscription` starts a subscription, the `fetching` flag
107 * is set to `true` and will remain `true` until the subscription
108 * completes on the API, or the {@link UseSubscriptionArgs.pause}
109 * flag is set to `true`.
110 */
111 fetching: boolean;
112 /** Indicates that the subscription result is not fresh.
113 *
114 * @remarks
115 * This is mostly unused for subscriptions and will rarely affect you, and
116 * is more relevant for queries.
117 *
118 * @see {@link OperationResult.stale} for the source of this value.
119 */
120 stale: boolean;
121 /** The {@link OperationResult.data} for the executed subscription, or data returned by a handler.
122 *
123 * @remarks
124 * `data` will be set to the last {@link OperationResult.data} value
125 * received for the subscription.
126 *
127 * It will instead be set to the values that {@link SubscriptionHandler}
128 * returned, if a handler has been passed to {@link useSubscription}.
129 */
130 data?: Data;
131 /** The {@link OperationResult.error} for the executed subscription. */
132 error?: CombinedError;
133 /** The {@link OperationResult.extensions} for the executed mutation. */
134 extensions?: Record<string, any>;
135 /** The {@link Operation} that the current state is for.
136 *
137 * @remarks
138 * This is the subscription {@link Operation} that is currently active.
139 * When {@link UseSubscriptionState.fetching} is `true`, this is the
140 * last `Operation` that the current state was for.
141 */
142 operation?: Operation<Data, Variables>;
143}
144
145/** Triggers {@link useSubscription} to reexecute a GraphQL subscription operation.
146 *
147 * @param opts - optionally, context options that will be merged with the hook's
148 * {@link UseSubscriptionArgs.context} options and the `Client`’s options.
149 *
150 * @remarks
151 * When called, {@link useSubscription} will restart the GraphQL subscription
152 * operation it currently holds. If {@link UseSubscriptionArgs.pause} is set
153 * to `true`, it will start executing the subscription.
154 *
155 * ```ts
156 * const [result, executeSubscription] = useSubscription({
157 * query,
158 * pause: true,
159 * });
160 *
161 * const start = () => {
162 * executeSubscription();
163 * };
164 * ```
165 */
166export type UseSubscriptionExecute = (opts?: Partial<OperationContext>) => void;
167
168/** Result tuple returned by the {@link useSubscription} hook.
169 *
170 * @remarks
171 * Similarly to a `useState` hook’s return value,
172 * the first element is the {@link useSubscription}’s state,
173 * a {@link UseSubscriptionState} object,
174 * and the second is used to imperatively re-execute or start the subscription
175 * via a {@link UseMutationExecute} function.
176 */
177export type UseSubscriptionResponse<
178 Data = any,
179 Variables extends AnyVariables = AnyVariables,
180> = [UseSubscriptionState<Data, Variables>, UseSubscriptionExecute];
181
182/** Hook to run a GraphQL subscription and get updated GraphQL results.
183 *
184 * @param args - a {@link UseSubscriptionArgs} object, to pass a `query`, `variables`, and options.
185 * @param handler - optionally, a {@link SubscriptionHandler} function to combine multiple subscription results.
186 * @returns a {@link UseSubscriptionResponse} tuple of a {@link UseSubscriptionState} result, and an execute function.
187 *
188 * @remarks
189 * `useSubscription` allows GraphQL subscriptions to be defined and executed.
190 * Given {@link UseSubscriptionArgs.query}, it executes the GraphQL subscription with the
191 * context’s {@link Client}.
192 *
193 * The returned result updates when the `Client` has new results
194 * for the subscription, and `data` is updated with the result’s data
195 * or with the `data` that a `handler` returns.
196 *
197 * @example
198 * ```ts
199 * import { gql, useSubscription } from '@urql/preact';
200 *
201 * const NotificationsSubscription = gql`
202 * subscription { newNotification { id, text } }
203 * `;
204 *
205 * const combineNotifications = (notifications = [], data) => {
206 * return [...notifications, data.newNotification];
207 * };
208 *
209 * const Notifications = () => {
210 * const [result, executeSubscription] = useSubscription(
211 * { query: NotificationsSubscription },
212 * combineNotifications,
213 * );
214 * // ...
215 * };
216 * ```
217 */
218export function useSubscription<
219 Data = any,
220 Result = Data,
221 Variables extends AnyVariables = AnyVariables,
222>(
223 args: UseSubscriptionArgs<Variables, Data>,
224 handler?: SubscriptionHandler<Data, Result>
225): UseSubscriptionResponse<Result, Variables> {
226 const client = useClient();
227
228 // Update handler on constant ref, since handler changes shouldn't
229 // trigger a new subscription run
230 const handlerRef = useRef(handler);
231 handlerRef.current = handler!;
232
233 // This creates a request which will keep a stable reference
234 // if request.key doesn't change
235 const request = useRequest(args.query, args.variables as Variables);
236
237 // Create a new subscription-source from client.executeSubscription
238 const makeSubscription$ = useCallback(
239 (opts?: Partial<OperationContext>) => {
240 return client.executeSubscription<Data, Variables>(request, {
241 ...args.context,
242 ...opts,
243 });
244 },
245 [client, request, args.context]
246 );
247
248 const subscription$ = useMemo(() => {
249 return args.pause ? null : makeSubscription$();
250 }, [args.pause, makeSubscription$]);
251
252 const [state, update] = useSource(
253 subscription$,
254 useCallback(
255 (subscription$$, prevState?: UseSubscriptionState<Result, Variables>) => {
256 return pipe(
257 subscription$$,
258 switchMap(subscription$ => {
259 if (!subscription$) return fromValue({ fetching: false });
260
261 return concat([
262 // Initially set fetching to true
263 fromValue({ fetching: true, stale: false }),
264 pipe(
265 subscription$,
266 map(({ stale, data, error, extensions, operation }) => ({
267 fetching: true,
268 stale: !!stale,
269 data,
270 error,
271 extensions,
272 operation,
273 }))
274 ),
275 // When the source proactively closes, fetching is set to false
276 fromValue({ fetching: false, stale: false }),
277 ]);
278 }),
279 // The individual partial results are merged into each previous result
280 scan(
281 (result: UseSubscriptionState<Result, Variables>, partial: any) => {
282 const { current: handler } = handlerRef;
283 // If a handler has been passed, it's used to merge new data in
284 const data =
285 partial.data != null
286 ? typeof handler === 'function'
287 ? handler(result.data, partial.data)
288 : partial.data
289 : result.data;
290 return { ...result, ...partial, data };
291 },
292 prevState || initialState
293 )
294 );
295 },
296 []
297 )
298 );
299
300 // This is the imperative execute function passed to the user
301 const executeSubscription = useCallback(
302 (opts?: Partial<OperationContext>) => update(makeSubscription$(opts)),
303 [update, makeSubscription$]
304 );
305
306 useEffect(() => {
307 update(subscription$);
308 }, [update, subscription$]);
309
310 return [state, executeSubscription];
311}