queryClient.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. import type {
  2. QueryClient,
  3. QueryClientConfig,
  4. QueryFunctionContext,
  5. SetDataOptions,
  6. Updater,
  7. UseQueryOptions,
  8. UseQueryResult,
  9. } from '@tanstack/react-query';
  10. import {useInfiniteQuery, useQuery} from '@tanstack/react-query';
  11. import type {APIRequestMethod, ApiResult, Client, ResponseMeta} from 'sentry/api';
  12. import type {ParsedHeader} from 'sentry/utils/parseLinkHeader';
  13. import parseLinkHeader from 'sentry/utils/parseLinkHeader';
  14. import type RequestError from 'sentry/utils/requestError/requestError';
  15. import useApi from 'sentry/utils/useApi';
  16. // Overrides to the default react-query options.
  17. // See https://tanstack.com/query/v4/docs/guides/important-defaults
  18. export const DEFAULT_QUERY_CLIENT_CONFIG: QueryClientConfig = {
  19. defaultOptions: {
  20. queries: {
  21. refetchOnWindowFocus: false,
  22. },
  23. },
  24. };
  25. // XXX: We need to set persistInFlight to disable query cancellation on
  26. // unmount. The current implementation of our API client does not
  27. // reject on query cancellation, which causes React Query to never
  28. // update from the isLoading state. This matches the library default
  29. // as well [0].
  30. //
  31. // This is slightly different from our typical usage of our api client
  32. // in components, where we do not want it to resolve, since we would
  33. // then have to guard our setState's against being unmounted.
  34. //
  35. // This has the advantage of storing the result in the cache as well.
  36. //
  37. // [0]: https://tanstack.com/query/v4/docs/guides/query-cancellation#default-behavior
  38. const PERSIST_IN_FLIGHT = true;
  39. type QueryKeyEndpointOptions<
  40. Headers = Record<string, string>,
  41. Query = Record<string, any>,
  42. Data = Record<string, any>,
  43. > = {
  44. data?: Data;
  45. headers?: Headers;
  46. method?: APIRequestMethod;
  47. query?: Query;
  48. };
  49. export type ApiQueryKey =
  50. | readonly [url: string]
  51. | readonly [
  52. url: string,
  53. options: QueryKeyEndpointOptions<
  54. Record<string, string>,
  55. Record<string, any>,
  56. Record<string, any>
  57. >,
  58. ];
  59. export interface UseApiQueryOptions<TApiResponse, TError = RequestError>
  60. extends Omit<
  61. UseQueryOptions<
  62. ApiResult<TApiResponse>,
  63. TError,
  64. ApiResult<TApiResponse>,
  65. ApiQueryKey
  66. >,
  67. // This is an explicit option in our function
  68. | 'queryKey'
  69. // This will always be a useApi api Query
  70. | 'queryFn'
  71. // We do not include the select option as this is difficult to make interop
  72. // with the way we extract data out of the ApiResult tuple
  73. | 'select'
  74. > {
  75. /**
  76. * staleTime is the amount of time (in ms) before cached data gets marked as stale.
  77. * Once data is marked stale, it will be refreshed on the next refetch event, which by default is when:
  78. * - The hook is mounted (configure with `refetchOnMount` option)
  79. * - The window is refocused (configure with `refetchOnWindowFocus` option)
  80. *
  81. * Use `staleTime: 0` if you need your data to always be up to date and don't mind excess refetches.
  82. * Be careful with this, especially if your hook is used at the root level or in multiple components.
  83. *
  84. * Use `staleTime: Infinity` if the data should never change, or changes very irregularly.
  85. * Note that the cached entries are garbage collected after 5 minutes of being unused (configure with `cacheTime`).
  86. *
  87. * Otherwise, provide a reasonable number (in ms) for your use case. Remember that the cache
  88. * can be updated or invalidated manually with QueryClient if you neeed to do so.
  89. */
  90. staleTime: number;
  91. }
  92. export type UseApiQueryResult<TData, TError> = UseQueryResult<TData, TError> & {
  93. /**
  94. * Get a header value from the response
  95. */
  96. getResponseHeader?: ResponseMeta['getResponseHeader'];
  97. };
  98. /**
  99. * Wraps React Query's useQuery for consistent usage in the Sentry app.
  100. * Query keys should be an array which include an endpoint URL and options such as query params.
  101. * This wrapper will execute the request using the query key URL.
  102. *
  103. * See https://tanstack.com/query/v4/docs/overview for docs on React Query.
  104. *
  105. * Example usage:
  106. *
  107. * const {data, isLoading, isError} = useQuery<EventsResponse>(
  108. * ['/events', {query: {limit: 50}}],
  109. * {staleTime: 0}
  110. * );
  111. */
  112. export function useApiQuery<TResponseData, TError = RequestError>(
  113. queryKey: ApiQueryKey,
  114. options: UseApiQueryOptions<TResponseData, TError>
  115. ): UseApiQueryResult<TResponseData, TError> {
  116. const api = useApi({persistInFlight: PERSIST_IN_FLIGHT});
  117. const queryFn = fetchDataQuery(api);
  118. const {data, ...rest} = useQuery(queryKey, queryFn, options);
  119. const queryResult = {
  120. data: data?.[0],
  121. getResponseHeader: data?.[2]?.getResponseHeader,
  122. ...rest,
  123. };
  124. // XXX: We need to cast here because unwrapping `data` breaks the type returned by
  125. // useQuery above. The react-query library's UseQueryResult is a union type and
  126. // too complex to recreate here so casting the entire object is more appropriate.
  127. return queryResult as UseApiQueryResult<TResponseData, TError>;
  128. }
  129. /**
  130. * This method, given an `api` will return a new method which can be used as a
  131. * default `queryFn` with `useApiQuery` or even the raw `useQuery` hook.
  132. *
  133. * This returned method, the `queryFn`, unwraps react-query's `QueryFunctionContext`
  134. * type into parts that will be passed into api.requestPromise
  135. *
  136. * See also: fetchInfiniteQuery & fetchMutation
  137. */
  138. export function fetchDataQuery(api: Client) {
  139. return function fetchDataQueryImpl(context: QueryFunctionContext<ApiQueryKey>) {
  140. const [url, opts] = context.queryKey;
  141. return api.requestPromise(url, {
  142. includeAllArgs: true,
  143. method: opts?.method ?? 'GET',
  144. data: opts?.data,
  145. query: opts?.query,
  146. headers: opts?.headers,
  147. });
  148. };
  149. }
  150. /**
  151. * Wraps React Query's queryClient.getQueryData to return only the cached API
  152. * response data. This does not include the ApiResult type. For that you can
  153. * manually call queryClient.getQueryData.
  154. */
  155. export function getApiQueryData<TResponseData>(
  156. queryClient: QueryClient,
  157. queryKey: ApiQueryKey
  158. ): TResponseData | undefined {
  159. return queryClient.getQueryData<ApiResult<TResponseData>>(queryKey)?.[0];
  160. }
  161. /**
  162. * Wraps React Query's queryClient.setQueryData to allow setting of API
  163. * response data without needing to provide a request object.
  164. */
  165. export function setApiQueryData<TResponseData>(
  166. queryClient: QueryClient,
  167. queryKey: ApiQueryKey,
  168. updater: Updater<TResponseData, TResponseData>,
  169. options?: SetDataOptions
  170. ): TResponseData | undefined {
  171. const previous = queryClient.getQueryData<ApiResult<TResponseData>>(queryKey);
  172. const newData =
  173. typeof updater === 'function'
  174. ? (updater as (input?: TResponseData) => TResponseData)(previous?.[0])
  175. : updater;
  176. const [_prevdata, prevStatusText, prevResponse] = previous ?? [
  177. undefined,
  178. undefined,
  179. undefined,
  180. ];
  181. const newResponse: ApiResult<TResponseData> = [newData, prevStatusText, prevResponse];
  182. queryClient.setQueryData(queryKey, newResponse, options);
  183. return newResponse[0];
  184. }
  185. /**
  186. * This method, given an `api` will return a new method which can be used as a
  187. * default `queryFn` with `useInfiniteQuery` hook.
  188. *
  189. * This returned method, the `queryFn`, unwraps react-query's `QueryFunctionContext`
  190. * type into parts that will be passed into api.requestPromise including the next
  191. * page cursor.
  192. *
  193. * See also: fetchDataQuery & fetchMutation
  194. */
  195. export function fetchInfiniteQuery<TResponseData>(api: Client) {
  196. return function fetchInfiniteQueryImpl({
  197. pageParam,
  198. queryKey,
  199. }: QueryFunctionContext<ApiQueryKey, undefined | ParsedHeader>): Promise<
  200. ApiResult<TResponseData>
  201. > {
  202. const [url, endpointOptions] = queryKey;
  203. return api.requestPromise(url, {
  204. includeAllArgs: true,
  205. headers: endpointOptions?.headers,
  206. query: {
  207. ...endpointOptions?.query,
  208. cursor: pageParam?.cursor,
  209. },
  210. });
  211. };
  212. }
  213. function parsePageParam(dir: 'previous' | 'next') {
  214. return ([, , resp]: ApiResult<unknown>) => {
  215. const parsed = parseLinkHeader(resp?.getResponseHeader('Link') ?? null);
  216. return parsed[dir].results ? parsed[dir] : null;
  217. };
  218. }
  219. /**
  220. * Wraps React Query's useInfiniteQuery for consistent usage in the Sentry app.
  221. * Query keys should be an array which include an endpoint URL and options such as query params.
  222. * This wrapper will execute the request using the query key URL.
  223. *
  224. * See https://tanstack.com/query/v4/docs/overview for docs on React Query.
  225. */
  226. export function useInfiniteApiQuery<TResponseData>({queryKey}: {queryKey: ApiQueryKey}) {
  227. const api = useApi({persistInFlight: PERSIST_IN_FLIGHT});
  228. return useInfiniteQuery({
  229. queryKey,
  230. queryFn: fetchInfiniteQuery<TResponseData>(api),
  231. getPreviousPageParam: parsePageParam('previous'),
  232. getNextPageParam: parsePageParam('next'),
  233. });
  234. }
  235. type ApiMutationVariables<
  236. Headers = Record<string, string>,
  237. Query = Record<string, any>,
  238. > =
  239. | ['PUT' | 'POST' | 'DELETE', string]
  240. | ['PUT' | 'POST' | 'DELETE', string, QueryKeyEndpointOptions<Headers, Query>]
  241. | [
  242. 'PUT' | 'POST' | 'DELETE',
  243. string,
  244. QueryKeyEndpointOptions<Headers, Query>,
  245. Record<string, unknown>,
  246. ];
  247. /**
  248. * This method, given an `api` will return a new method which can be used as a
  249. * default `queryFn` with `useMutation` hook.
  250. *
  251. * This returned method, the `queryFn`, unwraps react-query's `QueryFunctionContext`
  252. * type into parts that will be passed into api.requestPromise including different
  253. * `method` and supports putting & posting `data.
  254. *
  255. * See also: fetchDataQuery & fetchInfiniteQuery
  256. */
  257. export function fetchMutation(api: Client) {
  258. return function fetchMutationImpl(variables: ApiMutationVariables) {
  259. const [method, url, opts, data] = variables;
  260. return api.requestPromise(url, {
  261. method,
  262. query: opts?.query,
  263. headers: opts?.headers,
  264. data,
  265. });
  266. };
  267. }
  268. // eslint-disable-next-line import/export
  269. export * from '@tanstack/react-query';