-
Notifications
You must be signed in to change notification settings - Fork 2.6k
/
useSuspenseQuery.ts
355 lines (308 loc) · 10.7 KB
/
useSuspenseQuery.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
import {
useRef,
useEffect,
useCallback,
useMemo,
useState,
} from 'react';
import { equal } from '@wry/equality';
import {
ApolloClient,
ApolloError,
ApolloQueryResult,
DocumentNode,
ObservableQuery,
OperationVariables,
TypedDocumentNode,
WatchQueryOptions,
WatchQueryFetchPolicy,
} from '../../core';
import { invariant } from '../../utilities/globals';
import {
compact,
Concast,
isNonEmptyArray,
hasDirectives,
} from '../../utilities';
import { useApolloClient } from './useApolloClient';
import { DocumentType, verifyDocumentType } from '../parser';
import {
SuspenseQueryHookOptions,
ObservableQueryFields,
} from '../types/types';
import { useDeepMemo, useIsomorphicLayoutEffect } from './internal';
import { useSuspenseCache } from './useSuspenseCache';
import { useSyncExternalStore } from './useSyncExternalStore';
export interface UseSuspenseQueryResult<
TData = any,
TVariables = OperationVariables
> {
data: TData;
error: ApolloError | undefined;
fetchMore: ObservableQueryFields<TData, TVariables>['fetchMore'];
refetch: ObservableQueryFields<TData, TVariables>['refetch'];
}
const SUPPORTED_FETCH_POLICIES: WatchQueryFetchPolicy[] = [
'cache-first',
'network-only',
'no-cache',
'cache-and-network',
];
const DEFAULT_FETCH_POLICY = 'cache-first';
const DEFAULT_SUSPENSE_POLICY = 'always';
const DEFAULT_ERROR_POLICY = 'none';
export function useSuspenseQuery_experimental<
TData = any,
TVariables extends OperationVariables = OperationVariables
>(
query: DocumentNode | TypedDocumentNode<TData, TVariables>,
options: SuspenseQueryHookOptions<TData, TVariables> = Object.create(null)
): UseSuspenseQueryResult<TData, TVariables> {
const suspenseCache = useSuspenseCache();
const client = useApolloClient(options.client);
const watchQueryOptions = useWatchQueryOptions({ query, options, client });
const previousWatchQueryOptionsRef = useRef(watchQueryOptions);
const deferred = useIsDeferred(query);
const { fetchPolicy, errorPolicy, returnPartialData, variables } =
watchQueryOptions;
let cacheEntry = suspenseCache.lookup(query, variables);
const [observable] = useState(() => {
return cacheEntry?.observable || client.watchQuery(watchQueryOptions);
});
const result = useObservableQueryResult(observable);
const hasFullResult = result.data && !result.partial;
const hasPartialResult = result.data && result.partial;
const usePartialResult = returnPartialData && hasPartialResult;
if (
result.error &&
errorPolicy === 'none' &&
// If we've got a deferred query that errors on an incremental chunk, we
// will have a partial result before the error is collected. We do not want
// to throw errors that have been returned from incremental chunks. Instead
// we offload those errors to the `error` property.
(!deferred || !hasPartialResult)
) {
throw result.error;
}
if (result.loading) {
// If we don't have a cache entry, but we are in a loading state, we are on
// the first run of the hook. Kick off a network request so we can suspend
// immediately
if (!cacheEntry) {
cacheEntry = suspenseCache.add(query, variables, {
promise: maybeWrapConcastWithCustomPromise(
observable.reobserveAsConcast(watchQueryOptions),
{ deferred }
),
observable,
});
}
const hasUsableResult =
// When we have partial data in the cache, a network request will be kicked
// off to load the full set of data. Avoid suspending when the request is
// in flight to return the partial data immediately.
usePartialResult ||
// `cache-and-network` kicks off a network request even with a full set of
// data in the cache, which means the loading state will be set to `true`.
// Avoid suspending in this case.
(fetchPolicy === 'cache-and-network' && hasFullResult);
if (!hasUsableResult && !cacheEntry.fulfilled) {
throw cacheEntry.promise;
}
}
useEffect(() => {
const { variables, query } = watchQueryOptions;
const previousOpts = previousWatchQueryOptionsRef.current;
if (variables !== previousOpts.variables || query !== previousOpts.query) {
suspenseCache.remove(previousOpts.query, previousOpts.variables);
suspenseCache.add(query, variables, {
promise: observable.reobserve({ query, variables }),
observable,
});
previousWatchQueryOptionsRef.current = watchQueryOptions;
}
}, [watchQueryOptions]);
useEffect(() => {
return () => {
suspenseCache.remove(query, variables);
};
}, []);
return useMemo(() => {
return {
data: result.data,
error: errorPolicy === 'ignore' ? void 0 : toApolloError(result),
fetchMore: (options) => {
const promise = observable.fetchMore(options);
suspenseCache.add(query, watchQueryOptions.variables, {
promise,
observable,
});
return promise;
},
refetch: (variables?: Partial<TVariables>) => {
const promise = observable.refetch(variables);
suspenseCache.add(query, watchQueryOptions.variables, {
promise,
observable,
});
return promise;
},
};
}, [result, observable, errorPolicy]);
}
function validateOptions(options: WatchQueryOptions) {
const {
query,
fetchPolicy = DEFAULT_FETCH_POLICY,
returnPartialData,
} = options;
verifyDocumentType(query, DocumentType.Query);
validateFetchPolicy(fetchPolicy);
validatePartialDataReturn(fetchPolicy, returnPartialData);
}
function validateFetchPolicy(fetchPolicy: WatchQueryFetchPolicy) {
invariant(
SUPPORTED_FETCH_POLICIES.includes(fetchPolicy),
`The fetch policy \`${fetchPolicy}\` is not supported with suspense.`
);
}
function validatePartialDataReturn(
fetchPolicy: WatchQueryFetchPolicy,
returnPartialData: boolean | undefined
) {
if (fetchPolicy === 'no-cache' && returnPartialData) {
invariant.warn(
'Using `returnPartialData` with a `no-cache` fetch policy has no effect. To read partial data from the cache, consider using an alternate fetch policy.'
);
}
}
function toApolloError(result: ApolloQueryResult<any>) {
return isNonEmptyArray(result.errors)
? new ApolloError({ graphQLErrors: result.errors })
: result.error;
}
function maybeWrapConcastWithCustomPromise<TData>(
concast: Concast<ApolloQueryResult<TData>>,
{ deferred }: { deferred: boolean }
): Promise<ApolloQueryResult<TData>> {
if (deferred) {
return new Promise((resolve, reject) => {
// Unlike `concast.promise`, we want to resolve the promise on the initial
// chunk of the deferred query. This allows the component to unsuspend
// when we get the initial set of data, rather than waiting until all
// chunks have been loaded.
const subscription = concast.subscribe({
next: (value) => {
resolve(value);
subscription.unsubscribe();
},
error: reject,
});
});
}
return concast.promise;
}
interface UseWatchQueryOptionsHookOptions<TData, TVariables> {
query: DocumentNode | TypedDocumentNode<TData, TVariables>;
options: SuspenseQueryHookOptions<TData, TVariables>;
client: ApolloClient<any>;
}
function useWatchQueryOptions<TData, TVariables>({
query,
options,
client,
}: UseWatchQueryOptionsHookOptions<TData, TVariables>): WatchQueryOptions<
TVariables,
TData
> {
const { watchQuery: defaultOptions } = client.defaultOptions;
const watchQueryOptions = useDeepMemo<
WatchQueryOptions<TVariables, TData>
>(() => {
const {
errorPolicy,
fetchPolicy,
suspensePolicy = DEFAULT_SUSPENSE_POLICY,
variables,
...watchQueryOptions
} = options;
return {
...watchQueryOptions,
query,
errorPolicy:
errorPolicy || defaultOptions?.errorPolicy || DEFAULT_ERROR_POLICY,
fetchPolicy:
fetchPolicy || defaultOptions?.fetchPolicy || DEFAULT_FETCH_POLICY,
notifyOnNetworkStatusChange: suspensePolicy === 'always',
// By default, `ObservableQuery` will run `reobserve` the first time
// something `subscribe`s to the observable, which kicks off a network
// request. This creates a problem for suspense because we need to begin
// fetching the data immediately so we can throw the promise on the first
// render. Since we don't subscribe until after we've unsuspended, we need
// to avoid kicking off another network request for the same data we just
// fetched. This option toggles that behavior off to avoid the `reobserve`
// when the observable is first subscribed to.
fetchOnFirstSubscribe: false,
variables: compact({ ...defaultOptions?.variables, ...variables }),
};
}, [options, query, defaultOptions]);
if (__DEV__) {
validateOptions(watchQueryOptions);
}
return watchQueryOptions;
}
function useIsDeferred(query: DocumentNode) {
return useMemo(() => hasDirectives(['defer'], query), [query]);
}
function useObservableQueryResult<TData>(observable: ObservableQuery<TData>) {
const resultRef = useRef<ApolloQueryResult<TData>>();
const isMountedRef = useRef(false);
if (!resultRef.current) {
resultRef.current = observable.getCurrentResult();
}
// React keeps refs and effects from useSyncExternalStore around after the
// component initially mounts even if the component re-suspends. We need to
// track when the component suspends/unsuspends to ensure we don't try and
// update the component while its suspended since the observable's
// `next` function is called before the promise resolved.
//
// Unlike useEffect, useLayoutEffect will run its cleanup and initialization
// functions each time a component is suspended.
useIsomorphicLayoutEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
return useSyncExternalStore(
useCallback(
(forceUpdate) => {
function handleUpdate() {
const previousResult = resultRef.current!;
const result = observable.getCurrentResult();
if (
previousResult.loading === result.loading &&
previousResult.networkStatus === result.networkStatus &&
equal(previousResult.data, result.data)
) {
return;
}
resultRef.current = result;
if (isMountedRef.current) {
forceUpdate();
}
}
const subscription = observable.subscribe({
next: handleUpdate,
error: handleUpdate,
});
return () => {
subscription.unsubscribe();
};
},
[observable]
),
() => resultRef.current!,
() => resultRef.current!
);
}